From 3268670871e22d1e6d03eefd38927118ddcc5656 Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 20:35:26 -0700 Subject: [PATCH 01/12] feat(provider): add shared verification policy evaluator Centralize tier derivation, placement checks, and chain query mapping so indexer and marketplace paths apply the same AEP-86 rules. Signed-off-by: Joseph Chalabi --- packages/provider-verification/package.json | 30 ++ packages/provider-verification/src/index.ts | 12 + .../src/providerTierState.spec.ts | 149 ++++++++++ .../src/providerTierState.ts | 32 +++ .../src/providerVerification.spec.ts | 267 ++++++++++++++++++ .../src/providerVerification.ts | 191 +++++++++++++ .../providerVerificationQueryClient.spec.ts | 176 ++++++++++++ .../src/providerVerificationQueryClient.ts | 230 +++++++++++++++ .../provider-verification/tsconfig.build.json | 10 + packages/provider-verification/tsconfig.json | 5 + .../provider-verification/vitest.config.ts | 9 + 11 files changed, 1111 insertions(+) create mode 100644 packages/provider-verification/package.json create mode 100644 packages/provider-verification/src/index.ts create mode 100644 packages/provider-verification/src/providerTierState.spec.ts create mode 100644 packages/provider-verification/src/providerTierState.ts create mode 100644 packages/provider-verification/src/providerVerification.spec.ts create mode 100644 packages/provider-verification/src/providerVerification.ts create mode 100644 packages/provider-verification/src/providerVerificationQueryClient.spec.ts create mode 100644 packages/provider-verification/src/providerVerificationQueryClient.ts create mode 100644 packages/provider-verification/tsconfig.build.json create mode 100644 packages/provider-verification/tsconfig.json create mode 100644 packages/provider-verification/vitest.config.ts diff --git a/packages/provider-verification/package.json b/packages/provider-verification/package.json new file mode 100644 index 0000000000..7373ea7841 --- /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.41" + }, + "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" + } +}); From e2906a47c56135f6b1387d2dea7a24394b7333a2 Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 20:35:43 -0700 Subject: [PATCH 02/12] feat(indexer): ingest provider verification state Persist typed verification events and reconcile chain-backed records so expiry, maintenance, and tier state remain queryable after gaps or restarts. Signed-off-by: Joseph Chalabi --- .../0010_add_provider_verification.sql | 296 ++++++++++++ apps/indexer/drizzle/meta/_journal.json | 9 +- apps/indexer/drizzle/relations.ts | 148 +++++- apps/indexer/drizzle/schema.ts | 440 ++++++++++++++++++ apps/indexer/env/.env.sample | 4 + apps/indexer/package.json | 1 + apps/indexer/src/chain/chainSync.ts | 40 +- apps/indexer/src/index.ts | 9 + apps/indexer/src/indexers/index.ts | 6 +- .../providerVerificationEvent.spec.ts | 182 ++++++++ .../providerVerificationEvent.ts | 160 +++++++ .../providerVerificationIndexer.spec.ts | 75 +++ .../providerVerificationIndexer.ts | 134 ++++++ .../providerVerificationIndexerTables.spec.ts | 41 ++ ...roviderVerificationReconcileTarget.spec.ts | 47 ++ .../providerVerificationReconcileTarget.ts | 27 ++ .../providerVerificationReconciler.spec.ts | 144 ++++++ .../providerVerificationReconciler.ts | 125 +++++ .../providerVerificationRepository.spec.ts | 149 ++++++ .../providerVerificationRepository.ts | 345 ++++++++++++++ .../providerVerificationStateMapper.spec.ts | 133 ++++++ .../providerVerificationStateMapper.ts | 403 ++++++++++++++++ .../indexer/src/shared/utils/download.spec.ts | 36 ++ apps/indexer/src/shared/utils/download.ts | 6 +- apps/indexer/src/shared/utils/env.ts | 2 + packages/database/chainDefinitions.spec.ts | 35 ++ packages/database/chainDefinitions.ts | 154 +++++- packages/database/dbSchemas/akash/index.ts | 18 + packages/database/dbSchemas/akash/provider.ts | 18 +- .../dbSchemas/akash/providerMaintenance.ts | 26 ++ .../akash/verificationAttestation.ts | 35 ++ .../verificationAttestationCapability.ts | 17 + .../akash/verificationAuditEscrow.ts | 36 ++ .../verificationAuditEscrowCapability.ts | 19 + .../dbSchemas/akash/verificationAuditor.ts | 28 ++ .../dbSchemas/akash/verificationBlockEvent.ts | 21 + .../akash/verificationDiscrepancy.ts | 30 ++ .../dbSchemas/akash/verificationGrace.ts | 25 + .../akash/verificationGraceDiscrepancy.ts | 21 + .../dbSchemas/akash/verificationParams.ts | 15 + .../akash/verificationProviderBond.ts | 25 + .../verificationProviderBondUnbonding.ts | 22 + .../akash/verificationProviderObservation.ts | 20 + .../akash/verificationProviderSnapshot.ts | 38 ++ .../akash/verificationProviderTierDemotion.ts | 27 ++ .../akash/verificationProviderTierStream.ts | 13 + .../akash/verificationReconcileTarget.ts | 20 + packages/database/package.json | 5 +- 48 files changed, 3606 insertions(+), 24 deletions(-) create mode 100644 apps/indexer/drizzle/0010_add_provider_verification.sql create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationEvent.spec.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationEvent.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationIndexer.spec.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationIndexer.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationIndexerTables.spec.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationReconcileTarget.spec.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationReconcileTarget.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationReconciler.spec.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationReconciler.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationRepository.spec.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationRepository.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationStateMapper.spec.ts create mode 100644 apps/indexer/src/indexers/providerVerification/providerVerificationStateMapper.ts create mode 100644 apps/indexer/src/shared/utils/download.spec.ts create mode 100644 packages/database/chainDefinitions.spec.ts create mode 100644 packages/database/dbSchemas/akash/providerMaintenance.ts create mode 100644 packages/database/dbSchemas/akash/verificationAttestation.ts create mode 100644 packages/database/dbSchemas/akash/verificationAttestationCapability.ts create mode 100644 packages/database/dbSchemas/akash/verificationAuditEscrow.ts create mode 100644 packages/database/dbSchemas/akash/verificationAuditEscrowCapability.ts create mode 100644 packages/database/dbSchemas/akash/verificationAuditor.ts create mode 100644 packages/database/dbSchemas/akash/verificationBlockEvent.ts create mode 100644 packages/database/dbSchemas/akash/verificationDiscrepancy.ts create mode 100644 packages/database/dbSchemas/akash/verificationGrace.ts create mode 100644 packages/database/dbSchemas/akash/verificationGraceDiscrepancy.ts create mode 100644 packages/database/dbSchemas/akash/verificationParams.ts create mode 100644 packages/database/dbSchemas/akash/verificationProviderBond.ts create mode 100644 packages/database/dbSchemas/akash/verificationProviderBondUnbonding.ts create mode 100644 packages/database/dbSchemas/akash/verificationProviderObservation.ts create mode 100644 packages/database/dbSchemas/akash/verificationProviderSnapshot.ts create mode 100644 packages/database/dbSchemas/akash/verificationProviderTierDemotion.ts create mode 100644 packages/database/dbSchemas/akash/verificationProviderTierStream.ts create mode 100644 packages/database/dbSchemas/akash/verificationReconcileTarget.ts 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/env/.env.sample b/apps/indexer/env/.env.sample index 722f04005b..aa117a94a1 100644 --- a/apps/indexer/env/.env.sample +++ b/apps/indexer/env/.env.sample @@ -3,5 +3,9 @@ ACTIVE_CHAIN= AKASH_DATABASE_CS= AKASH_SANDBOX_DATABASE_CS= AKASH_TESTNET_DATABASE_CS= +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= KEEP_CACHE= DATA_FOLDER= diff --git a/apps/indexer/package.json b/apps/indexer/package.json index 80a5a94613..88f8b43dd7 100644 --- a/apps/indexer/package.json +++ b/apps/indexer/package.json @@ -36,6 +36,7 @@ "@akashnetwork/env-loader": "*", "@akashnetwork/instrumentation": "*", "@akashnetwork/logging": "*", + "@akashnetwork/provider-verification": "*", "@cosmjs/crypto": "~0.38.0", "@cosmjs/encoding": "~0.38.0", "@cosmjs/proto-signing": "~0.38.0", diff --git a/apps/indexer/src/chain/chainSync.ts b/apps/indexer/src/chain/chainSync.ts index 891abf0dff..04db097a75 100644 --- a/apps/indexer/src/chain/chainSync.ts +++ b/apps/indexer/src/chain/chainSync.ts @@ -1,6 +1,6 @@ import { activeChain, BME_VAULT_ADDRESS, IBC_USDC_DENOMS } from "@akashnetwork/database/chainDefinitions"; import { Block, Message } from "@akashnetwork/database/dbSchemas"; -import { BmeRawEvent } from "@akashnetwork/database/dbSchemas/akash"; +import { BmeRawEvent, VerificationBlockEvent } from "@akashnetwork/database/dbSchemas/akash"; import { Day, Transaction, TransactionEvent, TransactionEventAttribute } from "@akashnetwork/database/dbSchemas/base"; import { fromBase64 } from "@cosmjs/encoding"; import { decodeTxRaw } from "@cosmjs/proto-signing"; @@ -11,6 +11,7 @@ import { Op } from "sequelize"; import { sequelize } from "@src/db/dbConnection"; import { BME_BLOCK_EVENT_TYPE_VALUES, BME_EVENT_TYPES } from "@src/indexers/bmeIndexer"; +import { PROVIDER_VERIFICATION_EVENT_TYPES } from "@src/indexers/providerVerification/providerVerificationEvent"; import { ExecutionMode, executionMode, isProd, lastBlockToSync } from "@src/shared/constants"; import type { BlockResultType } from "@src/shared/types"; import { decodeIfBase64 } from "@src/shared/utils/base64"; @@ -77,12 +78,12 @@ export async function getSyncStatus(): Promise<{ }) )?.height; - const latestDateInDb = latestHeightInDb ? (await Block.findOne({ where: { height: latestHeightInDb } }))?.datetime ?? null : null; - const latestProcessedDateInDb = latestProcessedHeight ? (await Block.findOne({ where: { height: latestProcessedHeight } }))?.datetime ?? null : null; + const latestDateInDb = latestHeightInDb ? ((await Block.findOne({ where: { height: latestHeightInDb } }))?.datetime ?? null) : null; + const latestProcessedDateInDb = latestProcessedHeight ? ((await Block.findOne({ where: { height: latestProcessedHeight } }))?.datetime ?? null) : null; const latestNotificationProcessedHeight = firstNotificationUnprocessedMessage ? firstNotificationUnprocessedMessage - 1 : latestHeightInDb; const latestNotificationProcessedDateInDb = !activeChain.startHeight || latestNotificationProcessedHeight > activeChain.startHeight - ? (await Block.findOne({ where: { height: latestNotificationProcessedHeight } }))?.datetime ?? null + ? ((await Block.findOne({ where: { height: latestNotificationProcessedHeight } }))?.datetime ?? null) : null; return { @@ -192,6 +193,14 @@ async function insertBlocks(startHeight: number, endHeight: number) { let txsEventAttributesToAdd: any[] = []; let msgsToAdd: any[] = []; let bmeRawEventsToAdd: any[] = []; + let verificationBlockEventsToAdd: Array<{ + id: string; + height: number; + index: number; + type: string; + data: Record; + isProcessed: boolean; + }> = []; for (let i = startHeight; i <= endHeight; ++i) { const getCachedBlockTimer = benchmark.startTimer("getCachedBlockByHeight"); @@ -292,7 +301,22 @@ async function insertBlocks(startHeight: number, endHeight: number) { let bmeEventIndex = 0; let migrationBurnDetected = false; let migrationBurnedUakt = "0"; - for (const event of endBlockEvents) { + for (const [eventIndex, event] of endBlockEvents.entries()) { + if (env.PROVIDER_VERIFICATION_ENABLED && PROVIDER_VERIFICATION_EVENT_TYPES.includes(event.type)) { + const data: Record = {}; + for (const attr of event.attributes) { + data[decodeIfBase64(attr.key)] = attr.value ? decodeIfBase64(attr.value) : null; + } + verificationBlockEventsToAdd.push({ + id: randomUUID(), + height: i, + index: eventIndex, + type: event.type, + data, + isProcessed: false + }); + } + if ((BME_BLOCK_EVENT_TYPE_VALUES as readonly string[]).includes(event.type)) { const data: Record = {}; for (const attr of event.attributes) { @@ -431,12 +455,18 @@ async function insertBlocks(startHeight: number, endHeight: number) { await BmeRawEvent.bulkCreate(bmeRawEventsToAdd, { transaction: insertDbTransaction }); }); } + if (verificationBlockEventsToAdd.length > 0) { + await benchmark.measureAsync("createVerificationBlockEvents", async () => { + await VerificationBlockEvent.bulkCreate(verificationBlockEventsToAdd, { transaction: insertDbTransaction }); + }); + } blocksToAdd = []; txsToAdd = []; txsEventsToAdd = []; txsEventAttributesToAdd = []; msgsToAdd = []; bmeRawEventsToAdd = []; + verificationBlockEventsToAdd = []; console.log(`Blocks added to db: ${i - startHeight + 1} / ${blockCount} (${(((i - startHeight + 1) * 100) / blockCount).toFixed(2)}%)`); if (lastInsertedBlock) { diff --git a/apps/indexer/src/index.ts b/apps/indexer/src/index.ts index e438e7095d..f555b87120 100644 --- a/apps/indexer/src/index.ts +++ b/apps/indexer/src/index.ts @@ -2,6 +2,7 @@ import "@akashnetwork/env-loader"; import { activeChain, chainDefinitions } from "@akashnetwork/database/chainDefinitions"; import { LoggerService } from "@akashnetwork/logging"; +import { createProviderVerificationQueryClient } from "@akashnetwork/provider-verification"; import * as Sentry from "@sentry/node"; import { Hono } from "hono"; @@ -14,6 +15,7 @@ import { initDatabase } from "./db/buildDatabase"; import { sequelize } from "./db/dbConnection"; import { fetchValidatorKeybaseInfos } from "./db/keybaseProvider"; import { syncPriceHistory } from "./db/priceHistoryProvider"; +import { ProviderVerificationReconciler } from "./indexers/providerVerification/providerVerificationReconciler"; import { startServer } from "./lib/start-server/start-server"; import { updateProvidersLocation } from "./providers/ipLocationProvider"; import { syncProvidersInfo } from "./providers/providerStatusProvider"; @@ -128,6 +130,13 @@ function startScheduler() { scheduler.registerTask("Provider IP Lookup", () => updateProvidersLocation(), "30 minutes", true); scheduler.registerTask("USD Spending Tracker", () => updateUsdSpending(), "1 minute", true); scheduler.registerTask("Update provider uptime", () => updateProviderUptime(), "10 minutes", true); + + if (env.PROVIDER_VERIFICATION_ENABLED) { + const queryClient = createProviderVerificationQueryClient(env.PROVIDER_VERIFICATION_REST_API_URL ?? activeChain.apiUrl); + const reconciler = new ProviderVerificationReconciler(queryClient); + scheduler.registerTask("Refresh Provider Verification", () => reconciler.enqueueFullReconciliation(), "5 minutes", true); + scheduler.registerTask("Reconcile Provider Verification", () => reconciler.runBatch().then(() => undefined), "5 seconds", true); + } } if (!activeChain.startHeight) { diff --git a/apps/indexer/src/indexers/index.ts b/apps/indexer/src/indexers/index.ts index 1628aa0b75..23506be60d 100644 --- a/apps/indexer/src/indexers/index.ts +++ b/apps/indexer/src/indexers/index.ts @@ -1,5 +1,7 @@ import { activeChain } from "@akashnetwork/database/chainDefinitions"; +import { env } from "@src/shared/utils/env"; +import { ProviderVerificationIndexer } from "./providerVerification/providerVerificationIndexer"; import { AkashStatsIndexer } from "./akashStatsIndexer"; import { BmeIndexer } from "./bmeIndexer"; import type { Indexer } from "./indexer"; @@ -8,7 +10,9 @@ import { ValidatorIndexer } from "./validatorIndexer"; const validatorIndexer = new ValidatorIndexer(); const messageAddressesIndexer = new MessageAddressesIndexer(); -const customIndexers = [new AkashStatsIndexer(), new BmeIndexer()].filter(x => activeChain.customIndexers.includes(x.name)); +const customIndexers = [new AkashStatsIndexer(), new BmeIndexer(), ...(env.PROVIDER_VERIFICATION_ENABLED ? [new ProviderVerificationIndexer()] : [])].filter( + x => activeChain.customIndexers.includes(x.name) +); export const indexers: Indexer[] = activeChain.startHeight ? [...customIndexers, messageAddressesIndexer] diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationEvent.spec.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationEvent.spec.ts new file mode 100644 index 0000000000..e644eceb02 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationEvent.spec.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; + +import { parseProviderVerificationEventImpact, PROVIDER_VERIFICATION_EVENT_TYPES, type ProviderVerificationEventImpact } from "./providerVerificationEvent"; + +const EMPTY_IMPACT: ProviderVerificationEventImpact = { + providers: [], + auditors: [], + auditEscrowIds: [], + discrepancyIds: [], + graceIds: [], + maintenance: [] +}; + +const EXPECTED_EVENT_TYPES = [ + "akash.provider.v1beta4.EventProviderMaintenanceClosed", + "akash.provider.v1beta4.EventProviderMaintenanceOpened", + "akash.verification.v1.EventAttestationExpired", + "akash.verification.v1.EventAttestationReplaced", + "akash.verification.v1.EventAttestationRevoked", + "akash.verification.v1.EventAttestationSubmitted", + "akash.verification.v1.EventAttestationVoided", + "akash.verification.v1.EventAuditEscrowOpened", + "akash.verification.v1.EventAuditEscrowSettled", + "akash.verification.v1.EventAuditorBondPosted", + "akash.verification.v1.EventAuditorFrozen", + "akash.verification.v1.EventAuditorLapsed", + "akash.verification.v1.EventAuditorRegistered", + "akash.verification.v1.EventAuditorRemoved", + "akash.verification.v1.EventAuditorRenewed", + "akash.verification.v1.EventAuditorResigned", + "akash.verification.v1.EventDepositReturnedToAuditor", + "akash.verification.v1.EventDepositSlashed", + "akash.verification.v1.EventDiscrepancyDetected", + "akash.verification.v1.EventDiscrepancyResolved", + "akash.verification.v1.EventDiscrepancyTimedOut", + "akash.verification.v1.EventFeeEscrowed", + "akash.verification.v1.EventFeeReleasedToAuditor", + "akash.verification.v1.EventFeeReturnedToProvider", + "akash.verification.v1.EventProviderBondPosted", + "akash.verification.v1.EventProviderBondSlashed", + "akash.verification.v1.EventProviderBondWithdrawalCompleted", + "akash.verification.v1.EventProviderBondWithdrawalInitiated", + "akash.verification.v1.EventSnapshotHashPosted", + "akash.verification.v1.EventSnapshotResumed", + "akash.verification.v1.EventSnapshotSuspended", + "akash.verification.v1.EventVerificationGraceEnded", + "akash.verification.v1.EventVerificationGraceStarted" +]; + +describe("parseProviderVerificationEventImpact", () => { + it("covers every verification and provider maintenance event declared by the SDK", () => { + expect(PROVIDER_VERIFICATION_EVENT_TYPES).toEqual(EXPECTED_EVENT_TYPES); + }); + + it("parses an unordered persisted transaction event", () => { + expect( + parseProviderVerificationEventImpact({ + type: "akash.verification.v1.EventAttestationSubmitted", + attributes: [ + { key: "tier", value: "verification_tier_identified" }, + { key: "audit_escrow_id", value: "41" }, + { key: "auditor", value: "akash1auditor" }, + { key: "provider", value: "akash1provider" } + ] + }) + ).toEqual({ + ...EMPTY_IMPACT, + providers: ["akash1provider"], + auditors: ["akash1auditor"], + auditEscrowIds: ["41"] + }); + }); + + it("accepts a finalize-block event shape", () => { + const event = { + type: "akash.verification.v1.EventAttestationExpired", + attributes: [ + { key: "auditor", value: "akash1auditor", index: true }, + { key: "provider", value: "akash1provider", index: true }, + { key: "tier", value: "verification_tier_identified", index: false } + ] + }; + + expect(parseProviderVerificationEventImpact(event)).toEqual({ + ...EMPTY_IMPACT, + providers: ["akash1provider"], + auditors: ["akash1auditor"] + }); + }); + + it("decodes JSON-quoted typed-event values", () => { + expect( + parseProviderVerificationEventImpact({ + type: "akash.verification.v1.EventAuditorFrozen", + attributes: [ + { key: "discrepancy_id", value: '"9007199254740993"' }, + { key: "auditor", value: '"akash1auditor"' } + ] + }) + ).toEqual({ + ...EMPTY_IMPACT, + auditors: ["akash1auditor"], + discrepancyIds: ["9007199254740993"] + }); + }); + + it("deduplicates repeated attributes and sorts each impact set", () => { + expect( + parseProviderVerificationEventImpact({ + type: "akash.verification.v1.EventDiscrepancyDetected", + attributes: [ + { key: "auditor_b", value: "akash1z" }, + { key: "discrepancy_id", value: "9" }, + { key: "auditor_a", value: "akash1a" }, + { key: "provider", value: "akash1provider" }, + { key: "auditor_b", value: '"akash1z"' }, + { key: "discrepancy_id", value: '"9"' } + ] + }) + ).toEqual({ + ...EMPTY_IMPACT, + providers: ["akash1provider"], + auditors: ["akash1a", "akash1z"], + discrepancyIds: ["9"] + }); + }); + + it("returns both escrow records affected by an attestation replacement", () => { + expect( + parseProviderVerificationEventImpact({ + type: "akash.verification.v1.EventAttestationReplaced", + attributes: [ + { key: "new_audit_escrow_id", value: "12" }, + { key: "old_audit_escrow_id", value: "11" }, + { key: "provider", value: "akash1provider" }, + { key: "auditor", value: "akash1auditor" } + ] + }) + ).toEqual({ + ...EMPTY_IMPACT, + providers: ["akash1provider"], + auditors: ["akash1auditor"], + auditEscrowIds: ["11", "12"] + }); + }); + + it("returns a maintenance record identity", () => { + expect( + parseProviderVerificationEventImpact({ + type: "akash.provider.v1beta4.EventProviderMaintenanceOpened", + attributes: [ + { key: "maintenance_id", value: '"7"' }, + { key: "provider", value: '"akash1provider"' } + ] + }) + ).toEqual({ + ...EMPTY_IMPACT, + maintenance: [{ provider: "akash1provider", maintenanceId: "7" }] + }); + }); + + it("returns only the grace identity when grace-ended omits the provider", () => { + expect( + parseProviderVerificationEventImpact({ + type: "akash.verification.v1.EventVerificationGraceEnded", + attributes: [ + { key: "status", value: "verification_grace_status_expired" }, + { key: "grace_record_id", value: "25" } + ] + }) + ).toEqual({ ...EMPTY_IMPACT, graceIds: ["25"] }); + }); + + it("returns no impact for unknown events", () => { + expect( + parseProviderVerificationEventImpact({ + type: "akash.verification.v1.EventAttestationRemoved", + attributes: [{ key: "provider", value: "akash1provider" }] + }) + ).toEqual(EMPTY_IMPACT); + }); +}); diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationEvent.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationEvent.ts new file mode 100644 index 0000000000..e3a3b31515 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationEvent.ts @@ -0,0 +1,160 @@ +export type ProviderVerificationEventAttribute = { + key: string; + value: string | null; +}; + +export type ProviderVerificationEvent = { + type: string; + attributes?: readonly ProviderVerificationEventAttribute[]; +}; + +export type ProviderMaintenanceImpact = { + provider: string; + maintenanceId: string; +}; + +export type ProviderVerificationEventImpact = { + providers: string[]; + auditors: string[]; + auditEscrowIds: string[]; + discrepancyIds: string[]; + graceIds: string[]; + maintenance: ProviderMaintenanceImpact[]; +}; + +type ImpactField = Exclude; +type EventImpactDefinition = Partial> & { maintenance?: true }; + +const VERIFICATION_EVENT_PREFIX = "akash.verification.v1."; +const PROVIDER_EVENT_PREFIX = "akash.provider.v1beta4."; + +const EVENT_IMPACT_DEFINITIONS: Readonly> = { + [`${VERIFICATION_EVENT_PREFIX}EventAuditorRegistered`]: { auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAuditorBondPosted`]: { auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAuditorFrozen`]: { auditors: ["auditor"], discrepancyIds: ["discrepancy_id"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAuditorLapsed`]: { auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAuditorResigned`]: { auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAuditorRemoved`]: { auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAuditorRenewed`]: { auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAttestationSubmitted`]: { + providers: ["provider"], + auditors: ["auditor"], + auditEscrowIds: ["audit_escrow_id"] + }, + [`${VERIFICATION_EVENT_PREFIX}EventAttestationExpired`]: { providers: ["provider"], auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAttestationReplaced`]: { + providers: ["provider"], + auditors: ["auditor"], + auditEscrowIds: ["old_audit_escrow_id", "new_audit_escrow_id"] + }, + [`${VERIFICATION_EVENT_PREFIX}EventAttestationRevoked`]: { providers: ["provider"], auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAttestationVoided`]: { providers: ["provider"], auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventDiscrepancyDetected`]: { + providers: ["provider"], + auditors: ["auditor_a", "auditor_b"], + discrepancyIds: ["discrepancy_id"] + }, + [`${VERIFICATION_EVENT_PREFIX}EventDiscrepancyResolved`]: { + auditors: ["vindicated_auditor"], + discrepancyIds: ["discrepancy_id"] + }, + [`${VERIFICATION_EVENT_PREFIX}EventDiscrepancyTimedOut`]: { + auditors: ["auditor_a", "auditor_b"], + discrepancyIds: ["discrepancy_id"] + }, + [`${VERIFICATION_EVENT_PREFIX}EventProviderBondPosted`]: { providers: ["provider"] }, + [`${VERIFICATION_EVENT_PREFIX}EventProviderBondSlashed`]: { providers: ["provider"] }, + [`${VERIFICATION_EVENT_PREFIX}EventProviderBondWithdrawalInitiated`]: { providers: ["provider"] }, + [`${VERIFICATION_EVENT_PREFIX}EventProviderBondWithdrawalCompleted`]: { providers: ["provider"] }, + [`${VERIFICATION_EVENT_PREFIX}EventSnapshotHashPosted`]: { providers: ["provider"] }, + [`${VERIFICATION_EVENT_PREFIX}EventSnapshotSuspended`]: { providers: ["provider"] }, + [`${VERIFICATION_EVENT_PREFIX}EventSnapshotResumed`]: { providers: ["provider"] }, + [`${VERIFICATION_EVENT_PREFIX}EventFeeEscrowed`]: { providers: ["provider"], auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventFeeReleasedToAuditor`]: { auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventFeeReturnedToProvider`]: { providers: ["provider"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAuditEscrowOpened`]: { providers: ["provider"], auditEscrowIds: ["audit_escrow_id"] }, + [`${VERIFICATION_EVENT_PREFIX}EventAuditEscrowSettled`]: { auditEscrowIds: ["audit_escrow_id"] }, + [`${VERIFICATION_EVENT_PREFIX}EventDepositReturnedToAuditor`]: { auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventDepositSlashed`]: { auditors: ["auditor"] }, + [`${VERIFICATION_EVENT_PREFIX}EventVerificationGraceStarted`]: { providers: ["provider"], graceIds: ["grace_record_id"] }, + [`${VERIFICATION_EVENT_PREFIX}EventVerificationGraceEnded`]: { graceIds: ["grace_record_id"] }, + [`${PROVIDER_EVENT_PREFIX}EventProviderMaintenanceOpened`]: { maintenance: true }, + [`${PROVIDER_EVENT_PREFIX}EventProviderMaintenanceClosed`]: { maintenance: true } +}; + +export const PROVIDER_VERIFICATION_EVENT_TYPES = Object.freeze(Object.keys(EVENT_IMPACT_DEFINITIONS).sort()); + +export function parseProviderVerificationEventImpact(event: ProviderVerificationEvent): ProviderVerificationEventImpact { + const definition = EVENT_IMPACT_DEFINITIONS[event.type]; + const impact = createEmptyImpact(); + if (!definition) return impact; + + const attributes = collectAttributes(event.attributes ?? []); + for (const field of ["providers", "auditors", "auditEscrowIds", "discrepancyIds", "graceIds"] as const) { + impact[field] = collectValues(attributes, definition[field] ?? []); + } + + if (definition.maintenance) { + const providers = collectValues(attributes, ["provider"]); + const maintenanceIds = collectValues(attributes, ["maintenance_id"]); + impact.maintenance = providers + .flatMap(provider => maintenanceIds.map(maintenanceId => ({ provider, maintenanceId }))) + .sort((left, right) => left.provider.localeCompare(right.provider) || left.maintenanceId.localeCompare(right.maintenanceId)); + } + + return impact; +} + +function createEmptyImpact(): ProviderVerificationEventImpact { + return { + providers: [], + auditors: [], + auditEscrowIds: [], + discrepancyIds: [], + graceIds: [], + maintenance: [] + }; +} + +function collectAttributes(attributes: readonly ProviderVerificationEventAttribute[]): ReadonlyMap> { + const valuesByKey = new Map>(); + + for (const attribute of attributes) { + const value = parseAttributeValue(attribute.value); + if (value === null) continue; + + const values = valuesByKey.get(attribute.key) ?? new Set(); + values.add(value); + valuesByKey.set(attribute.key, values); + } + + return valuesByKey; +} + +function collectValues(attributes: ReadonlyMap>, keys: readonly string[]): string[] { + const values = new Set(); + for (const key of keys) { + for (const value of attributes.get(key) ?? []) { + values.add(value); + } + } + return [...values].sort(); +} + +function parseAttributeValue(value: string | null): string | null { + if (value === null) return null; + + const trimmed = value.trim(); + if (!trimmed) return null; + + if (/^-?\d+$/.test(trimmed)) return trimmed; + + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === "string") return parsed; + if (typeof parsed === "number" && Number.isFinite(parsed)) return String(parsed); + return null; + } catch { + return trimmed; + } +} diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationIndexer.spec.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationIndexer.spec.ts new file mode 100644 index 0000000000..cfc3882f61 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationIndexer.spec.ts @@ -0,0 +1,75 @@ +import type { Message, Transaction, TransactionEvent } from "@akashnetwork/database/dbSchemas/base"; +import type { Transaction as DbTransaction } from "sequelize"; +import { describe, expect, it, vi } from "vitest"; + +import { ProviderVerificationIndexer } from "./providerVerificationIndexer"; +import type { ProviderVerificationRepository } from "./providerVerificationRepository"; + +describe(ProviderVerificationIndexer.name, () => { + it("enqueues transaction-event identities in the surrounding database transaction", async () => { + const repository = createRepository(); + const indexer = new ProviderVerificationIndexer(repository); + const dbTransaction = {} as DbTransaction; + const transaction = { height: 123 } as Transaction; + const events = [ + { + type: "akash.verification.v1.EventAttestationSubmitted", + attributes: [ + { key: "provider", value: "akash1provider" }, + { key: "auditor", value: "akash1auditor" }, + { key: "audit_escrow_id", value: "8" } + ] + } + ] as TransactionEvent[]; + + await indexer.afterEveryTransaction({} as never, transaction, dbTransaction, events); + + expect(repository.enqueueMany).toHaveBeenCalledWith( + [ + { targetType: "audit_escrow", targetKey: "8" }, + { targetType: "auditor", targetKey: "akash1auditor" }, + { targetType: "provider", targetKey: "akash1provider" } + ], + 123, + dbTransaction + ); + }); + + it("uses the message fallback for provider removal because the SDK has no removal event", async () => { + const repository = createRepository(); + const indexer = new ProviderVerificationIndexer(repository); + const dbTransaction = {} as DbTransaction; + + await indexer.processMessage({ provider: "akash1provider", auditor: "akash1auditor" }, 124, dbTransaction, { + type: "/akash.verification.v1.MsgRemoveAttestation" + } as Message); + + expect(repository.enqueue).toHaveBeenCalledWith({ targetType: "provider", targetKey: "akash1provider" }, 124, dbTransaction); + }); + + it("queues all provider aggregates with a parameter update transaction", async () => { + const repository = createRepository(); + const indexer = new ProviderVerificationIndexer(repository); + const dbTransaction = {} as DbTransaction; + + await indexer.processMessage({}, 125, dbTransaction, { + type: "/akash.verification.v1.MsgUpdateParams" + } as Message); + + expect(repository.enqueue).toHaveBeenCalledWith({ targetType: "global", targetKey: "*" }, 125, dbTransaction); + expect(repository.enqueueAllProviders).toHaveBeenCalledWith(125, dbTransaction); + }); +}); + +function createRepository() { + return { + enqueue: vi.fn(), + enqueueAllProviders: vi.fn(), + enqueueMany: vi.fn(), + getUnprocessedBlockEvents: vi.fn(), + markBlockEventsProcessed: vi.fn() + } satisfies Pick< + ProviderVerificationRepository, + "enqueue" | "enqueueAllProviders" | "enqueueMany" | "getUnprocessedBlockEvents" | "markBlockEventsProcessed" + >; +} diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationIndexer.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationIndexer.ts new file mode 100644 index 0000000000..ae548ce545 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationIndexer.ts @@ -0,0 +1,134 @@ +import type * as verificationV1 from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import type { AkashBlock as Block } from "@akashnetwork/database/dbSchemas/akash"; +import { + ProviderMaintenance, + VerificationAttestation, + VerificationAttestationCapability, + VerificationAuditEscrow, + VerificationAuditEscrowCapability, + VerificationAuditor, + VerificationBlockEvent, + VerificationDiscrepancy, + VerificationGrace, + VerificationGraceDiscrepancy, + VerificationParams, + VerificationProviderBond, + VerificationProviderBondUnbonding, + VerificationProviderObservation, + VerificationProviderSnapshot, + VerificationProviderTierDemotion, + VerificationProviderTierStream, + VerificationReconcileTarget +} from "@akashnetwork/database/dbSchemas/akash"; +import type { Transaction, TransactionEvent } from "@akashnetwork/database/dbSchemas/base"; +import type { DecodedTxRaw } from "@cosmjs/proto-signing"; +import type { Transaction as DbTransaction } from "sequelize"; + +import type { IGenesis } from "@src/chain/genesisTypes"; +import { Indexer } from "../indexer"; +import { parseProviderVerificationEventImpact, PROVIDER_VERIFICATION_EVENT_TYPES } from "./providerVerificationEvent"; +import { toProviderVerificationReconcileTargets } from "./providerVerificationReconcileTarget"; +import { ProviderVerificationRepository } from "./providerVerificationRepository"; + +const providerVerificationEventTypes = new Set(PROVIDER_VERIFICATION_EVENT_TYPES); + +type VerificationEventRepository = Pick< + ProviderVerificationRepository, + "enqueue" | "enqueueAllProviders" | "enqueueMany" | "getUnprocessedBlockEvents" | "markBlockEventsProcessed" +>; + +export class ProviderVerificationIndexer extends Indexer { + constructor(private readonly repository: VerificationEventRepository = new ProviderVerificationRepository()) { + super(); + this.name = "ProviderVerificationIndexer"; + this.runForEveryBlocks = true; + this.processFailedTxs = false; + this.msgHandlers = { + "/akash.verification.v1.MsgRemoveAttestation": this.handleRemoveAttestation, + "/akash.verification.v1.MsgUpdateParams": this.handleUpdateParams + }; + } + + async dropTables(): Promise { + await VerificationProviderTierDemotion.drop({ cascade: true }); + await VerificationProviderTierStream.drop({ cascade: true }); + await VerificationGraceDiscrepancy.drop({ cascade: true }); + await VerificationAttestationCapability.drop({ cascade: true }); + await VerificationAuditEscrowCapability.drop({ cascade: true }); + await VerificationProviderBondUnbonding.drop({ cascade: true }); + await VerificationAttestation.drop({ cascade: true }); + await VerificationAuditEscrow.drop({ cascade: true }); + await VerificationProviderBond.drop({ cascade: true }); + await VerificationProviderObservation.drop({ cascade: true }); + await VerificationGrace.drop({ cascade: true }); + await ProviderMaintenance.drop({ cascade: true }); + await VerificationProviderSnapshot.drop({ cascade: true }); + await VerificationDiscrepancy.drop({ cascade: true }); + await VerificationAuditor.drop({ cascade: true }); + await VerificationParams.drop({ cascade: true }); + await VerificationReconcileTarget.drop({ cascade: true }); + await VerificationBlockEvent.drop({ cascade: true }); + } + + async createTables(): Promise { + await VerificationAuditor.sync({ force: false }); + await VerificationAttestation.sync({ force: false }); + await VerificationAttestationCapability.sync({ force: false }); + await VerificationAuditEscrow.sync({ force: false }); + await VerificationAuditEscrowCapability.sync({ force: false }); + await VerificationDiscrepancy.sync({ force: false }); + await VerificationGrace.sync({ force: false }); + await VerificationGraceDiscrepancy.sync({ force: false }); + await VerificationProviderBond.sync({ force: false }); + await VerificationProviderObservation.sync({ force: false }); + await VerificationProviderTierStream.sync({ force: false }); + await VerificationProviderTierStream.findOrCreate({ where: { id: 1 } }); + await VerificationProviderTierDemotion.sync({ force: false }); + await VerificationProviderBondUnbonding.sync({ force: false }); + await VerificationProviderSnapshot.sync({ force: false }); + await ProviderMaintenance.sync({ force: false }); + await VerificationParams.sync({ force: false }); + await VerificationReconcileTarget.sync({ force: false }); + await VerificationBlockEvent.sync({ force: false }); + } + + initCache(): Promise { + return Promise.resolve(); + } + + seed(_genesis: IGenesis): Promise { + return Promise.resolve(); + } + + async afterEveryTransaction( + _rawTx: DecodedTxRaw, + currentTransaction: Transaction, + dbTransaction: DbTransaction, + txEvents: TransactionEvent[] + ): Promise { + for (const event of txEvents) { + if (!providerVerificationEventTypes.has(event.type)) continue; + const impact = parseProviderVerificationEventImpact({ type: event.type, attributes: event.attributes }); + await this.repository.enqueueMany(toProviderVerificationReconcileTargets(impact), currentTransaction.height, dbTransaction); + } + } + + async afterEveryBlock(currentBlock: Block, _previousBlock: Block | null, dbTransaction: DbTransaction): Promise { + const events = await this.repository.getUnprocessedBlockEvents(currentBlock.height, dbTransaction); + for (const event of events) { + const attributes = Object.entries(event.data).map(([key, value]) => ({ key, value })); + const impact = parseProviderVerificationEventImpact({ type: event.type, attributes }); + await this.repository.enqueueMany(toProviderVerificationReconcileTargets(impact), currentBlock.height, dbTransaction); + } + if (events.length > 0) await this.repository.markBlockEventsProcessed(currentBlock.height, dbTransaction); + } + + private async handleRemoveAttestation(message: verificationV1.MsgRemoveAttestation, height: number, transaction: DbTransaction): Promise { + await this.repository.enqueue({ targetType: "provider", targetKey: message.provider }, height, transaction); + } + + private async handleUpdateParams(_message: verificationV1.Verification_MsgUpdateParams, height: number, transaction: DbTransaction): Promise { + await this.repository.enqueue({ targetType: "global", targetKey: "*" }, height, transaction); + await this.repository.enqueueAllProviders(height, transaction); + } +} diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationIndexerTables.spec.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationIndexerTables.spec.ts new file mode 100644 index 0000000000..7aafa92c49 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationIndexerTables.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ProviderVerificationIndexer } from "./providerVerificationIndexer"; + +const mocks = vi.hoisted(() => { + const model = () => ({ drop: vi.fn().mockResolvedValue(undefined), sync: vi.fn().mockResolvedValue(undefined) }); + const models = { + ProviderMaintenance: model(), + VerificationAttestation: model(), + VerificationAttestationCapability: model(), + VerificationAuditEscrow: model(), + VerificationAuditEscrowCapability: model(), + VerificationAuditor: model(), + VerificationBlockEvent: model(), + VerificationDiscrepancy: model(), + VerificationGrace: model(), + VerificationGraceDiscrepancy: model(), + VerificationParams: model(), + VerificationProviderBond: model(), + VerificationProviderBondUnbonding: model(), + VerificationProviderObservation: model(), + VerificationProviderSnapshot: model(), + VerificationProviderTierDemotion: model(), + VerificationProviderTierStream: { ...model(), findOrCreate: vi.fn().mockResolvedValue([]) }, + VerificationReconcileTarget: model() + }; + + return { models }; +}); + +vi.mock("@akashnetwork/database/dbSchemas/akash", () => mocks.models); + +describe(`${ProviderVerificationIndexer.name}.createTables`, () => { + it("creates tier tables and seeds the singleton stream", async () => { + await new ProviderVerificationIndexer().createTables(); + + expect(mocks.models.VerificationProviderTierStream.sync).toHaveBeenCalledWith({ force: false }); + expect(mocks.models.VerificationProviderTierStream.findOrCreate).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(mocks.models.VerificationProviderTierDemotion.sync).toHaveBeenCalledWith({ force: false }); + }); +}); diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationReconcileTarget.spec.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationReconcileTarget.spec.ts new file mode 100644 index 0000000000..22b926d8be --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationReconcileTarget.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import type { ProviderVerificationEventImpact } from "./providerVerificationEvent"; +import { toProviderVerificationReconcileTargets } from "./providerVerificationReconcileTarget"; + +describe(toProviderVerificationReconcileTargets.name, () => { + it("coalesces all directly queryable event identities", () => { + expect( + toProviderVerificationReconcileTargets( + impact({ + providers: ["akash1provider"], + auditors: ["akash1auditor"], + auditEscrowIds: ["7", "7"], + discrepancyIds: ["9"], + maintenance: [{ provider: "akash1provider", maintenanceId: "4" }] + }) + ) + ).toEqual([ + { targetType: "audit_escrow", targetKey: "7" }, + { targetType: "auditor", targetKey: "akash1auditor" }, + { targetType: "discrepancy", targetKey: "9" }, + { targetType: "provider", targetKey: "akash1provider" } + ]); + }); + + it("requests a provider sweep for a grace identity that cannot be queried directly", () => { + expect(toProviderVerificationReconcileTargets(impact({ graceIds: ["12"] }))).toEqual([{ targetType: "all_providers", targetKey: "*" }]); + }); + + it("uses the provider carried by grace-started events instead of a sweep", () => { + expect(toProviderVerificationReconcileTargets(impact({ providers: ["akash1provider"], graceIds: ["12"] }))).toEqual([ + { targetType: "provider", targetKey: "akash1provider" } + ]); + }); +}); + +function impact(overrides: Partial): ProviderVerificationEventImpact { + return { + providers: [], + auditors: [], + auditEscrowIds: [], + discrepancyIds: [], + graceIds: [], + maintenance: [], + ...overrides + }; +} diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationReconcileTarget.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationReconcileTarget.ts new file mode 100644 index 0000000000..8a8ff9d429 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationReconcileTarget.ts @@ -0,0 +1,27 @@ +import type { ProviderVerificationEventImpact } from "./providerVerificationEvent"; + +export type ProviderVerificationReconcileTargetType = "global" | "provider" | "auditor" | "audit_escrow" | "discrepancy" | "all_providers"; + +export interface ProviderVerificationReconcileTarget { + targetType: ProviderVerificationReconcileTargetType; + targetKey: string; +} + +export function toProviderVerificationReconcileTargets(impact: ProviderVerificationEventImpact): ProviderVerificationReconcileTarget[] { + const targets = new Map(); + const add = (targetType: ProviderVerificationReconcileTargetType, targetKey: string) => { + targets.set(`${targetType}:${targetKey}`, { targetType, targetKey }); + }; + + for (const provider of impact.providers) add("provider", provider); + for (const auditor of impact.auditors) add("auditor", auditor); + for (const auditEscrowId of impact.auditEscrowIds) add("audit_escrow", auditEscrowId); + for (const discrepancyId of impact.discrepancyIds) add("discrepancy", discrepancyId); + for (const maintenance of impact.maintenance) add("provider", maintenance.provider); + + // The chain exposes grace records by provider, not by grace ID. Grace-ended + // events omit the provider, so a bounded provider sweep is the only complete repair. + if (impact.graceIds.length > 0 && impact.providers.length === 0) add("all_providers", "*"); + + return [...targets.values()].sort((left, right) => left.targetType.localeCompare(right.targetType) || left.targetKey.localeCompare(right.targetKey)); +} diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationReconciler.spec.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationReconciler.spec.ts new file mode 100644 index 0000000000..4cf5e7cc10 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationReconciler.spec.ts @@ -0,0 +1,144 @@ +import type { ProviderVerificationQueryClient } from "@akashnetwork/provider-verification"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { ReconcileBlockSource, ReconcileRepository } from "./providerVerificationReconciler"; +import { ProviderVerificationReconciler } from "./providerVerificationReconciler"; +import type { ClaimedProviderVerificationTarget } from "./providerVerificationRepository"; + +const block = { height: 200, datetime: new Date("2026-08-24T12:00:00.000Z") }; + +describe(ProviderVerificationReconciler.name, () => { + const client = mock(); + const repository = mock(); + const blockSource = mock(); + + beforeEach(() => { + vi.resetAllMocks(); + blockSource.getLatestProcessedBlock.mockResolvedValue(block); + repository.hasDiscrepancies.mockResolvedValue(true); + repository.replaceGlobalState.mockResolvedValue(true); + repository.replaceProviderState.mockResolvedValue(true); + }); + + it("queues scheduled refreshes without invalidating the currently indexed view", async () => { + await new ProviderVerificationReconciler(client, repository, blockSource).enqueueFullReconciliation(); + + expect(repository.enqueue).toHaveBeenCalledWith({ targetType: "global", targetKey: "*" }, 200, undefined, false); + expect(repository.enqueueAllProviders).toHaveBeenCalledWith(200, undefined, false); + }); + + it("reconciles a provider at the latest locally processed height", async () => { + const target = providerTarget(); + repository.claimNext.mockResolvedValueOnce(target).mockResolvedValueOnce(null); + client.getProviderState.mockResolvedValue(emptyProviderState("200")); + + const processed = await new ProviderVerificationReconciler(client, repository, blockSource).runBatch(); + + expect(processed).toBe(1); + expect(client.getProviderState).toHaveBeenCalledWith("akash1provider", "200"); + expect(repository.replaceProviderState).toHaveBeenCalledWith(expect.objectContaining({ provider: "akash1provider", observedHeight: 200 })); + expect(repository.complete).toHaveBeenCalledWith(target, 200); + }); + + it("refreshes global records before writing grace references that are not indexed yet", async () => { + const target = providerTarget(); + repository.claimNext.mockResolvedValueOnce(target).mockResolvedValueOnce(null); + repository.hasDiscrepancies.mockResolvedValue(false); + client.getProviderState.mockResolvedValue({ + ...emptyProviderState("200"), + grace: { + id: 4n, + provider: "akash1provider", + preservedTier: 2, + startedAt: block.datetime, + expiresAt: new Date("2026-08-25T12:00:00.000Z"), + sourceDiscrepancyIds: [7n], + status: 1 + } + }); + client.getGlobalState.mockResolvedValue({ + observedHeight: "200", + params: { verificationModuleActive: true } as never, + auditors: [], + discrepancies: [] + }); + + await new ProviderVerificationReconciler(client, repository, blockSource).runBatch(); + + expect(repository.replaceGlobalState).toHaveBeenCalledBefore(repository.replaceProviderState); + }); + + it("resolves escrow invalidations back to their provider aggregate", async () => { + const target = { ...providerTarget(), targetType: "audit_escrow" as const, targetKey: "17" }; + repository.claimNext.mockResolvedValueOnce(target).mockResolvedValueOnce(null); + client.getAuditEscrow.mockResolvedValue({ provider: "akash1provider" } as Awaited>); + client.getProviderState.mockResolvedValue(emptyProviderState("200")); + + await new ProviderVerificationReconciler(client, repository, blockSource).runBatch(); + + expect(client.getAuditEscrow).toHaveBeenCalledWith("17", "200"); + expect(client.getProviderState).toHaveBeenCalledWith("akash1provider", "200"); + }); + + it("resolves discrepancy invalidations back to their provider aggregate", async () => { + const target = { ...providerTarget(), targetType: "discrepancy" as const, targetKey: "19" }; + repository.claimNext.mockResolvedValueOnce(target).mockResolvedValueOnce(null); + client.getDiscrepancy.mockResolvedValue({ provider: "akash1provider" } as Awaited>); + client.getGlobalState.mockResolvedValue({ + observedHeight: "200", + params: { verificationModuleActive: true } as never, + auditors: [], + discrepancies: [] + }); + client.getProviderState.mockResolvedValue(emptyProviderState("200")); + + await new ProviderVerificationReconciler(client, repository, blockSource).runBatch(); + + expect(client.getDiscrepancy).toHaveBeenCalledWith("19", "200"); + expect(client.getGlobalState).toHaveBeenCalledWith("200"); + expect(client.getProviderState).toHaveBeenCalledWith("akash1provider", "200"); + expect(repository.replaceGlobalState).toHaveBeenCalledBefore(repository.replaceProviderState); + }); + + it("keeps a failed target for bounded retry", async () => { + const target = providerTarget(); + const error = new Error("query unavailable"); + repository.claimNext.mockResolvedValueOnce(target).mockResolvedValueOnce(null); + client.getProviderState.mockRejectedValue(error); + + await new ProviderVerificationReconciler(client, repository, blockSource).runBatch(); + + expect(repository.fail).toHaveBeenCalledWith(target, error); + expect(repository.complete).not.toHaveBeenCalled(); + }); + + it("retries a global target when the canonical params response is incomplete", async () => { + const target = { ...providerTarget(), targetType: "global" as const, targetKey: "*" }; + repository.claimNext.mockResolvedValueOnce(target).mockResolvedValueOnce(null); + client.getGlobalState.mockResolvedValue({ observedHeight: "200", params: null, auditors: [], discrepancies: [] }); + + await new ProviderVerificationReconciler(client, repository, blockSource).runBatch(); + + expect(repository.replaceGlobalState).not.toHaveBeenCalled(); + expect(repository.fail).toHaveBeenCalledWith(target, expect.objectContaining({ message: "Provider verification params are missing at height 200" })); + }); +}); + +function providerTarget(): ClaimedProviderVerificationTarget { + return { targetType: "provider", targetKey: "akash1provider", requestedHeight: 100, attemptCount: 0 }; +} + +function emptyProviderState(observedHeight: string): Awaited> { + return { + provider: "akash1provider", + attestations: [], + auditEscrows: [], + bond: null, + requiredBondForCurrentTier: null, + grace: null, + maintenances: [], + snapshot: null, + observedHeight + }; +} diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationReconciler.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationReconciler.ts new file mode 100644 index 0000000000..cce3730bd9 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationReconciler.ts @@ -0,0 +1,125 @@ +import { Block } from "@akashnetwork/database/dbSchemas"; +import type { ProviderVerificationQueryClient } from "@akashnetwork/provider-verification"; +import type { Transaction as DbTransaction } from "sequelize"; + +import type { ClaimedProviderVerificationTarget } from "./providerVerificationRepository"; +import { ProviderVerificationRepository } from "./providerVerificationRepository"; +import { mapProviderVerificationGlobalState, mapProviderVerificationProviderState } from "./providerVerificationStateMapper"; + +type VerificationQueryClient = Pick; + +interface ReconcileBlock { + height: number; + datetime: Date; +} + +export interface ReconcileBlockSource { + getLatestProcessedBlock(): Promise; +} + +export interface ReconcileRepository { + claimNext(): Promise; + complete(target: ClaimedProviderVerificationTarget, processedHeight: number): Promise; + enqueue( + target: { targetType: "global" | "provider" | "all_providers"; targetKey: string }, + requestedHeight: number, + transaction?: DbTransaction, + invalidated?: boolean + ): Promise; + enqueueAllProviders(requestedHeight: number, transaction?: DbTransaction, invalidated?: boolean): Promise; + fail(target: ClaimedProviderVerificationTarget, error: unknown): Promise; + findAuditEscrowProvider(id: string): Promise; + hasDiscrepancies(ids: readonly string[]): Promise; + replaceGlobalState(rows: ReturnType): Promise; + replaceProviderState(rows: ReturnType): Promise; +} + +const defaultBlockSource: ReconcileBlockSource = { + async getLatestProcessedBlock() { + const block = await Block.findOne({ where: { isProcessed: true }, order: [["height", "DESC"]], attributes: ["height", "datetime"] }); + return block ? { height: block.height, datetime: block.datetime } : null; + } +}; + +export class ProviderVerificationReconciler { + constructor( + private readonly client: VerificationQueryClient, + private readonly repository: ReconcileRepository = new ProviderVerificationRepository(), + private readonly blockSource: ReconcileBlockSource = defaultBlockSource + ) {} + + async enqueueFullReconciliation(): Promise { + const block = await this.requireLatestProcessedBlock(); + await this.repository.enqueue({ targetType: "global", targetKey: "*" }, block.height, undefined, false); + await this.repository.enqueueAllProviders(block.height, undefined, false); + } + + async runBatch(limit = 25): Promise { + let processed = 0; + while (processed < limit) { + const target = await this.repository.claimNext(); + if (!target) break; + + try { + const block = await this.requireLatestProcessedBlock(target.requestedHeight); + await this.reconcileTarget(target, block); + await this.repository.complete(target, block.height); + } catch (error) { + await this.repository.fail(target, error); + } + processed++; + } + return processed; + } + + private async reconcileTarget(target: ClaimedProviderVerificationTarget, block: ReconcileBlock): Promise { + const height = block.height.toString(); + switch (target.targetType) { + case "global": + case "auditor": + await this.reconcileGlobal(height, block.datetime); + return; + case "discrepancy": { + const discrepancy = await this.client.getDiscrepancy(target.targetKey, height); + await this.reconcileGlobal(height, block.datetime); + if (discrepancy?.provider) await this.reconcileProvider(discrepancy.provider, height, block.datetime); + return; + } + case "provider": + await this.reconcileProvider(target.targetKey, height, block.datetime); + return; + case "audit_escrow": { + const escrow = await this.client.getAuditEscrow(target.targetKey, height); + const provider = escrow?.provider || (await this.repository.findAuditEscrowProvider(target.targetKey)); + if (provider) await this.reconcileProvider(provider, height, block.datetime); + return; + } + case "all_providers": + await this.repository.enqueueAllProviders(block.height); + return; + } + } + + private async reconcileGlobal(height: string, blockTime: Date): Promise { + const state = await this.client.getGlobalState(height); + if (!state.params) throw new Error(`Provider verification params are missing at height ${height}`); + await this.repository.replaceGlobalState(mapProviderVerificationGlobalState(state, blockTime)); + } + + private async reconcileProvider(provider: string, height: string, blockTime: Date): Promise { + const state = await this.client.getProviderState(provider, height); + const sourceDiscrepancyIds = state.grace?.sourceDiscrepancyIds.map(id => id.toString()) ?? []; + if (!(await this.repository.hasDiscrepancies(sourceDiscrepancyIds))) { + await this.reconcileGlobal(height, blockTime); + } + await this.repository.replaceProviderState(mapProviderVerificationProviderState(state, blockTime)); + } + + private async requireLatestProcessedBlock(minimumHeight = 0): Promise { + const block = await this.blockSource.getLatestProcessedBlock(); + if (!block || block.height < minimumHeight) { + throw new Error(`Provider verification state is not ready at height ${minimumHeight}`); + } + return block; + } +} diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationRepository.spec.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationRepository.spec.ts new file mode 100644 index 0000000000..cfec49713c --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationRepository.spec.ts @@ -0,0 +1,149 @@ +import { VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ProviderVerificationRepository } from "./providerVerificationRepository"; +import type { ProviderVerificationProviderRows } from "./providerVerificationStateMapper"; + +const mocks = vi.hoisted(() => { + const transaction = { id: "provider-replacement" }; + const model = () => ({ + bulkCreate: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + create: vi.fn().mockResolvedValue(undefined), + destroy: vi.fn().mockResolvedValue(0), + findAll: vi.fn().mockResolvedValue([]), + findByPk: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue([0]), + upsert: vi.fn().mockResolvedValue(undefined) + }); + const connection = { + query: vi.fn().mockResolvedValue([]), + transaction: vi.fn(async (callback: (transaction: { id: string }) => Promise) => callback(transaction)) + }; + const models = { + ProviderMaintenance: model(), + VerificationAttestation: model(), + VerificationAttestationCapability: model(), + VerificationAuditEscrow: model(), + VerificationAuditEscrowCapability: model(), + VerificationAuditor: model(), + VerificationBlockEvent: model(), + VerificationDiscrepancy: model(), + VerificationGrace: model(), + VerificationGraceDiscrepancy: model(), + VerificationParams: model(), + VerificationProviderBond: model(), + VerificationProviderBondUnbonding: model(), + VerificationProviderObservation: model(), + VerificationProviderSnapshot: model(), + VerificationProviderTierDemotion: model(), + VerificationReconcileTarget: { ...model(), sequelize: connection } + }; + + return { connection, models, transaction }; +}); + +vi.mock("@akashnetwork/database/dbSchemas/akash", () => mocks.models); + +describe(`${ProviderVerificationRepository.name}.replaceProviderState`, () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.connection.query.mockResolvedValue([]); + mocks.models.VerificationAuditEscrow.findAll.mockResolvedValue([]); + mocks.models.VerificationGrace.findAll.mockResolvedValue([]); + mocks.models.VerificationProviderObservation.findByPk.mockResolvedValue(null); + }); + + it("establishes the first tier observation without emitting a demotion", async () => { + const rows = providerRows(); + + await expect(new ProviderVerificationRepository().replaceProviderState(rows)).resolves.toBe(true); + + expect(mocks.models.VerificationProviderTierDemotion.create).not.toHaveBeenCalled(); + expect(mocks.models.VerificationProviderObservation.upsert).toHaveBeenCalledWith( + { + provider: rows.provider, + observedHeight: rows.observedHeight, + observedBlockTime: rows.observedBlockTime, + effectiveTier: rows.tierState.effectiveTier, + maxPlacementTier: rows.tierState.maxPlacementTier, + snapshotState: rows.tierState.snapshotState + }, + { transaction: mocks.transaction } + ); + }); + + it("inserts one demotion in the provider replacement transaction", async () => { + mocks.models.VerificationProviderObservation.findByPk.mockResolvedValue({ + observedHeight: 99, + effectiveTier: VerificationTier.verification_tier_established, + maxPlacementTier: VerificationTier.verification_tier_established, + snapshotState: "current" + }); + const rows = providerRows({ + tierState: { + effectiveTier: VerificationTier.verification_tier_verified, + maxPlacementTier: VerificationTier.verification_tier_identified, + snapshotState: "stale" + } + }); + + await expect(new ProviderVerificationRepository().replaceProviderState(rows)).resolves.toBe(true); + + expect(mocks.models.VerificationProviderTierDemotion.create).toHaveBeenCalledTimes(1); + expect(mocks.models.VerificationProviderTierDemotion.create).toHaveBeenCalledWith( + { + provider: rows.provider, + 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: rows.observedHeight, + observedBlockTime: rows.observedBlockTime + }, + { transaction: mocks.transaction } + ); + }); + + it.each([100, 101])("rejects an observation at or below the existing height %s", async observedHeight => { + mocks.models.VerificationProviderObservation.findByPk.mockResolvedValue({ + observedHeight, + effectiveTier: VerificationTier.verification_tier_established, + maxPlacementTier: VerificationTier.verification_tier_established, + snapshotState: "current" + }); + + await expect(new ProviderVerificationRepository().replaceProviderState(providerRows())).resolves.toBe(false); + + expect(mocks.models.VerificationAttestation.destroy).not.toHaveBeenCalled(); + expect(mocks.models.VerificationProviderTierDemotion.create).not.toHaveBeenCalled(); + expect(mocks.models.VerificationProviderObservation.upsert).not.toHaveBeenCalled(); + }); +}); + +function providerRows(overrides: Partial = {}): ProviderVerificationProviderRows { + return { + provider: "akash1provider", + observedHeight: 100, + observedBlockTime: new Date("2026-08-24T12:00:00.000Z"), + tierState: { + effectiveTier: VerificationTier.verification_tier_established, + maxPlacementTier: VerificationTier.verification_tier_established, + snapshotState: "current" + }, + attestations: [], + attestationCapabilities: [], + auditEscrows: [], + auditEscrowCapabilities: [], + bond: null, + bondUnbondingEntries: [], + grace: null, + graceDiscrepancies: [], + maintenances: [], + snapshot: null, + ...overrides + }; +} diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationRepository.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationRepository.ts new file mode 100644 index 0000000000..4b337dc427 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationRepository.ts @@ -0,0 +1,345 @@ +import { + ProviderMaintenance, + VerificationAttestation, + VerificationAttestationCapability, + VerificationAuditEscrow, + VerificationAuditEscrowCapability, + VerificationAuditor, + VerificationBlockEvent, + VerificationDiscrepancy, + VerificationGrace, + VerificationGraceDiscrepancy, + VerificationParams, + VerificationProviderBond, + VerificationProviderBondUnbonding, + VerificationProviderObservation, + VerificationProviderSnapshot, + VerificationProviderTierDemotion, + VerificationReconcileTarget +} from "@akashnetwork/database/dbSchemas/akash"; +import { + detectProviderTierDemotion, + type ProviderTierDemotionChange, + type ProviderTierState, + type SnapshotComplianceState +} from "@akashnetwork/provider-verification"; +import type { Transaction as DbTransaction } from "sequelize"; +import { Op, QueryTypes } from "sequelize"; + +import type { ProviderVerificationReconcileTarget, ProviderVerificationReconcileTargetType } from "./providerVerificationReconcileTarget"; +import type { ProviderVerificationGlobalRows, ProviderVerificationProviderRows } from "./providerVerificationStateMapper"; + +export interface ClaimedProviderVerificationTarget extends ProviderVerificationReconcileTarget { + requestedHeight: number; + attemptCount: number; +} + +export class ProviderVerificationRepository { + async enqueue(target: ProviderVerificationReconcileTarget, requestedHeight: number, transaction?: DbTransaction, invalidated = true): Promise { + await database().query( + `INSERT INTO verification_reconcile_target + (target_type, target_key, requested_height, invalidated, attempt_count) + VALUES (:targetType, :targetKey, :requestedHeight, :invalidated, 0) + ON CONFLICT (target_type, target_key) DO UPDATE + SET requested_height = GREATEST(verification_reconcile_target.requested_height, EXCLUDED.requested_height), + invalidated = verification_reconcile_target.invalidated OR EXCLUDED.invalidated`, + { replacements: { ...target, requestedHeight, invalidated }, transaction } + ); + } + + async enqueueMany(targets: readonly ProviderVerificationReconcileTarget[], requestedHeight: number, transaction?: DbTransaction): Promise { + for (const target of targets) { + await this.enqueue(target, requestedHeight, transaction); + } + } + + async enqueueAllProviders(requestedHeight: number, transaction?: DbTransaction, invalidated = true): Promise { + await database().query( + `INSERT INTO verification_reconcile_target + (target_type, target_key, requested_height, invalidated, attempt_count) + SELECT 'provider', owner, :requestedHeight, :invalidated, 0 + FROM provider + WHERE "deletedHeight" IS NULL + ON CONFLICT (target_type, target_key) DO UPDATE + SET requested_height = GREATEST(verification_reconcile_target.requested_height, EXCLUDED.requested_height), + invalidated = verification_reconcile_target.invalidated OR EXCLUDED.invalidated`, + { replacements: { requestedHeight, invalidated }, transaction } + ); + } + + async claimNext(): Promise { + const [target] = await database().query<{ + targetType: ProviderVerificationReconcileTargetType; + targetKey: string; + requestedHeight: number; + attemptCount: number; + }>( + `WITH candidate AS ( + SELECT target_type, target_key + FROM verification_reconcile_target + WHERE (claimed_at IS NULL OR claimed_at < NOW() - INTERVAL '5 minutes') + AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) + ORDER BY requested_height ASC, target_type ASC, target_key ASC + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE verification_reconcile_target AS target + SET claimed_at = NOW() + FROM candidate + WHERE target.target_type = candidate.target_type + AND target.target_key = candidate.target_key + RETURNING target.target_type AS "targetType", + target.target_key AS "targetKey", + target.requested_height AS "requestedHeight", + target.attempt_count AS "attemptCount"`, + { type: QueryTypes.SELECT } + ); + + return target ?? null; + } + + async complete(target: ClaimedProviderVerificationTarget, processedHeight: number): Promise { + const deleted = await VerificationReconcileTarget.destroy({ + where: { + targetType: target.targetType, + targetKey: target.targetKey, + requestedHeight: { [Op.lte]: processedHeight } + } + }); + + if (deleted === 0) { + await VerificationReconcileTarget.update( + { claimedAt: null, attemptCount: 0, nextAttemptAt: null, lastError: null }, + { where: { targetType: target.targetType, targetKey: target.targetKey } } + ); + } + } + + async fail(target: ClaimedProviderVerificationTarget, error: unknown): Promise { + const attemptCount = target.attemptCount + 1; + const delaySeconds = Math.min(300, 2 ** Math.min(attemptCount, 8)); + await VerificationReconcileTarget.update( + { + claimedAt: null, + attemptCount, + nextAttemptAt: new Date(Date.now() + delaySeconds * 1_000), + lastError: error instanceof Error ? error.message : String(error) + }, + { where: { targetType: target.targetType, targetKey: target.targetKey } } + ); + } + + async replaceGlobalState(rows: ProviderVerificationGlobalRows): Promise { + const observedHeight = globalObservedHeight(rows); + + return database().transaction(async transaction => { + await acquireReconciliationLock("global", transaction); + const currentHeight = await queryMaxObservedHeight(["verification_params", "verification_auditor", "verification_discrepancy"], transaction); + if (currentHeight > observedHeight) return false; + + if (rows.params) { + await VerificationParams.upsert({ ...rows.params }, { transaction }); + } else { + await VerificationParams.destroy({ where: {}, transaction }); + } + + const auditorAddresses = rows.auditors.map(row => row.address); + await VerificationAuditor.destroy({ where: auditorAddresses.length > 0 ? { address: { [Op.notIn]: auditorAddresses } } : {}, transaction }); + for (const row of rows.auditors) await VerificationAuditor.upsert({ ...row }, { transaction }); + + const discrepancyIds = rows.discrepancies.map(row => row.id); + await VerificationDiscrepancy.destroy({ where: discrepancyIds.length > 0 ? { id: { [Op.notIn]: discrepancyIds } } : {}, transaction }); + for (const row of rows.discrepancies) await VerificationDiscrepancy.upsert({ ...row }, { transaction }); + return true; + }); + } + + async replaceProviderState(rows: ProviderVerificationProviderRows): Promise { + return database().transaction(async transaction => { + const providerWhere = { provider: rows.provider }; + await acquireReconciliationLock(`provider:${rows.provider}`, transaction); + const currentObservation = await VerificationProviderObservation.findByPk(rows.provider, { + attributes: ["observedHeight", "effectiveTier", "maxPlacementTier", "snapshotState"], + transaction + }); + if (currentObservation && currentObservation.observedHeight >= rows.observedHeight) return false; + + const demotion = currentObservation ? createTierDemotion(currentObservation, rows) : null; + + const escrows = await VerificationAuditEscrow.findAll({ attributes: ["id"], where: providerWhere, transaction }); + const graces = await VerificationGrace.findAll({ attributes: ["id"], where: providerWhere, transaction }); + const escrowIds = escrows.map(record => record.id); + const graceIds = graces.map(record => record.id); + + await VerificationAttestationCapability.destroy({ where: providerWhere, transaction }); + if (escrowIds.length > 0) { + await VerificationAuditEscrowCapability.destroy({ where: { auditEscrowId: { [Op.in]: escrowIds } }, transaction }); + } + if (graceIds.length > 0) { + await VerificationGraceDiscrepancy.destroy({ where: { graceId: { [Op.in]: graceIds } }, transaction }); + } + await VerificationProviderBondUnbonding.destroy({ where: providerWhere, transaction }); + + await VerificationAttestation.destroy({ where: providerWhere, transaction }); + await VerificationAuditEscrow.destroy({ where: providerWhere, transaction }); + await VerificationProviderBond.destroy({ where: providerWhere, transaction }); + await VerificationGrace.destroy({ where: providerWhere, transaction }); + await ProviderMaintenance.destroy({ where: providerWhere, transaction }); + await VerificationProviderSnapshot.destroy({ where: providerWhere, transaction }); + + if (rows.attestations.length > 0) + await VerificationAttestation.bulkCreate( + rows.attestations.map(row => ({ ...row })), + { transaction } + ); + if (rows.attestationCapabilities.length > 0) { + await VerificationAttestationCapability.bulkCreate( + rows.attestationCapabilities.map(row => ({ ...row })), + { transaction } + ); + } + if (rows.auditEscrows.length > 0) + await VerificationAuditEscrow.bulkCreate( + rows.auditEscrows.map(row => ({ ...row })), + { transaction } + ); + if (rows.auditEscrowCapabilities.length > 0) { + await VerificationAuditEscrowCapability.bulkCreate( + rows.auditEscrowCapabilities.map(row => ({ ...row })), + { transaction } + ); + } + if (rows.bond) await VerificationProviderBond.create({ ...rows.bond }, { transaction }); + if (rows.bondUnbondingEntries.length > 0) { + await VerificationProviderBondUnbonding.bulkCreate( + rows.bondUnbondingEntries.map(row => ({ ...row })), + { transaction } + ); + } + if (rows.grace) await VerificationGrace.create({ ...rows.grace }, { transaction }); + if (rows.graceDiscrepancies.length > 0) { + await VerificationGraceDiscrepancy.bulkCreate( + rows.graceDiscrepancies.map(row => ({ ...row })), + { transaction } + ); + } + if (rows.maintenances.length > 0) + await ProviderMaintenance.bulkCreate( + rows.maintenances.map(row => ({ ...row })), + { transaction } + ); + if (rows.snapshot) await VerificationProviderSnapshot.create({ ...rows.snapshot }, { transaction }); + if (demotion) await VerificationProviderTierDemotion.create({ ...demotion }, { transaction }); + await VerificationProviderObservation.upsert( + { + provider: rows.provider, + observedHeight: rows.observedHeight, + observedBlockTime: rows.observedBlockTime, + effectiveTier: rows.tierState.effectiveTier, + maxPlacementTier: rows.tierState.maxPlacementTier, + snapshotState: rows.tierState.snapshotState + }, + { transaction } + ); + return true; + }); + } + + async findAuditEscrowProvider(id: string): Promise { + return (await VerificationAuditEscrow.findByPk(id, { attributes: ["provider"] }))?.provider ?? null; + } + + async hasDiscrepancies(ids: readonly string[]): Promise { + if (ids.length === 0) return true; + return (await VerificationDiscrepancy.count({ where: { id: { [Op.in]: ids } } })) === new Set(ids).size; + } + + async getUnprocessedBlockEvents(height: number, transaction: DbTransaction): Promise { + return VerificationBlockEvent.findAll({ where: { height, isProcessed: false }, order: [["index", "ASC"]], transaction }); + } + + async markBlockEventsProcessed(height: number, transaction: DbTransaction): Promise { + await VerificationBlockEvent.update({ isProcessed: true }, { where: { height, isProcessed: false }, transaction }); + } +} + +interface ProviderTierObservation { + effectiveTier: number; + maxPlacementTier: number; + snapshotState: string; +} + +interface ProviderTierDemotionRow { + provider: string; + previousEffectiveTier: number; + previousMaxPlacementTier: number; + previousSnapshotState: string; + currentEffectiveTier: number; + currentMaxPlacementTier: number; + currentSnapshotState: string; + changes: ProviderTierDemotionChange[]; + observedHeight: number; + observedBlockTime: Date; +} + +function createTierDemotion(current: ProviderTierObservation, rows: ProviderVerificationProviderRows): ProviderTierDemotionRow | null { + const previous: ProviderTierState = { + effectiveTier: current.effectiveTier, + maxPlacementTier: current.maxPlacementTier, + snapshotState: parseSnapshotState(current.snapshotState) + }; + const changes = detectProviderTierDemotion(previous, rows.tierState); + if (changes.length === 0) return null; + + return { + provider: rows.provider, + previousEffectiveTier: previous.effectiveTier, + previousMaxPlacementTier: previous.maxPlacementTier, + previousSnapshotState: previous.snapshotState, + currentEffectiveTier: rows.tierState.effectiveTier, + currentMaxPlacementTier: rows.tierState.maxPlacementTier, + currentSnapshotState: rows.tierState.snapshotState, + changes, + observedHeight: rows.observedHeight, + observedBlockTime: rows.observedBlockTime + }; +} + +function parseSnapshotState(value: string): SnapshotComplianceState { + switch (value) { + case "unknown": + case "not_posted": + case "current": + case "stale": + case "suspended": + return value; + default: + throw new Error(`Invalid provider verification snapshot state: ${value}`); + } +} + +async function queryMaxObservedHeight(tables: readonly string[], transaction: DbTransaction): Promise { + const selects = tables.map(table => `SELECT observed_height FROM ${table}`).join(" UNION ALL "); + const [result] = await database().query<{ height: number | null }>(`SELECT MAX(observed_height) AS height FROM (${selects}) observations`, { + transaction, + type: QueryTypes.SELECT + }); + return result?.height ?? 0; +} + +function globalObservedHeight(rows: ProviderVerificationGlobalRows): number { + return rows.observedHeight; +} + +async function acquireReconciliationLock(key: string, transaction: DbTransaction): Promise { + await database().query("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))", { + replacements: { key: `provider-verification:${key}` }, + transaction + }); +} + +function database() { + const connection = VerificationReconcileTarget.sequelize; + if (!connection) throw new Error("Provider verification models are not registered with a database connection"); + return connection; +} diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationStateMapper.spec.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationStateMapper.spec.ts new file mode 100644 index 0000000000..0a44e6f7e7 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationStateMapper.spec.ts @@ -0,0 +1,133 @@ +import type { ProviderVerificationGlobalState, ProviderVerificationProviderState } from "@akashnetwork/provider-verification"; +import { describe, expect, it } from "vitest"; + +import { mapProviderVerificationGlobalState, mapProviderVerificationProviderState } from "./providerVerificationStateMapper"; + +const observedBlockTime = new Date("2026-08-24T12:00:00.000Z"); + +describe("providerVerificationStateMapper", () => { + it("maps provider aggregates without losing bytes or bigint values", () => { + const state = { + provider: "akash1provider", + observedHeight: "1234", + attestations: [ + { + provider: "akash1provider", + auditor: "akash1auditor", + tier: 2, + capabilities: [3, 4], + evidenceHash: Uint8Array.from([1, 2]), + fee: { denom: "uakt", amount: "10" }, + feeStatus: 1, + createdAt: new Date("2026-08-01T00:00:00.000Z"), + expiresAt: new Date("2027-08-01T00:00:00.000Z"), + status: 1, + voidedReason: 0, + deposit: { denom: "uakt", amount: "20" }, + depositStatus: 1, + auditEscrowId: 9007199254740993n, + faultAttribution: 0 + } + ], + auditEscrows: [], + bond: { + provider: "akash1provider", + bondedAmount: { denom: "uakt", amount: "100" }, + unbondingEntries: [], + slashed: false, + lastSlashTime: undefined + }, + requiredBondForCurrentTier: { denom: "uakt", amount: "80" }, + grace: null, + maintenances: [], + snapshot: { + provider: "akash1provider", + snapshotHash: Uint8Array.from([9, 8]), + resourceSummary: { + totalGpus: 1, + totalVcpus: 16, + totalMemoryMb: 32768n, + totalStorageMb: 1000000n, + activeLeases: 4, + softwareVersion: "v0.16.0", + softwareSignature: new Uint8Array(), + softwareIdentity: undefined + }, + 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 + } + } as ProviderVerificationProviderState; + + const rows = mapProviderVerificationProviderState(state, observedBlockTime); + + expect(rows.attestations[0]).toMatchObject({ auditEscrowId: "9007199254740993", evidenceHash: Buffer.from([1, 2]), observedHeight: 1234 }); + expect(rows.attestationCapabilities).toEqual([ + expect.objectContaining({ capability: 3, provider: "akash1provider", auditor: "akash1auditor" }), + expect.objectContaining({ capability: 4, provider: "akash1provider", auditor: "akash1auditor" }) + ]); + expect(rows.bond).toMatchObject({ bondedAmount: "100", requiredForCurrentTierAmount: "80" }); + expect(rows.snapshot).toMatchObject({ snapshotHash: Buffer.from([9, 8]), totalMemoryMb: "32768", softwareSignature: null }); + expect(rows.tierState).toEqual({ effectiveTier: 2, maxPlacementTier: 2, snapshotState: "current" }); + }); + + it("serializes verification params and global bigint identities", () => { + const state = { + observedHeight: "99", + params: { verificationModuleActive: true }, + auditors: [], + discrepancies: [ + { + id: 9007199254740995n, + provider: "akash1provider", + auditorA: "akash1a", + auditorATier: 1, + auditorB: "akash1b", + auditorBTier: 3, + timestamp: observedBlockTime, + resolutionStatus: 1, + resolutionProposalId: 0n, + graceRecordId: 12n, + resolutionReason: 0, + faultAttribution: 0, + resolutionEvidenceHash: new Uint8Array() + } + ] + } as unknown as ProviderVerificationGlobalState; + + const rows = mapProviderVerificationGlobalState(state, observedBlockTime); + + expect(rows.params).toMatchObject({ id: 1, observedHeight: 99, params: { verification_module_active: true } }); + expect(rows.discrepancies[0]).toMatchObject({ id: "9007199254740995", graceRecordId: "12", resolutionEvidenceHash: null }); + }); + + it("persists an inactive verification module as false", () => { + const state = { + observedHeight: "100", + params: { verificationModuleActive: false }, + auditors: [], + discrepancies: [] + } as unknown as ProviderVerificationGlobalState; + + const rows = mapProviderVerificationGlobalState(state, observedBlockTime); + + expect(rows.params?.params).toMatchObject({ verification_module_active: false }); + }); + + it("rejects incomplete chain records instead of inventing timestamps", () => { + const state = { + provider: "akash1provider", + observedHeight: "1", + attestations: [], + auditEscrows: [], + bond: null, + requiredBondForCurrentTier: null, + grace: null, + snapshot: null, + maintenances: [{ record: undefined, status: 1 }] + } as ProviderVerificationProviderState; + + expect(() => mapProviderVerificationProviderState(state, observedBlockTime)).toThrow("maintenance.record is missing"); + }); +}); diff --git a/apps/indexer/src/indexers/providerVerification/providerVerificationStateMapper.ts b/apps/indexer/src/indexers/providerVerification/providerVerificationStateMapper.ts new file mode 100644 index 0000000000..ee524d79c2 --- /dev/null +++ b/apps/indexer/src/indexers/providerVerification/providerVerificationStateMapper.ts @@ -0,0 +1,403 @@ +import { Verification_Params } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { + deriveProviderTierState, + type ProviderTierState, + type ProviderVerificationGlobalState, + type ProviderVerificationProviderState +} from "@akashnetwork/provider-verification"; + +interface Observation { + observedHeight: number; + observedBlockTime: Date; +} + +export interface ProviderVerificationGlobalRows { + observedHeight: number; + params: (Observation & { id: number; params: Record }) | null; + auditors: Array< + Observation & { + address: string; + status: number; + maxAttestationTier: number; + bondDenom: string; + bondAmount: string; + bondStatus: number; + metadataHash: Buffer | null; + registeredAt: Date; + renewalDeadline: Date; + discrepancyCount: string; + bondUnbondingCompletionTime: Date | null; + } + >; + discrepancies: Array< + Observation & { + id: string; + provider: string; + auditorA: string; + auditorATier: number; + auditorB: string; + auditorBTier: number; + detectedAt: Date; + resolutionStatus: number; + resolutionProposalId: string; + graceRecordId: string; + resolutionReason: number; + faultAttribution: number; + resolutionEvidenceHash: Buffer | null; + } + >; +} + +export interface ProviderVerificationProviderRows { + provider: string; + observedHeight: number; + observedBlockTime: Date; + tierState: ProviderTierState; + attestations: Array< + Observation & { + provider: string; + auditor: string; + tier: number; + evidenceHash: Buffer; + feeDenom: string; + feeAmount: string; + feeStatus: number; + createdAt: Date; + expiresAt: Date; + status: number; + voidedReason: number; + depositDenom: string; + depositAmount: string; + depositStatus: number; + auditEscrowId: string; + faultAttribution: number; + } + >; + attestationCapabilities: Array; + auditEscrows: Array< + Observation & { + id: string; + provider: string; + consumedByAuditor: string; + requestedTier: number; + feeDenom: string; + feeAmount: string; + feeStatus: number; + providerDepositDenom: string; + providerDepositAmount: string; + providerDepositStatus: number; + status: number; + openedAt: Date; + consumedAt: Date | null; + expiresAt: Date; + metadataHash: Buffer | null; + settlementReason: number; + faultAttribution: number; + } + >; + auditEscrowCapabilities: Array; + bond: + | (Observation & { + provider: string; + bondedDenom: string; + bondedAmount: string; + requiredForCurrentTierDenom: string; + requiredForCurrentTierAmount: string; + slashed: boolean; + lastSlashTime: Date | null; + }) + | null; + bondUnbondingEntries: Array; + grace: + | (Observation & { + id: string; + provider: string; + preservedTier: number; + startedAt: Date; + expiresAt: Date; + status: number; + }) + | null; + graceDiscrepancies: Array; + maintenances: Array< + Observation & { + provider: string; + id: string; + maintenanceType: number; + startsAt: Date; + expectedEndsAt: Date; + openedAt: Date; + closedAt: Date | null; + metadataHash: Buffer | null; + status: number; + } + >; + snapshot: + | (Observation & { + provider: string; + snapshotHash: Buffer; + totalGpus: number; + totalVcpus: number; + totalMemoryMb: string; + totalStorageMb: string; + activeLeases: number; + softwareVersion: string; + softwareSignature: Buffer | null; + softwareIdentityVersion: string | null; + softwareArtifactRef: string | null; + softwareDigestAlgorithm: string | null; + softwareDigest: Buffer | null; + softwareSignatureType: string | null; + softwareIdentitySignature: Buffer | null; + softwareSignatureRef: string | null; + softwarePublicKeyRef: string | null; + postedAt: Date; + snapshotTimestamp: Date; + complianceDeadline: Date; + suspended: boolean; + }) + | null; +} + +export function mapProviderVerificationGlobalState(state: ProviderVerificationGlobalState, observedBlockTime: Date): ProviderVerificationGlobalRows { + const observation = createObservation(state.observedHeight, observedBlockTime); + + return { + observedHeight: observation.observedHeight, + params: state.params + ? { + id: 1, + params: serializeVerificationParams(state.params), + ...observation + } + : null, + auditors: state.auditors.map(auditor => ({ + address: auditor.address, + status: auditor.status, + maxAttestationTier: auditor.maxAttestationTier, + bondDenom: auditor.bondAmount?.denom ?? "", + bondAmount: auditor.bondAmount?.amount ?? "0", + bondStatus: auditor.bondStatus, + metadataHash: optionalBuffer(auditor.metadataHash), + registeredAt: requiredDate(auditor.registeredAt, "auditor.registeredAt"), + renewalDeadline: requiredDate(auditor.renewalDeadline, "auditor.renewalDeadline"), + discrepancyCount: auditor.discrepancyCount.toString(), + bondUnbondingCompletionTime: auditor.bondUnbondingCompletionTime ?? null, + ...observation + })), + discrepancies: state.discrepancies.map(discrepancy => ({ + id: discrepancy.id.toString(), + provider: discrepancy.provider, + auditorA: discrepancy.auditorA, + auditorATier: discrepancy.auditorATier, + auditorB: discrepancy.auditorB, + auditorBTier: discrepancy.auditorBTier, + detectedAt: requiredDate(discrepancy.timestamp, "discrepancy.timestamp"), + resolutionStatus: discrepancy.resolutionStatus, + resolutionProposalId: discrepancy.resolutionProposalId.toString(), + graceRecordId: discrepancy.graceRecordId.toString(), + resolutionReason: discrepancy.resolutionReason, + faultAttribution: discrepancy.faultAttribution, + resolutionEvidenceHash: optionalBuffer(discrepancy.resolutionEvidenceHash), + ...observation + })) + }; +} + +export function mapProviderVerificationProviderState(state: ProviderVerificationProviderState, observedBlockTime: Date): ProviderVerificationProviderRows { + const observation = createObservation(state.observedHeight, observedBlockTime); + const tierState = deriveProviderTierState({ + attestations: state.attestations, + graces: state.grace ? [state.grace] : [], + snapshot: state.snapshot, + completeness: { attestations: true, graces: true, snapshot: true }, + observedAt: observedBlockTime, + observedHeight: state.observedHeight + }); + const attestations = state.attestations.map(attestation => ({ + provider: attestation.provider, + auditor: attestation.auditor, + tier: attestation.tier, + evidenceHash: Buffer.from(attestation.evidenceHash), + feeDenom: attestation.fee?.denom ?? "", + feeAmount: attestation.fee?.amount ?? "0", + feeStatus: attestation.feeStatus, + createdAt: requiredDate(attestation.createdAt, "attestation.createdAt"), + expiresAt: requiredDate(attestation.expiresAt, "attestation.expiresAt"), + status: attestation.status, + voidedReason: attestation.voidedReason, + depositDenom: attestation.deposit?.denom ?? "", + depositAmount: attestation.deposit?.amount ?? "0", + depositStatus: attestation.depositStatus, + auditEscrowId: attestation.auditEscrowId.toString(), + faultAttribution: attestation.faultAttribution, + ...observation + })); + const auditEscrows = state.auditEscrows.map(escrow => ({ + id: escrow.id.toString(), + provider: escrow.provider, + consumedByAuditor: escrow.consumedByAuditor, + requestedTier: escrow.requestedTier, + feeDenom: escrow.fee?.denom ?? "", + feeAmount: escrow.fee?.amount ?? "0", + feeStatus: escrow.feeStatus, + providerDepositDenom: escrow.providerDeposit?.denom ?? "", + providerDepositAmount: escrow.providerDeposit?.amount ?? "0", + providerDepositStatus: escrow.providerDepositStatus, + status: escrow.status, + openedAt: requiredDate(escrow.openedAt, "auditEscrow.openedAt"), + consumedAt: escrow.consumedAt ?? null, + expiresAt: requiredDate(escrow.expiresAt, "auditEscrow.expiresAt"), + metadataHash: optionalBuffer(escrow.metadataHash), + settlementReason: escrow.settlementReason, + faultAttribution: escrow.faultAttribution, + ...observation + })); + const grace = state.grace + ? { + id: state.grace.id.toString(), + provider: state.grace.provider, + preservedTier: state.grace.preservedTier, + startedAt: requiredDate(state.grace.startedAt, "grace.startedAt"), + expiresAt: requiredDate(state.grace.expiresAt, "grace.expiresAt"), + status: state.grace.status, + ...observation + } + : null; + const requiredBond = state.bond ? requiredCoin(state.requiredBondForCurrentTier, "requiredBondForCurrentTier") : null; + const bond = state.bond + ? { + provider: state.bond.provider, + bondedDenom: state.bond.bondedAmount?.denom ?? "", + bondedAmount: state.bond.bondedAmount?.amount ?? "0", + requiredForCurrentTierDenom: requiredBond!.denom, + requiredForCurrentTierAmount: requiredBond!.amount, + slashed: state.bond.slashed, + lastSlashTime: state.bond.lastSlashTime ?? null, + ...observation + } + : null; + const snapshot = mapSnapshot(state, observation); + + return { + provider: state.provider, + observedHeight: observation.observedHeight, + observedBlockTime: observation.observedBlockTime, + tierState, + attestations, + attestationCapabilities: state.attestations.flatMap(attestation => + attestation.capabilities.map(capability => ({ + provider: attestation.provider, + auditor: attestation.auditor, + capability, + ...observation + })) + ), + auditEscrows, + auditEscrowCapabilities: state.auditEscrows.flatMap(escrow => + escrow.requestedCapabilities.map(capability => ({ auditEscrowId: escrow.id.toString(), capability, ...observation })) + ), + bond, + bondUnbondingEntries: + state.bond?.unbondingEntries.map((entry, entryIndex) => ({ + provider: state.bond!.provider, + entryIndex, + denom: entry.amount?.denom ?? "", + amount: entry.amount?.amount ?? "0", + completionTime: requiredDate(entry.completionTime, "bond.unbondingEntry.completionTime"), + ...observation + })) ?? [], + grace, + graceDiscrepancies: + state.grace?.sourceDiscrepancyIds.map(discrepancyId => ({ + graceId: state.grace!.id.toString(), + discrepancyId: discrepancyId.toString(), + ...observation + })) ?? [], + maintenances: state.maintenances.map(maintenance => { + const record = maintenance.record; + if (!record) throw new Error("Invalid provider verification response: maintenance.record is missing"); + + return { + provider: record.provider, + id: record.id.toString(), + maintenanceType: record.maintenanceType, + startsAt: requiredDate(record.startsAt, "maintenance.startsAt"), + expectedEndsAt: requiredDate(record.expectedEndsAt, "maintenance.expectedEndsAt"), + openedAt: requiredDate(record.openedAt, "maintenance.openedAt"), + closedAt: record.closedAt ?? null, + metadataHash: optionalBuffer(record.metadataHash), + status: maintenance.status, + ...observation + }; + }), + snapshot + }; +} + +function mapSnapshot(state: ProviderVerificationProviderState, observation: Observation): ProviderVerificationProviderRows["snapshot"] { + if (!state.snapshot) return null; + const summary = state.snapshot.resourceSummary; + if (!summary) throw new Error("Invalid provider verification response: snapshot.resourceSummary is missing"); + const identity = summary.softwareIdentity; + + return { + provider: state.snapshot.provider, + snapshotHash: Buffer.from(state.snapshot.snapshotHash), + totalGpus: summary.totalGpus, + totalVcpus: summary.totalVcpus, + totalMemoryMb: summary.totalMemoryMb.toString(), + totalStorageMb: summary.totalStorageMb.toString(), + activeLeases: summary.activeLeases, + softwareVersion: summary.softwareVersion, + softwareSignature: optionalBuffer(summary.softwareSignature), + softwareIdentityVersion: identity?.version || null, + softwareArtifactRef: identity?.artifactRef || null, + softwareDigestAlgorithm: identity?.digestAlgorithm || null, + softwareDigest: optionalBuffer(identity?.digest), + softwareSignatureType: identity?.signatureType || null, + softwareIdentitySignature: optionalBuffer(identity?.signature), + softwareSignatureRef: identity?.signatureRef || null, + softwarePublicKeyRef: identity?.publicKeyRef || null, + postedAt: requiredDate(state.snapshot.postedAt, "snapshot.postedAt"), + snapshotTimestamp: requiredDate(state.snapshot.snapshotTimestamp, "snapshot.snapshotTimestamp"), + complianceDeadline: requiredDate(state.snapshot.complianceDeadline, "snapshot.complianceDeadline"), + suspended: state.snapshot.suspended, + ...observation + }; +} + +function createObservation(height: string, observedBlockTime: Date): Observation { + const observedHeight = Number(height); + if (!Number.isSafeInteger(observedHeight) || observedHeight < 0) { + throw new Error(`Invalid provider verification observation height: ${height}`); + } + if (Number.isNaN(observedBlockTime.getTime())) { + throw new Error("Invalid provider verification observation block time"); + } + + return { observedHeight, observedBlockTime }; +} + +function serializeVerificationParams(params: Verification_Params): Record { + return { + ...(Verification_Params.toJSON(params) as Record), + verification_module_active: params.verificationModuleActive + }; +} + +function requiredDate(value: Date | undefined, field: string): Date { + if (!value || Number.isNaN(value.getTime())) { + throw new Error(`Invalid provider verification response: ${field} is missing`); + } + return value; +} + +function optionalBuffer(value: Uint8Array | undefined): Buffer | null { + return value?.length ? Buffer.from(value) : null; +} + +function requiredCoin(value: { denom: string; amount: string } | null, field: string): { denom: string; amount: string } { + if (!value) throw new Error(`Invalid provider verification response: ${field} is missing`); + return value; +} diff --git a/apps/indexer/src/shared/utils/download.spec.ts b/apps/indexer/src/shared/utils/download.spec.ts new file mode 100644 index 0000000000..807cfeb984 --- /dev/null +++ b/apps/indexer/src/shared/utils/download.spec.ts @@ -0,0 +1,36 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { download } from "./download"; + +describe(download.name, () => { + const cleanup: Array<() => Promise> = []; + + afterEach(async () => { + await Promise.all(cleanup.splice(0).map(dispose => dispose())); + }); + + it("downloads from an HTTP endpoint", async () => { + const directory = await mkdtemp(join(tmpdir(), "indexer-download-")); + const destination = join(directory, "genesis.json"); + const server = createServer((_request, response) => response.end('{"chain_id":"aep-86"}')); + cleanup.push( + () => new Promise((resolve, reject) => server.close(error => (error ? reject(error) : resolve()))), + () => rm(directory, { recursive: true }) + ); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected an IP listener"); + + await download(`http://127.0.0.1:${address.port}/genesis.json`, destination); + + await expect(readFile(destination, "utf8")).resolves.toBe('{"chain_id":"aep-86"}'); + }); +}); diff --git a/apps/indexer/src/shared/utils/download.ts b/apps/indexer/src/shared/utils/download.ts index a9b17e1a23..77579bdd32 100644 --- a/apps/indexer/src/shared/utils/download.ts +++ b/apps/indexer/src/shared/utils/download.ts @@ -1,5 +1,6 @@ import fs from "fs"; -import http from "https"; +import http from "node:http"; +import https from "node:https"; import { basename } from "path"; import { bytesToHumanReadableSize } from "./files"; @@ -12,9 +13,10 @@ export async function download(url: string, path: string): Promise { path = basename(uri.pathname); } const file = fs.createWriteStream(path); + const client = uri.protocol === "http:" ? http : https; return new Promise(function (resolve, reject) { - http.get(uri.href).on("response", function (res) { + client.get(uri.href).on("response", function (res) { const len = parseInt(res.headers["content-length"] ?? "0", 10); let downloaded = 0; let lastProgressLog = Date.now(); diff --git a/apps/indexer/src/shared/utils/env.ts b/apps/indexer/src/shared/utils/env.ts index 082dfaaf0f..f6a347f0f7 100644 --- a/apps/indexer/src/shared/utils/env.ts +++ b/apps/indexer/src/shared/utils/env.ts @@ -14,6 +14,8 @@ export const env = { PASSAGE_DATABASE_CS: process.env.PASSAGE_DATABASE_CS, JUNO_DATABASE_CS: process.env.JUNO_DATABASE_CS, ACTIVE_CHAIN: process.env.ACTIVE_CHAIN, + PROVIDER_VERIFICATION_ENABLED: process.env.PROVIDER_VERIFICATION_ENABLED === "true", + PROVIDER_VERIFICATION_REST_API_URL: process.env.PROVIDER_VERIFICATION_REST_API_URL, KEEP_CACHE: process.env.KEEP_CACHE === "true", STANDBY: process.env.STANDBY === "true", DATA_FOLDER: process.env.DATA_FOLDER ?? "./data", 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" } } From ac4dad9dd44608d055969572a4ef6c370bdaae0c Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 20:35:58 -0700 Subject: [PATCH 03/12] feat(bid): screen providers by verification policy Poll normalized verification facts with provider inventory and reject bids that do not satisfy the placement policy before ranking eligible providers. Signed-off-by: Joseph Chalabi --- .../0005_add_provider_verification.sql | 1 + .../drizzle/meta/0005_snapshot.json | 275 ++++++++++++++++++ .../drizzle/meta/_journal.json | 9 +- apps/provider-inventory/package.json | 1 + .../src/config/env.config.ts | 1 + .../bid-screening/bid-screening.controller.ts | 3 +- .../http-schemas/bid-screening.schema.spec.ts | 52 ++++ .../src/http-schemas/bid-screening.schema.ts | 91 +++++- .../groupspec-mapper/groupspec-mapper.ts | 20 +- .../provider-verification-mapper.spec.ts | 100 +++++++ .../provider-verification-mapper.ts | 69 +++++ .../provider-inventory.schema.ts | 2 + .../provider-inventory/src/providers/index.ts | 1 + ...ider-verification-query-client.provider.ts | 14 + .../bid-screening.repository.integration.ts | 34 ++- .../bid-screening/bid-screening.repository.ts | 12 +- ...ovider-inventory.repository.integration.ts | 48 ++- .../provider-inventory.repository.ts | 24 +- .../bid-screening.service.spec.ts | 173 ++++++++++- .../bid-screening/bid-screening.service.ts | 99 ++++++- .../chain-provider-poller.service.spec.ts | 146 +++++++++- .../chain-provider-poller.service.ts | 91 +++++- .../discovery-scheduler.service.spec.ts | 30 +- .../discovery-scheduler.service.ts | 16 +- .../src/types/chain-provider.ts | 5 + .../provider-inventory/src/types/inventory.ts | 21 ++ .../src/types/provider-verification.ts | 33 +++ .../functional/discovery-pipeline.spec.ts | 29 ++ 28 files changed, 1342 insertions(+), 58 deletions(-) create mode 100644 apps/provider-inventory/drizzle/0005_add_provider_verification.sql create mode 100644 apps/provider-inventory/drizzle/meta/0005_snapshot.json create mode 100644 apps/provider-inventory/src/http-schemas/bid-screening.schema.spec.ts create mode 100644 apps/provider-inventory/src/mappers/provider-verification-mapper/provider-verification-mapper.spec.ts create mode 100644 apps/provider-inventory/src/mappers/provider-verification-mapper/provider-verification-mapper.ts create mode 100644 apps/provider-inventory/src/providers/provider-verification-query-client.provider.ts create mode 100644 apps/provider-inventory/src/types/provider-verification.ts diff --git a/apps/provider-inventory/drizzle/0005_add_provider_verification.sql b/apps/provider-inventory/drizzle/0005_add_provider_verification.sql new file mode 100644 index 0000000000..8392a6ff6b --- /dev/null +++ b/apps/provider-inventory/drizzle/0005_add_provider_verification.sql @@ -0,0 +1 @@ +ALTER TABLE "provider_inventory" ADD COLUMN "verification" jsonb; \ No newline at end of file diff --git a/apps/provider-inventory/drizzle/meta/0005_snapshot.json b/apps/provider-inventory/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000000..5f39785b02 --- /dev/null +++ b/apps/provider-inventory/drizzle/meta/0005_snapshot.json @@ -0,0 +1,275 @@ +{ + "id": "50ff95c1-cb89-47bd-8bf7-095331fac248", + "prevId": "d65bf4ae-2ed9-4979-a847-fd2673014509", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.provider_incidents": { + "name": "provider_incidents", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_incidents_open_per_provider": { + "name": "uniq_provider_incidents_open_per_provider", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "ended_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_incidents_provider": { + "name": "idx_incidents_provider", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_inventory": { + "name": "provider_inventory", + "schema": "", + "columns": { + "owner": { + "name": "owner", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "host_uri": { + "name": "host_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_online": { + "name": "is_online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_online_since": { + "name": "is_online_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "inventory": { + "name": "inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_available_cpu": { + "name": "total_available_cpu", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "total_available_memory": { + "name": "total_available_memory", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "total_available_gpu": { + "name": "total_available_gpu", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "total_available_eph": { + "name": "total_available_eph", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "total_available_persistent": { + "name": "total_available_persistent", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "total_available_leased_ip": { + "name": "total_available_leased_ip", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "max_node_free_cpu": { + "name": "max_node_free_cpu", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "max_node_free_memory": { + "name": "max_node_free_memory", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "max_node_free_gpu": { + "name": "max_node_free_gpu", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "reclamation_window": { + "name": "reclamation_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "gpu_models": { + "name": "gpu_models", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "storage_classes": { + "name": "storage_classes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "self_attributes": { + "name": "self_attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "signed_attributes": { + "name": "signed_attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "audited_by": { + "name": "audited_by", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "verification": { + "name": "verification", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_provider_inventory_online": { + "name": "idx_provider_inventory_online", + "columns": [ + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "is_online AND is_online_since IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/provider-inventory/drizzle/meta/_journal.json b/apps/provider-inventory/drizzle/meta/_journal.json index 63b97c1a69..c5bc2a3648 100644 --- a/apps/provider-inventory/drizzle/meta/_journal.json +++ b/apps/provider-inventory/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1781690088926, "tag": "0004_add_reclamation_window", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1787631983025, + "tag": "0005_add_provider_verification", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/provider-inventory/package.json b/apps/provider-inventory/package.json index 3b6e38edff..736dca6947 100644 --- a/apps/provider-inventory/package.json +++ b/apps/provider-inventory/package.json @@ -28,6 +28,7 @@ "@akashnetwork/env-loader": "*", "@akashnetwork/instrumentation": "*", "@akashnetwork/logging": "*", + "@akashnetwork/provider-verification": "*", "@hono/node-server": "^1.19.0", "@hono/otel": "~0.4.0", "@hono/zod-openapi": "^0.18.4", diff --git a/apps/provider-inventory/src/config/env.config.ts b/apps/provider-inventory/src/config/env.config.ts index 18a97f4032..f26b2fb2f7 100644 --- a/apps/provider-inventory/src/config/env.config.ts +++ b/apps/provider-inventory/src/config/env.config.ts @@ -18,6 +18,7 @@ export const envSchema = z.object({ .nonnegative() .int() .default(10 * ONE_MINUTE), + VERIFICATION_QUERY_CONCURRENCY: z.number({ coerce: true }).int().positive().default(20), MAX_CONCURRENT_STREAM_CONNECTIONS: z.number({ coerce: true }).positive().default(100), STREAM_RECONNECT_INITIAL_DELAY_MS: z.number({ coerce: true }).nonnegative().default(60_000), STREAM_RECONNECT_MAX_DELAY_MS: z diff --git a/apps/provider-inventory/src/controllers/bid-screening/bid-screening.controller.ts b/apps/provider-inventory/src/controllers/bid-screening/bid-screening.controller.ts index 79955ffcef..fdb052d55c 100644 --- a/apps/provider-inventory/src/controllers/bid-screening/bid-screening.controller.ts +++ b/apps/provider-inventory/src/controllers/bid-screening/bid-screening.controller.ts @@ -13,7 +13,6 @@ export class BidScreeningController { } async screenProviders(request: BidScreeningRequest, options?: Abortable): Promise { - const results = await this.#bidScreeningService.findMatchingProviders(request as unknown as BidScreeningInput, options); - return { providers: results }; + return await this.#bidScreeningService.screenProviders(request as unknown as BidScreeningInput, options); } } diff --git a/apps/provider-inventory/src/http-schemas/bid-screening.schema.spec.ts b/apps/provider-inventory/src/http-schemas/bid-screening.schema.spec.ts new file mode 100644 index 0000000000..5b0c193c09 --- /dev/null +++ b/apps/provider-inventory/src/http-schemas/bid-screening.schema.spec.ts @@ -0,0 +1,52 @@ +import { AuditorSelectionMode, CapabilityFlag, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { describe, expect, it } from "vitest"; + +import { VerificationRequirementSchema } from "./bid-screening.schema"; + +describe("VerificationRequirementSchema", () => { + it("accepts the generated GroupSpec verification shape", () => { + const result = VerificationRequirementSchema.parse({ + minTier: VerificationTier.verification_tier_verified, + requiredCapabilities: [CapabilityFlag.capability_persistent_storage], + requiredAuditors: ["akash1auditor"], + auditorMode: AuditorSelectionMode.auditor_selection_mode_all, + minAuditorCount: 2 + }); + + expect(result).toEqual({ + minTier: VerificationTier.verification_tier_verified, + requiredCapabilities: [CapabilityFlag.capability_persistent_storage], + requiredAuditors: ["akash1auditor"], + auditorMode: AuditorSelectionMode.auditor_selection_mode_all, + minAuditorCount: 2 + }); + }); + + it("defaults generated repeated and scalar fields", () => { + expect(VerificationRequirementSchema.parse({ minTier: VerificationTier.verification_tier_identified })).toEqual({ + minTier: VerificationTier.verification_tier_identified, + requiredCapabilities: [], + requiredAuditors: [], + auditorMode: AuditorSelectionMode.auditor_selection_mode_unspecified, + minAuditorCount: 0 + }); + }); + + it("rejects capabilities that are not defined by AEP-86", () => { + const result = VerificationRequirementSchema.safeParse({ + minTier: VerificationTier.verification_tier_identified, + requiredCapabilities: [999] + }); + + expect(result.success).toBe(false); + }); + + it("rejects filters on a tier-zero requirement", () => { + const result = VerificationRequirementSchema.safeParse({ + minTier: VerificationTier.verification_tier_unspecified, + requiredAuditors: ["akash1auditor"] + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/apps/provider-inventory/src/http-schemas/bid-screening.schema.ts b/apps/provider-inventory/src/http-schemas/bid-screening.schema.ts index b1956b766e..77860b033c 100644 --- a/apps/provider-inventory/src/http-schemas/bid-screening.schema.ts +++ b/apps/provider-inventory/src/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"); @@ -91,9 +92,64 @@ 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" + }); + }); +export type VerificationRequirementInput = z.infer; + const RequirementsSchema = z.object({ signedBy: SignedBySchema.default({}), - attributes: z.array(AttributeSchema).default([]) + attributes: z.array(AttributeSchema).default([]), + verification: VerificationRequirementSchema.optional() }); /** @@ -140,6 +196,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({ @@ -152,8 +221,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/provider-inventory/src/mappers/groupspec-mapper/groupspec-mapper.ts b/apps/provider-inventory/src/mappers/groupspec-mapper/groupspec-mapper.ts index 8f6fde45ea..8f4f29990c 100644 --- a/apps/provider-inventory/src/mappers/groupspec-mapper/groupspec-mapper.ts +++ b/apps/provider-inventory/src/mappers/groupspec-mapper/groupspec-mapper.ts @@ -22,13 +22,11 @@ export function mapGroupSpecToResourceUnits(request: Omit memory: { quantity: resource.memory.quantity.val }, - storage: resource.storage.map( - (s): RequestedStorage => ({ - name: s.name, - quantity: s.quantity.val, - attributes: parseStorageAttributes(s.attributes ?? []) - }) - ), + storage: resource.storage.map((s): RequestedStorage => ({ + name: s.name, + quantity: s.quantity.val, + attributes: parseStorageAttributes(s.attributes ?? []) + })), // GroupSpecJSON kind is of type string, not enum. It's enum for gRPC response/request. So, can safely cast here. endpoints: (resource.endpoints ?? []) as unknown as RequestedResourceUnit["resources"]["endpoints"] }, @@ -45,4 +43,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-inventory/src/mappers/provider-verification-mapper/provider-verification-mapper.spec.ts b/apps/provider-inventory/src/mappers/provider-verification-mapper/provider-verification-mapper.spec.ts new file mode 100644 index 0000000000..28f5cf0c08 --- /dev/null +++ b/apps/provider-inventory/src/mappers/provider-verification-mapper/provider-verification-mapper.spec.ts @@ -0,0 +1,100 @@ +import { AttestationStatus, CapabilityFlag, VerificationGraceStatus, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import type { ProviderVerificationScreeningState } from "@akashnetwork/provider-verification"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { mapProviderVerification, mapStoredProviderVerificationFacts } from "./provider-verification-mapper"; + +describe(mapProviderVerification.name, () => { + it("stores only the normalized facts required for screening", () => { + const stored = mapProviderVerification({ + moduleActive: true, + observedAt: new Date("2026-08-24T12:00:00.000Z"), + observedHeight: "123", + state: mock({ + provider: "akash1provider", + attestations: [ + mock({ + auditor: "akash1z", + capabilities: [CapabilityFlag.capability_persistent_storage, CapabilityFlag.capability_bare_metal], + status: AttestationStatus.attestation_status_valid, + tier: VerificationTier.verification_tier_verified + }), + mock({ + auditor: "akash1a", + capabilities: [], + status: AttestationStatus.attestation_status_expired, + tier: VerificationTier.verification_tier_identified + }) + ], + grace: mock({ + preservedTier: VerificationTier.verification_tier_established, + status: VerificationGraceStatus.verification_grace_status_active + }), + snapshot: mock({ complianceDeadline: new Date("2026-08-25T12:00:00.000Z"), suspended: false }) + }) + }); + + expect(stored).toEqual({ + moduleActive: true, + facts: { + attestations: [ + { + auditor: "akash1a", + capabilities: [], + status: AttestationStatus.attestation_status_expired, + tier: VerificationTier.verification_tier_identified + }, + { + auditor: "akash1z", + capabilities: [CapabilityFlag.capability_persistent_storage, CapabilityFlag.capability_bare_metal], + status: AttestationStatus.attestation_status_valid, + tier: VerificationTier.verification_tier_verified + } + ], + completeness: { attestations: true, graces: true, snapshot: true }, + graces: [ + { + preservedTier: VerificationTier.verification_tier_established, + status: VerificationGraceStatus.verification_grace_status_active + } + ], + observedAt: "2026-08-24T12:00:00.000Z", + observedHeight: "123", + snapshot: { complianceDeadline: "2026-08-25T12:00:00.000Z", suspended: false } + } + }); + }); + + it("represents unavailable provider queries as incomplete facts", () => { + const stored = mapProviderVerification({ + moduleActive: null, + observedAt: new Date("2026-08-24T12:00:00.000Z"), + observedHeight: "123", + state: null + }); + + expect(stored.facts.completeness).toEqual({ attestations: false, graces: false, snapshot: false }); + expect(stored.facts.attestations).toEqual([]); + expect(stored.facts.graces).toEqual([]); + expect(stored.facts.snapshot).toBeNull(); + }); + + it("hydrates persisted timestamps for the shared evaluator", () => { + const stored = mapProviderVerification({ + moduleActive: true, + observedAt: new Date("2026-08-24T12:00:00.000Z"), + observedHeight: "123", + state: mock({ + attestations: [], + grace: null, + snapshot: mock({ complianceDeadline: new Date("2026-08-25T12:00:00.000Z"), suspended: false }) + }) + }); + + const facts = mapStoredProviderVerificationFacts(stored); + + expect(facts.observedAt).toEqual(new Date("2026-08-24T12:00:00.000Z")); + expect(facts.snapshot?.complianceDeadline).toEqual(new Date("2026-08-25T12:00:00.000Z")); + }); +}); diff --git a/apps/provider-inventory/src/mappers/provider-verification-mapper/provider-verification-mapper.ts b/apps/provider-inventory/src/mappers/provider-verification-mapper/provider-verification-mapper.ts new file mode 100644 index 0000000000..4b9c954334 --- /dev/null +++ b/apps/provider-inventory/src/mappers/provider-verification-mapper/provider-verification-mapper.ts @@ -0,0 +1,69 @@ +import type { ProviderVerificationScreeningState } from "@akashnetwork/provider-verification"; +import type { ProviderVerificationFacts } from "@akashnetwork/provider-verification"; + +import type { StoredProviderVerification } from "@src/types/provider-verification"; + +interface MapProviderVerificationInput { + moduleActive: boolean | null; + observedAt: Date; + observedHeight: string; + state: ProviderVerificationScreeningState | null; +} + +export function mapProviderVerification(input: MapProviderVerificationInput): StoredProviderVerification { + const { moduleActive, observedAt, observedHeight, state } = input; + + if (!state) { + return { + moduleActive, + facts: { + attestations: [], + completeness: { attestations: false, graces: false, snapshot: false }, + graces: [], + observedAt: observedAt.toISOString(), + observedHeight, + snapshot: null + } + }; + } + + return { + moduleActive, + facts: { + attestations: state.attestations + .map(attestation => ({ + auditor: attestation.auditor, + capabilities: [...attestation.capabilities].sort((left, right) => left - right), + status: attestation.status, + tier: attestation.tier + })) + .sort((left, right) => left.auditor.localeCompare(right.auditor)), + completeness: { attestations: true, graces: true, snapshot: true }, + graces: state.grace ? [{ preservedTier: state.grace.preservedTier, status: state.grace.status }] : [], + observedAt: observedAt.toISOString(), + observedHeight, + snapshot: state.snapshot + ? { + complianceDeadline: state.snapshot.complianceDeadline?.toISOString() ?? null, + suspended: state.snapshot.suspended + } + : null + } + }; +} + +export function mapStoredProviderVerificationFacts(verification: StoredProviderVerification): ProviderVerificationFacts { + return { + attestations: verification.facts.attestations, + completeness: verification.facts.completeness, + graces: verification.facts.graces, + observedAt: new Date(verification.facts.observedAt), + observedHeight: verification.facts.observedHeight, + snapshot: verification.facts.snapshot + ? { + complianceDeadline: verification.facts.snapshot.complianceDeadline ? new Date(verification.facts.snapshot.complianceDeadline) : undefined, + suspended: verification.facts.snapshot.suspended + } + : null + }; +} diff --git a/apps/provider-inventory/src/model-schemas/provider-inventory/provider-inventory.schema.ts b/apps/provider-inventory/src/model-schemas/provider-inventory/provider-inventory.schema.ts index e310390530..52263d2f6a 100644 --- a/apps/provider-inventory/src/model-schemas/provider-inventory/provider-inventory.schema.ts +++ b/apps/provider-inventory/src/model-schemas/provider-inventory/provider-inventory.schema.ts @@ -2,6 +2,7 @@ import { sql } from "drizzle-orm"; import { bigint, boolean, index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { jsonbBigint } from "@src/lib/jsonb-bigint/jsonb-bigint.column"; +import type { StoredProviderVerification } from "@src/types/provider-verification"; export const providerInventory = pgTable( "provider_inventory", @@ -48,6 +49,7 @@ export const providerInventory = pgTable( selfAttributes: jsonbBigint("self_attributes").notNull().default([]), signedAttributes: jsonbBigint("signed_attributes").notNull().default([]), auditedBy: text("audited_by").array().notNull().default([]), + verification: jsonbBigint("verification").$type(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow() diff --git a/apps/provider-inventory/src/providers/index.ts b/apps/provider-inventory/src/providers/index.ts index 17345cc533..1c0283840e 100644 --- a/apps/provider-inventory/src/providers/index.ts +++ b/apps/provider-inventory/src/providers/index.ts @@ -3,3 +3,4 @@ export * from "./app-config.provider"; export * from "./postgres.provider"; export * from "./drizzle.provider"; export * from "./logger-factory.provider"; +export * from "./provider-verification-query-client.provider"; diff --git a/apps/provider-inventory/src/providers/provider-verification-query-client.provider.ts b/apps/provider-inventory/src/providers/provider-verification-query-client.provider.ts new file mode 100644 index 0000000000..a72c524ba8 --- /dev/null +++ b/apps/provider-inventory/src/providers/provider-verification-query-client.provider.ts @@ -0,0 +1,14 @@ +import { ProviderVerificationQueryClient } from "@akashnetwork/provider-verification"; +import type { InjectionToken } from "tsyringe"; +import { container, instancePerContainerCachingFactory } from "tsyringe"; + +import { CHAIN_SDK } from "./chain-sdk.provider"; + +export const PROVIDER_VERIFICATION_QUERY_CLIENT = Symbol("PROVIDER_VERIFICATION_QUERY_CLIENT") as InjectionToken; + +container.register(PROVIDER_VERIFICATION_QUERY_CLIENT, { + useFactory: instancePerContainerCachingFactory(c => { + const sdk = c.resolve(CHAIN_SDK); + return new ProviderVerificationQueryClient(sdk.akash.verification.v1, sdk.akash.provider.v1beta4); + }) +}); diff --git a/apps/provider-inventory/src/repositories/bid-screening/bid-screening.repository.integration.ts b/apps/provider-inventory/src/repositories/bid-screening/bid-screening.repository.integration.ts index f7a9463f59..b9dba6db83 100644 --- a/apps/provider-inventory/src/repositories/bid-screening/bid-screening.repository.integration.ts +++ b/apps/provider-inventory/src/repositories/bid-screening/bid-screening.repository.integration.ts @@ -10,6 +10,7 @@ import { parseStorageAttributes } from "@src/mappers/storage-attribute-parser/st import { providerInventory } from "@src/model-schemas/provider-inventory/provider-inventory.schema"; import { DRIZZLE_DB } from "@src/providers/drizzle.provider"; import type { RequestedResourceUnit, ResourceAttribute } from "@src/types/inventory"; +import type { StoredProviderVerification } from "@src/types/provider-verification"; import type { PlacementRequirements } from "./bid-screening.aggregator"; import { AUDITOR, BidScreeningRepository } from "./bid-screening.repository"; @@ -582,6 +583,21 @@ describe(BidScreeningRepository.name, () => { expect(second.updatedAt).not.toBe(first.updatedAt); }); + it("re-fetches a fresh candidate when only the verification observation changes", async () => { + await seed({ owner: "akash1verification", verification: verificationAt("100") }); + const [first] = await repository.findCandidates([unit({})], requirements()); + + await db + .update(providerInventory) + .set({ verification: verificationAt("101") }) + .where(eq(providerInventory.owner, "akash1verification")); + + const [second] = await repository.findCandidates([unit({})], requirements()); + + expect(second).not.toBe(first); + expect(second.verification?.facts.observedHeight).toBe("101"); + }); + it("reuses cached providers while fetching only the uncached ones in a mixed batch", async () => { await seed({ owner: "akash1cacheWarm" }); const [warm] = await repository.findCandidates([unit({})], requirements()); @@ -620,6 +636,7 @@ describe(BidScreeningRepository.name, () => { reclamationWindow?: number | null; inventory?: unknown; createdAt?: Date; + verification?: StoredProviderVerification | null; } async function seed(input: SeedInput): Promise { @@ -644,7 +661,8 @@ describe(BidScreeningRepository.name, () => { storageClasses: input.storageClasses ?? [], reclamationWindow: input.reclamationWindow ?? null, inventory: input.inventory ?? { nodes: [], storage: {} }, - createdAt: input.createdAt + createdAt: input.createdAt, + verification: input.verification ?? null }); } }); @@ -698,6 +716,20 @@ function owners(rows: { owner: string }[]): string[] { return rows.map(r => r.owner).sort(); } +function verificationAt(observedHeight: string): StoredProviderVerification { + return { + moduleActive: true, + facts: { + attestations: [], + completeness: { attestations: true, graces: true, snapshot: true }, + graces: [], + observedAt: "2026-08-24T12:00:00.000Z", + observedHeight, + snapshot: null + } + }; +} + interface RawStorageVolume { name: string; quantity: bigint; diff --git a/apps/provider-inventory/src/repositories/bid-screening/bid-screening.repository.ts b/apps/provider-inventory/src/repositories/bid-screening/bid-screening.repository.ts index f835f8f247..0ab0341594 100644 --- a/apps/provider-inventory/src/repositories/bid-screening/bid-screening.repository.ts +++ b/apps/provider-inventory/src/repositories/bid-screening/bid-screening.repository.ts @@ -4,6 +4,7 @@ import { inject, singleton } from "tsyringe"; import { providerInventory } from "@src/model-schemas/provider-inventory/provider-inventory.schema"; import { type Database, PG_CLIENT } from "@src/providers/postgres.provider"; import type { ClusterState, RequestedResourceUnit } from "@src/types/inventory"; +import type { StoredProviderVerification } from "@src/types/provider-verification"; import { aggregateCriteria, type BidScreeningCriteria, type PlacementRequirements } from "./bid-screening.aggregator"; // TODO(Issue 5): move auditor allowlist into configuration and accept it as a request input. export const AUDITOR = "akash1365yvmc4s7awdyj3n2sav7xfx76adc6dnmlx63"; @@ -17,6 +18,8 @@ export interface BidScreeningCandidate { updatedAt: string; location: string | null; organization: string | null; + verification: StoredProviderVerification | null; + verificationObservedHeight: string | null; } const TABLE = getTableName(providerInventory); @@ -38,10 +41,11 @@ export class BidScreeningRepository { const criteria = aggregateCriteria(resourceUnits, requirements); const where = this.#buildWhere(criteria); - const rows = await sql>` + const rows = await sql>` SELECT ${sql(providerInventory.owner.name)} AS owner, - to_char(${sql(providerInventory.updatedAt.name)} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "updatedAt" + to_char(${sql(providerInventory.updatedAt.name)} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "updatedAt", + ${sql(providerInventory.verification.name)} #>> '{facts,observedHeight}' AS "verificationObservedHeight" FROM ${sql(TABLE)} WHERE ${where} `; @@ -52,7 +56,7 @@ export class BidScreeningRepository { for (let i = 0; i < rows.length; i++) { const row = rows[i]; const cache = this.#providersInventory.get(row.owner); - if (!cache || cache.updatedAt !== row.updatedAt) { + if (!cache || cache.updatedAt !== row.updatedAt || cache.verificationObservedHeight !== row.verificationObservedHeight) { ownersToFetch.push(row.owner); missingFinalCandidatesIndexes.set(row.owner, i); } else { @@ -68,6 +72,8 @@ export class BidScreeningRepository { to_char(${sql(providerInventory.createdAt.name)} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "createdAt", ${sql(providerInventory.hostUri.name)} AS "hostUri", ${sql(providerInventory.inventory.name)} AS cluster, + ${sql(providerInventory.verification.name)} AS verification, + ${sql(providerInventory.verification.name)} #>> '{facts,observedHeight}' AS "verificationObservedHeight", ${sql(providerInventory.auditedBy.name)} @> ARRAY[${AUDITOR}]::text[] AS "isAudited", COALESCE( (SELECT sa->>'value' FROM jsonb_array_elements(${sql(providerInventory.signedAttributes.name)}) AS sa WHERE sa->>'key' = 'location-region' LIMIT 1), diff --git a/apps/provider-inventory/src/repositories/provider-inventory/provider-inventory.repository.integration.ts b/apps/provider-inventory/src/repositories/provider-inventory/provider-inventory.repository.integration.ts index 6af1b4ea26..32b429d99a 100644 --- a/apps/provider-inventory/src/repositories/provider-inventory/provider-inventory.repository.integration.ts +++ b/apps/provider-inventory/src/repositories/provider-inventory/provider-inventory.repository.integration.ts @@ -7,8 +7,9 @@ import { describe, expect, it } from "vitest"; import { providerInventory } from "@src/model-schemas/provider-inventory/provider-inventory.schema"; import { DRIZZLE_DB } from "@src/providers/drizzle.provider"; -import type { ChainProvider } from "@src/types/chain-provider"; +import type { ChainProvider, DiscoveredChainProvider } from "@src/types/chain-provider"; import type { ClusterState } from "@src/types/inventory"; +import type { StoredProviderVerification } from "@src/types/provider-verification"; import { ProviderInventoryRepository } from "./provider-inventory.repository"; describe(ProviderInventoryRepository.name, () => { @@ -225,6 +226,28 @@ describe(ProviderInventoryRepository.name, () => { }); }); + describe("bulkUpdateVerification", () => { + it("persists the latest verification observation without advancing the liveness clock", async () => { + const { repository, db } = setup(); + const updatedAt = new Date("2026-01-01T00:00:00.000Z"); + await seed(db, { owner: "akash1a", updatedAt }); + + await repository.bulkUpdateVerification([createDiscoveredProvider({ owner: "akash1a", verification: verificationAt("123") })]); + + const [row] = await db.select().from(providerInventory).where(eq(providerInventory.owner, "akash1a")); + expect(row.verification).toEqual(verificationAt("123")); + expect(row.updatedAt).toEqual(updatedAt); + }); + + it("does not insert inventory rows for providers that were not upserted", async () => { + const { repository, db } = setup(); + + await repository.bulkUpdateVerification([createDiscoveredProvider({ owner: "akash1missing" })]); + + expect(await db.select().from(providerInventory)).toEqual([]); + }); + }); + describe("updateInventory", () => { it("updates the existing row and never inserts a row for an owner with no attributes row", async () => { const { repository, db } = setup(); @@ -423,6 +446,7 @@ interface SeedInput { selfAttributes?: { key: string; value: string }[]; signedAttributes?: { key: string; value: string; auditor: string }[]; auditedBy?: string[]; + verification?: StoredProviderVerification | null; updatedAt?: Date; } @@ -435,6 +459,7 @@ async function seed(db: PostgresJsDatabase, input: SeedInput): Promise { selfAttributes: input.selfAttributes ?? [], signedAttributes: input.signedAttributes ?? [], auditedBy: input.auditedBy ?? [], + verification: input.verification ?? null, ...(input.updatedAt && { updatedAt: input.updatedAt }) }); } @@ -450,6 +475,27 @@ function createProvider(overrides?: Partial): ChainProvider { }; } +function createDiscoveredProvider(overrides?: Partial): DiscoveredChainProvider { + return { + ...createProvider(overrides), + verification: overrides?.verification ?? verificationAt("123") + }; +} + +function verificationAt(observedHeight: string): StoredProviderVerification { + return { + moduleActive: true, + facts: { + attestations: [], + completeness: { attestations: true, graces: true, snapshot: true }, + graces: [], + observedAt: "2026-08-24T12:00:00.000Z", + observedHeight, + snapshot: null + } + }; +} + function createCluster(overrides?: { cpu?: bigint }): ClusterState { const node = { name: "node-1", diff --git a/apps/provider-inventory/src/repositories/provider-inventory/provider-inventory.repository.ts b/apps/provider-inventory/src/repositories/provider-inventory/provider-inventory.repository.ts index 764f44c310..74e4c1a646 100644 --- a/apps/provider-inventory/src/repositories/provider-inventory/provider-inventory.repository.ts +++ b/apps/provider-inventory/src/repositories/provider-inventory/provider-inventory.repository.ts @@ -5,7 +5,7 @@ import { inject, singleton } from "tsyringe"; import { paginate } from "@src/lib/generators/paginate/paginate"; import { providerInventory } from "@src/model-schemas/provider-inventory/provider-inventory.schema"; import { type Database, PG_CLIENT } from "@src/providers/postgres.provider"; -import type { ChainProvider } from "@src/types/chain-provider"; +import type { ChainProvider, DiscoveredChainProvider } from "@src/types/chain-provider"; import { ClusterState } from "@src/types/inventory"; import { DbDriver } from "../db-driver/db-driver"; import { mapToStoredClusterState } from "./stored-cluster-state-mapper/stored-cluster-state-mapper"; @@ -148,4 +148,26 @@ export class ProviderInventoryRepository { return rows.map(row => ({ owner: row.owner })); } + + async bulkUpdateVerification(providers: DiscoveredChainProvider[]): Promise { + if (providers.length === 0) return; + + const sql = this.#sql; + const c = providerInventory; + const payload = providers.map(provider => ({ + [c.owner.name]: provider.owner, + [c.verification.name]: provider.verification + })); + + await sql` + UPDATE ${sql(TABLE)} AS inventory + SET ${sql(c.verification.name)} = observations.${sql(c.verification.name)} + FROM jsonb_to_recordset(${sql.json(payload as Parameters[0])}::jsonb) AS observations( + ${sql(c.owner.name)} text, + ${sql(c.verification.name)} jsonb + ) + WHERE inventory.${sql(c.owner.name)} = observations.${sql(c.owner.name)} + AND inventory.${sql(c.verification.name)} IS DISTINCT FROM observations.${sql(c.verification.name)} + `; + } } diff --git a/apps/provider-inventory/src/services/bid-screening/bid-screening.service.spec.ts b/apps/provider-inventory/src/services/bid-screening/bid-screening.service.spec.ts index e6c0b3c698..8e04db71a6 100644 --- a/apps/provider-inventory/src/services/bid-screening/bid-screening.service.spec.ts +++ b/apps/provider-inventory/src/services/bid-screening/bid-screening.service.spec.ts @@ -1,9 +1,12 @@ +import type { VerificationRequirement } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { AttestationStatus, AuditorSelectionMode, CapabilityFlag, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; import type { LoggerService } from "@akashnetwork/logging"; import { describe, expect, it } from "vitest"; import { mock } from "vitest-mock-extended"; import type { BidScreeningCandidate, BidScreeningRepository } from "@src/repositories/bid-screening/bid-screening.repository"; import type { DailyDowntimeRow, ProviderIncidentRepository } from "@src/repositories/provider-incident/provider-incident.repository"; +import type { StoredProviderVerification } from "@src/types/provider-verification"; import type { ClusterInventoryMatcherService } from "../cluster-inventory-matcher/cluster-inventory-matcher.service"; import type { BidScreeningInput } from "./bid-screening.service"; import { BidScreeningService } from "./bid-screening.service"; @@ -228,6 +231,127 @@ describe(BidScreeningService.name, () => { expect(incidentRepository.findDailyDowntimeByProviders).toHaveBeenCalledWith(["akash1abc"], request.timezone); expect(results[0].incidents).toEqual([{ date: "2026-06-01", hasOpenIncident: true, incidentCount: 1, downtimeSeconds: 3600 }]); }); + + it("keeps the existing response shape when no verification requirement is present", async () => { + const { service, repository, matcher } = setup(); + repository.findCandidates.mockResolvedValue([makeCandidate("akash1abc")]); + matcher.match.mockReturnValue({ matched: true }); + + const result = await service.screenProviders(makeRequest()); + + expect(result).toEqual({ + providers: [ + expect.objectContaining({ + owner: "akash1abc" + }) + ] + }); + expect(result.providers[0]).not.toHaveProperty("verification"); + }); + + it("includes providers that pass verification after capacity matching", async () => { + const { service, repository, matcher } = setup(); + repository.findCandidates.mockResolvedValue([makeCandidate("akash1abc", { verification: storedVerification() })]); + matcher.match.mockReturnValue({ matched: true }); + + const result = await service.screenProviders( + makeRequest({ + verification: verificationRequirement({ requiredCapabilities: [CapabilityFlag.capability_persistent_storage] }) + }) + ); + + expect(result.exclusions).toEqual([]); + expect(result.providers[0].verification).toMatchObject({ + outcome: "pass", + summary: { tierGateTier: VerificationTier.verification_tier_identified } + }); + }); + + it("requires both legacy signedBy and verification when both are present", async () => { + const { service, repository, matcher } = setup(); + repository.findCandidates.mockResolvedValue([makeCandidate("akash1abc", { verification: storedVerification() })]); + matcher.match.mockReturnValue({ matched: true }); + const request = makeRequest({ + signedBy: { allOf: ["akash1legacyauditor"], anyOf: [] }, + verification: verificationRequirement({ minTier: VerificationTier.verification_tier_verified }) + }); + + const result = await service.screenProviders(request); + + expect(repository.findCandidates).toHaveBeenCalledWith(expect.any(Array), request.requirements); + expect(result.providers).toEqual([]); + expect(result.exclusions?.[0].firstFailure).toEqual({ code: "snapshot_not_posted" }); + }); + + it("excludes verification failures with the first and complete failure set", async () => { + const { service, repository, matcher } = setup(); + repository.findCandidates.mockResolvedValue([makeCandidate("akash1abc", { verification: storedVerification() })]); + matcher.match.mockReturnValue({ matched: true }); + + const result = await service.screenProviders( + makeRequest({ + verification: verificationRequirement({ + minTier: VerificationTier.verification_tier_verified, + requiredCapabilities: [CapabilityFlag.capability_bare_metal], + minAuditorCount: 2 + }) + }) + ); + + expect(result.providers).toEqual([]); + expect(result.exclusions).toEqual([ + expect.objectContaining({ + owner: "akash1abc", + firstFailure: { code: "snapshot_not_posted" }, + failures: [ + { code: "snapshot_not_posted" }, + { + code: "insufficient_tier", + actual: VerificationTier.verification_tier_identified, + required: VerificationTier.verification_tier_verified + }, + { code: "missing_capability", capability: CapabilityFlag.capability_bare_metal }, + { code: "insufficient_auditor_count", actual: 0, required: 2 } + ] + }) + ]); + }); + + it("fails open and marks unavailable verification facts as not evaluated", async () => { + const { service, repository, matcher } = setup(); + repository.findCandidates.mockResolvedValue([makeCandidate("akash1abc")]); + matcher.match.mockReturnValue({ matched: true }); + + const result = await service.screenProviders(makeRequest({ verification: verificationRequirement() })); + + expect(result.exclusions).toEqual([]); + expect(result.providers[0].verification).toEqual({ + outcome: "not_evaluated", + incompleteFacts: ["params", "attestations", "graces"], + summary: expect.any(Object) + }); + }); + + it("marks disabled-module screening as not evaluated without excluding the provider", async () => { + const { service, repository, matcher } = setup(); + repository.findCandidates.mockResolvedValue([makeCandidate("akash1abc", { verification: storedVerification({ moduleActive: false, complete: false }) })]); + matcher.match.mockReturnValue({ matched: true }); + + const result = await service.screenProviders(makeRequest({ verification: verificationRequirement() })); + + expect(result.exclusions).toEqual([]); + expect(result.providers[0].verification).toMatchObject({ outcome: "not_evaluated", incompleteFacts: ["module_inactive"] }); + }); + + it("does not report capacity failures as verification exclusions", async () => { + const { service, repository, matcher } = setup(); + repository.findCandidates.mockResolvedValue([makeCandidate("akash1abc", { verification: storedVerification() })]); + matcher.match.mockReturnValue({ matched: false, error: "INSUFFICIENT_CAPACITY" }); + + const result = await service.screenProviders(makeRequest({ verification: verificationRequirement() })); + + expect(result).toEqual({ providers: [], exclusions: [] }); + }); }); function setup() { @@ -247,7 +371,13 @@ function makeDowntimeRow(provider: string, overrides?: Partial function makeCandidate( owner: string, - overrides?: { isAudited?: boolean; createdAt?: string; location?: string | null; organization?: string | null } + overrides?: { + isAudited?: boolean; + createdAt?: string; + location?: string | null; + organization?: string | null; + verification?: StoredProviderVerification | null; + } ): BidScreeningCandidate { return { owner, @@ -255,6 +385,8 @@ function makeCandidate( isAudited: overrides?.isAudited ?? false, createdAt: overrides?.createdAt ?? "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", + verification: overrides?.verification ?? null, + verificationObservedHeight: overrides?.verification?.facts.observedHeight ?? null, location: overrides?.location ?? null, organization: overrides?.organization ?? null, cluster: { @@ -278,13 +410,15 @@ function makeRequest( overrides?: Partial<{ attributes: { key: string; value: string }[]; signedBy: { allOf: string[]; anyOf: string[] }; + verification: VerificationRequirement; }> ): BidScreeningInput { return { timezone: "America/Chicago", requirements: { signedBy: overrides?.signedBy ?? { allOf: [], anyOf: [] }, - attributes: overrides?.attributes ?? [] + attributes: overrides?.attributes ?? [], + verification: overrides?.verification }, resources: [ { @@ -302,3 +436,38 @@ function makeRequest( ] }; } + +function verificationRequirement(overrides: Partial = {}): VerificationRequirement { + return { + minTier: VerificationTier.verification_tier_identified, + requiredCapabilities: [], + requiredAuditors: [], + auditorMode: AuditorSelectionMode.auditor_selection_mode_unspecified, + minAuditorCount: 0, + ...overrides + }; +} + +function storedVerification(input: { complete?: boolean; moduleActive?: boolean | null } = {}): StoredProviderVerification { + const complete = input.complete ?? true; + return { + moduleActive: input.moduleActive ?? true, + facts: { + attestations: complete + ? [ + { + auditor: "akash1auditor", + capabilities: [CapabilityFlag.capability_persistent_storage], + status: AttestationStatus.attestation_status_valid, + tier: VerificationTier.verification_tier_identified + } + ] + : [], + completeness: { attestations: complete, graces: complete, snapshot: complete }, + graces: [], + observedAt: "2026-08-24T12:00:00.000Z", + observedHeight: "123", + snapshot: null + } + }; +} diff --git a/apps/provider-inventory/src/services/bid-screening/bid-screening.service.ts b/apps/provider-inventory/src/services/bid-screening/bid-screening.service.ts index 410dbdf096..0ffcc384ae 100644 --- a/apps/provider-inventory/src/services/bid-screening/bid-screening.service.ts +++ b/apps/provider-inventory/src/services/bid-screening/bid-screening.service.ts @@ -1,14 +1,17 @@ +import { type VerificationRequirement, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; import { withSpan } from "@akashnetwork/instrumentation"; +import { evaluateProviderVerification, type ProviderVerificationFacts } from "@akashnetwork/provider-verification"; import type { Abortable } from "node:events"; import { inject, singleton } from "tsyringe"; +import { mapStoredProviderVerificationFacts } from "@src/mappers/provider-verification-mapper/provider-verification-mapper"; import { bidScreeningBinPackerMatched, bidScreeningPrefilterCandidates } from "@src/metrics/metrics"; import { LOGGER_FACTORY, type LoggerFactory, LoggerService } from "@src/providers/logger-factory.provider"; import { type BidScreeningCandidate, BidScreeningRepository } from "@src/repositories/bid-screening/bid-screening.repository"; import { DailyDowntimeRow, ProviderIncidentRepository } from "@src/repositories/provider-incident/provider-incident.repository"; import type { GroupSpecJSON } from "../../mappers/groupspec-mapper/groupspec-mapper"; import { mapGroupSpecToResourceUnits } from "../../mappers/groupspec-mapper/groupspec-mapper"; -import type { BidScreeningResult, RequestedResourceUnit } from "../../types/inventory"; +import type { BidScreeningExclusion, BidScreeningResult, BidScreeningSelection, RequestedResourceUnit } from "../../types/inventory"; import { ClusterInventoryMatcherService } from "../cluster-inventory-matcher/cluster-inventory-matcher.service"; const EMPTY_OBJECT = Object.freeze(Object.create(null)); @@ -33,6 +36,10 @@ export class BidScreeningService { } async findMatchingProviders(request: BidScreeningInput, options?: Abortable): Promise { + return (await this.screenProviders(request, options)).providers; + } + + async screenProviders(request: BidScreeningInput, options?: Abortable): Promise { const resourceUnits = await withSpan("mapRequestToResourceUnits", async () => mapGroupSpecToResourceUnits(request)); this.#logger.info({ event: "BID_SCREENING_START", resourceGroupCount: resourceUnits.length }); @@ -49,20 +56,26 @@ export class BidScreeningService { if (options?.signal?.aborted) return []; const items = this.#filterProviders(candidates, resourceUnits); bidScreeningBinPackerMatched.record(items.length); - this.#logger.info({ - event: "BID_SCREENING_COMPLETE", - candidatesCount: candidates.length, - matchedCount: items.length - }); return items; }); + const verificationRequirement = effectiveVerificationRequirement(request.requirements.verification); + const screening = this.#screenByVerification(matched, verificationRequirement); + this.#logger.info({ + event: "BID_SCREENING_COMPLETE", + candidatesCount: candidates.length, + capacityMatchedCount: matched.length, + excludedByVerificationCount: screening.exclusions.length, + matchedCount: screening.providers.length, + verificationNotEvaluatedCount: screening.providers.filter(provider => provider.verification?.outcome === "not_evaluated").length + }); + const incidentsByOwner: Partial[]>> = await withSpan( "fetchIncidentsForMatched", async ({ activeSpan }) => { - if (!matched.length || options?.signal?.aborted) return EMPTY_OBJECT; + if (!screening.providers.length || options?.signal?.aborted) return EMPTY_OBJECT; const rows = await this.#incidentRepository.findDailyDowntimeByProviders( - matched.map(candidate => candidate.owner), + screening.providers.map(({ candidate }) => candidate.owner), request.timezone ); activeSpan.setAttribute("amountOfIncidents", rows.length); @@ -81,7 +94,8 @@ export class BidScreeningService { } ); - return matched.map(candidate => this.#toResult(candidate, incidentsByOwner)); + const providers = screening.providers.map(({ candidate, verification }) => this.#toResult(candidate, incidentsByOwner, verification)); + return verificationRequirement ? { providers, exclusions: screening.exclusions } : { providers }; } #filterProviders(candidates: BidScreeningCandidate[], resourceUnits: RequestedResourceUnit[]): BidScreeningCandidate[] { @@ -100,7 +114,47 @@ export class BidScreeningService { return matched; } - #toResult(candidate: BidScreeningCandidate, incidentsByOwner: Partial[]>>): BidScreeningResult { + #screenByVerification(candidates: BidScreeningCandidate[], requirement: VerificationRequirement | null): VerificationScreeningResult { + if (!requirement) return { providers: candidates.map(candidate => ({ candidate })), exclusions: [] }; + + const providers: VerificationScreeningResult["providers"] = []; + const exclusions: BidScreeningExclusion[] = []; + + for (const candidate of candidates) { + const stored = candidate.verification; + const facts = stored ? mapStoredProviderVerificationFacts(stored) : unknownVerificationFacts(); + const evaluation = evaluateProviderVerification({ facts, moduleActive: stored?.moduleActive ?? null, requirement }); + + if (evaluation.outcome === "fail") { + exclusions.push({ + owner: candidate.owner, + firstFailure: evaluation.firstFailure, + failures: evaluation.failures, + summary: evaluation.summary + }); + } else if (evaluation.outcome === "unknown") { + providers.push({ + candidate, + verification: { outcome: "not_evaluated", incompleteFacts: evaluation.incompleteFacts, summary: evaluation.summary } + }); + } else if (stored?.moduleActive === false) { + providers.push({ + candidate, + verification: { outcome: "not_evaluated", incompleteFacts: ["module_inactive"], summary: evaluation.summary } + }); + } else { + providers.push({ candidate, verification: { outcome: "pass", summary: evaluation.summary } }); + } + } + + return { exclusions, providers }; + } + + #toResult( + candidate: BidScreeningCandidate, + incidentsByOwner: Partial[]>>, + verification?: BidScreeningResult["verification"] + ): BidScreeningResult { return { owner: candidate.owner, hostUri: candidate.hostUri, @@ -108,7 +162,8 @@ export class BidScreeningService { createdAt: candidate.createdAt, location: candidate.location, organization: candidate.organization, - incidents: incidentsByOwner[candidate.owner] ?? [] + incidents: incidentsByOwner[candidate.owner] ?? [], + ...(verification ? { verification } : {}) }; } } @@ -117,3 +172,25 @@ export interface BidScreeningInput extends Omit { timezone: string; reclamationWindow?: number; } + +interface VerificationScreeningResult { + providers: Array<{ candidate: BidScreeningCandidate; verification?: BidScreeningResult["verification"] }>; + exclusions: BidScreeningExclusion[]; +} + +function effectiveVerificationRequirement(requirement: VerificationRequirement | undefined): VerificationRequirement | null { + return !requirement || requirement.minTier === VerificationTier.verification_tier_unspecified ? null : requirement; +} + +function unknownVerificationFacts(): ProviderVerificationFacts { + const facts: ProviderVerificationFacts = { + attestations: [], + completeness: { attestations: false, graces: false, snapshot: false }, + graces: [], + observedAt: new Date(0), + observedHeight: "", + snapshot: null + }; + + return facts; +} diff --git a/apps/provider-inventory/src/services/chain-provider-poller/chain-provider-poller.service.spec.ts b/apps/provider-inventory/src/services/chain-provider-poller/chain-provider-poller.service.spec.ts index fa2f346847..742b158f77 100644 --- a/apps/provider-inventory/src/services/chain-provider-poller/chain-provider-poller.service.spec.ts +++ b/apps/provider-inventory/src/services/chain-provider-poller/chain-provider-poller.service.spec.ts @@ -1,13 +1,17 @@ +import { AttestationStatus, CapabilityFlag, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; import type { ChainNodeWebSDK } from "@akashnetwork/chain-sdk/web"; +import type { ProviderVerificationQueryClient, ProviderVerificationScreeningState } from "@akashnetwork/provider-verification"; import { describe, expect, it } from "vitest"; import { mock, mockDeep } from "vitest-mock-extended"; +import type { EnvConfig } from "@src/providers/app-config.provider"; import type { LoggerFactory } from "@src/providers/logger-factory.provider"; -import type { ChainProvider } from "@src/types/chain-provider"; +import type { DiscoveredChainProvider } from "@src/types/chain-provider"; import { ChainProviderPollerService } from "./chain-provider-poller.service"; type ProvidersResponse = Awaited>; type AuditResponse = Awaited>; +type ParamsResponse = Awaited>; type ChainSDKProvider = ProvidersResponse["providers"][number]; type AuditRecord = AuditResponse["providers"][number]; @@ -16,7 +20,7 @@ describe(ChainProviderPollerService.name, () => { const { service, getProviders } = setup(); getProviders.mockResolvedValueOnce(providersResponse([chainProvider({ owner: "akash1aaa" })], new Uint8Array(0))); - const batches: ChainProvider[][] = []; + const batches: DiscoveredChainProvider[][] = []; for await (const batch of service.poll()) { batches.push(batch); } @@ -34,7 +38,7 @@ describe(ChainProviderPollerService.name, () => { }) ); - const batches: ChainProvider[][] = []; + const batches: DiscoveredChainProvider[][] = []; for await (const batch of service.poll()) { batches.push(batch); } @@ -50,7 +54,7 @@ describe(ChainProviderPollerService.name, () => { .mockResolvedValueOnce(providersResponse([chainProvider({ owner: "akash1aaa" })], pageOneKey)) .mockResolvedValueOnce(providersResponse([chainProvider({ owner: "akash1bbb" })], new Uint8Array(0))); - const batches: ChainProvider[][] = []; + const batches: DiscoveredChainProvider[][] = []; for await (const batch of service.poll()) { batches.push(batch); } @@ -108,17 +112,128 @@ describe(ChainProviderPollerService.name, () => { ]); }); - function setup() { + it("pins discovery and verification queries to one latest block", async () => { + const { service, getAllProvidersAttributes, getParams, getProviders, verificationClient } = setup({ moduleActive: true }); + getProviders.mockResolvedValueOnce(providersResponse([chainProvider({ owner: "akash1aaa" })], new Uint8Array(0))); + verificationClient.getProviderScreeningState.mockResolvedValue(providerVerificationState("akash1aaa")); + + const [batch] = await Array.fromAsync(service.poll()); + + expect(getParams).toHaveBeenCalledTimes(1); + expect(getParams).toHaveBeenCalledWith({}, expect.objectContaining({ headers: { "x-cosmos-block-height": "123" } })); + expect(getAllProvidersAttributes).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ headers: { "x-cosmos-block-height": "123" } })); + expect(getProviders).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ headers: { "x-cosmos-block-height": "123" } })); + expect(verificationClient.getProviderScreeningState).toHaveBeenCalledWith("akash1aaa", "123"); + expect(batch[0].verification).toMatchObject({ + moduleActive: true, + facts: { + completeness: { attestations: true, graces: true, snapshot: true }, + observedAt: "2026-08-24T12:00:00.000Z", + observedHeight: "123" + } + }); + }); + + it("skips provider verification fanout when the module is inactive", async () => { + const { service, getProviders, verificationClient } = setup({ moduleActive: false }); + getProviders.mockResolvedValueOnce(providersResponse([chainProvider({ owner: "akash1aaa" })], new Uint8Array(0))); + + const [batch] = await Array.fromAsync(service.poll()); + + expect(verificationClient.getProviderScreeningState).not.toHaveBeenCalled(); + expect(batch[0].verification).toMatchObject({ + moduleActive: false, + facts: { completeness: { attestations: false, graces: false, snapshot: false }, observedHeight: "123" } + }); + }); + + it("marks verification unknown without breaking provider discovery when params are unavailable", async () => { + const { service, getParams, getProviders, verificationClient } = setup(); + getParams.mockRejectedValueOnce(new Error("verification route unavailable")); + getProviders.mockResolvedValueOnce(providersResponse([chainProvider({ owner: "akash1aaa" })], new Uint8Array(0))); + + const [batch] = await Array.fromAsync(service.poll()); + + expect(batch).toHaveLength(1); + expect(batch[0].verification.moduleActive).toBeNull(); + expect(batch[0].verification.facts.completeness).toEqual({ attestations: false, graces: false, snapshot: false }); + expect(verificationClient.getProviderScreeningState).not.toHaveBeenCalled(); + }); + + it("marks one provider unknown when its pinned verification queries fail", async () => { + const { service, getProviders, verificationClient } = setup({ moduleActive: true }); + getProviders.mockResolvedValueOnce(providersResponse([chainProvider({ owner: "akash1aaa" })], new Uint8Array(0))); + verificationClient.getProviderScreeningState.mockRejectedValueOnce(new Error("query failed")); + + const [batch] = await Array.fromAsync(service.poll()); + + expect(batch[0].verification).toMatchObject({ + moduleActive: true, + facts: { completeness: { attestations: false, graces: false, snapshot: false }, observedHeight: "123" } + }); + }); + + it("bounds concurrent provider verification queries", async () => { + const { service, getProviders, verificationClient } = setup({ moduleActive: true, verificationQueryConcurrency: 2 }); + const providers = Array.from({ length: 6 }, (_, index) => chainProvider({ owner: `akash1provider${index}` })); + getProviders.mockResolvedValueOnce(providersResponse(providers, new Uint8Array(0))); + let active = 0; + let maxActive = 0; + verificationClient.getProviderScreeningState.mockImplementation(async provider => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise(resolve => setTimeout(resolve, 5)); + active--; + return providerVerificationState(provider); + }); + + await Array.fromAsync(service.poll()); + + expect(verificationClient.getProviderScreeningState).toHaveBeenCalledTimes(6); + expect(maxActive).toBe(2); + }); + + function setup(input: { moduleActive?: boolean; verificationQueryConcurrency?: number } = {}) { const chainSDK = mockDeep(); const getAllProvidersAttributes = chainSDK.akash.audit.v1.getAllProvidersAttributes; + const getLatestBlock = chainSDK.cosmos.base.tendermint.v1beta1.getLatestBlock; + const getParams = chainSDK.akash.verification.v1.getParams; const getProviders = chainSDK.akash.provider.v1beta4.getProviders; + const verificationClient = mock(); getAllProvidersAttributes.mockResolvedValue(auditResponse([], new Uint8Array(0))); + getLatestBlock.mockResolvedValue({ + blockId: undefined, + block: undefined, + sdkBlock: { + data: undefined, + evidence: undefined, + lastCommit: undefined, + header: { + appHash: new Uint8Array(), + chainId: "testnet", + consensusHash: new Uint8Array(), + dataHash: new Uint8Array(), + evidenceHash: new Uint8Array(), + height: 123n, + lastBlockId: undefined, + lastCommitHash: new Uint8Array(), + lastResultsHash: new Uint8Array(), + nextValidatorsHash: new Uint8Array(), + proposerAddress: "akashvaloper1proposer", + time: new Date("2026-08-24T12:00:00.000Z"), + validatorsHash: new Uint8Array(), + version: undefined + } + } + }); + getParams.mockResolvedValue({ params: mock>({ verificationModuleActive: input.moduleActive ?? false }) }); const loggerFactory: LoggerFactory = () => mock>(); - const service = new ChainProviderPollerService(chainSDK, loggerFactory); + const config = { VERIFICATION_QUERY_CONCURRENCY: input.verificationQueryConcurrency ?? 20 } as EnvConfig; + const service = new ChainProviderPollerService(chainSDK, verificationClient, config, loggerFactory); - return { service, chainSDK, getAllProvidersAttributes, getProviders }; + return { service, chainSDK, getAllProvidersAttributes, getParams, getProviders, verificationClient }; } }); @@ -138,3 +253,20 @@ function providersResponse(providers: ChainSDKProvider[], nextKey: Uint8Array): function auditResponse(providers: AuditRecord[], nextKey: Uint8Array): AuditResponse { return { providers, pagination: { nextKey } } as unknown as AuditResponse; } + +function providerVerificationState(provider: string): ProviderVerificationScreeningState { + return mock({ + provider, + attestations: [ + mock({ + auditor: "akash1auditor", + capabilities: [CapabilityFlag.capability_persistent_storage], + status: AttestationStatus.attestation_status_valid, + tier: VerificationTier.verification_tier_verified + }) + ], + grace: null, + snapshot: null, + observedHeight: "123" + }); +} diff --git a/apps/provider-inventory/src/services/chain-provider-poller/chain-provider-poller.service.ts b/apps/provider-inventory/src/services/chain-provider-poller/chain-provider-poller.service.ts index c9110f56a7..9e72777ad9 100644 --- a/apps/provider-inventory/src/services/chain-provider-poller/chain-provider-poller.service.ts +++ b/apps/provider-inventory/src/services/chain-provider-poller/chain-provider-poller.service.ts @@ -1,34 +1,58 @@ import type { ChainNodeWebSDK } from "@akashnetwork/chain-sdk/web"; import type { LoggerService } from "@akashnetwork/logging"; +import { ProviderVerificationQueryClient } from "@akashnetwork/provider-verification"; +import { Sema } from "async-sema"; import { inject, singleton } from "tsyringe"; import { paginate } from "@src/lib/generators/paginate/paginate"; +import { mapProviderVerification } from "@src/mappers/provider-verification-mapper/provider-verification-mapper"; +import type { EnvConfig } from "@src/providers/app-config.provider"; +import { APP_CONFIG } from "@src/providers/app-config.provider"; import { CHAIN_SDK } from "@src/providers/chain-sdk.provider"; import type { LoggerFactory } from "@src/providers/logger-factory.provider"; import { LOGGER_FACTORY } from "@src/providers/logger-factory.provider"; -import type { ChainProvider } from "@src/types/chain-provider"; +import { PROVIDER_VERIFICATION_QUERY_CLIENT } from "@src/providers/provider-verification-query-client.provider"; +import type { ChainProvider, DiscoveredChainProvider } from "@src/types/chain-provider"; + +interface VerificationObservation { + moduleActive: boolean | null; + observedAt: Date; + observedHeight: string; +} @singleton() export class ChainProviderPollerService { readonly #logger: LoggerService; readonly #chainSDK: ChainNodeWebSDK; + readonly #verificationClient: ProviderVerificationQueryClient; + readonly #verificationQueryConcurrency: number; - constructor(@inject(CHAIN_SDK) chainSDK: ChainNodeWebSDK, @inject(LOGGER_FACTORY) loggerFactory: LoggerFactory) { + constructor( + @inject(CHAIN_SDK) chainSDK: ChainNodeWebSDK, + @inject(PROVIDER_VERIFICATION_QUERY_CLIENT) verificationClient: ProviderVerificationQueryClient, + @inject(APP_CONFIG) config: EnvConfig, + @inject(LOGGER_FACTORY) loggerFactory: LoggerFactory + ) { this.#chainSDK = chainSDK; + this.#verificationClient = verificationClient; + this.#verificationQueryConcurrency = config.VERIFICATION_QUERY_CONCURRENCY; this.#logger = loggerFactory({ context: "ChainProviderPoller" }); } - async *poll(input: { signal?: AbortSignal; batchSize?: number } = {}): AsyncGenerator { + async *poll(input: { signal?: AbortSignal; batchSize?: number } = {}): AsyncGenerator { const MAX_PROVIDERS_PER_BATCH = input.batchSize ?? 500; this.#logger.info({ event: "CHAIN_POLL_START" }); + const verificationObservation = await this.#getVerificationObservation(input.signal); + const queryOptions = { + headers: { "x-cosmos-block-height": verificationObservation.observedHeight }, + signal: input.signal + }; + const signedByOwner = new Map; auditors: Set }>(); const auditPages = paginate( async key => { - const response = await this.#chainSDK.akash.audit.v1.getAllProvidersAttributes( - { pagination: { limit: MAX_PROVIDERS_PER_BATCH, key } }, - { signal: input.signal } - ); + const response = await this.#chainSDK.akash.audit.v1.getAllProvidersAttributes({ pagination: { limit: MAX_PROVIDERS_PER_BATCH, key } }, queryOptions); return { items: response.providers, nextKey: response.pagination?.nextKey }; }, { signal: input.signal } @@ -51,10 +75,7 @@ export class ChainProviderPollerService { const providerPages = paginate( async key => { this.#logger.info({ event: "CHAIN_PROVIDERS_POLL_BATCH", nextKey: key ? Buffer.from(key).toString("base64") : null }); - const response = await this.#chainSDK.akash.provider.v1beta4.getProviders( - { pagination: { limit: MAX_PROVIDERS_PER_BATCH, key } }, - { signal: input.signal } - ); + const response = await this.#chainSDK.akash.provider.v1beta4.getProviders({ pagination: { limit: MAX_PROVIDERS_PER_BATCH, key } }, queryOptions); return { items: response.providers, nextKey: response.pagination?.nextKey }; }, { signal: input.signal } @@ -79,12 +100,58 @@ export class ChainProviderPollerService { } if (validProviders.length > 0) { - yield validProviders; + yield await this.#attachVerification(validProviders, verificationObservation, input.signal); } } this.#logger.info({ event: "CHAIN_PROVIDERS_POLL_COMPLETE", providerCount }); } + + async #getVerificationObservation(signal?: AbortSignal): Promise { + const blockResponse = await this.#chainSDK.cosmos.base.tendermint.v1beta1.getLatestBlock({}, { signal }); + const header = blockResponse.sdkBlock?.header ?? blockResponse.block?.header; + if (!header?.time) throw new Error("Latest block response did not include a height and time"); + + const observedHeight = header.height.toString(); + const observedAt = header.time; + + try { + const response = await this.#chainSDK.akash.verification.v1.getParams({}, { headers: { "x-cosmos-block-height": observedHeight }, signal }); + return { moduleActive: response.params?.verificationModuleActive ?? null, observedAt, observedHeight }; + } catch (error) { + this.#logger.warn({ event: "VERIFICATION_PARAMS_UNAVAILABLE", error, observedHeight }); + return { moduleActive: null, observedAt, observedHeight }; + } + } + + async #attachVerification(providers: ChainProvider[], observation: VerificationObservation, signal?: AbortSignal): Promise { + if (observation.moduleActive !== true) { + return providers.map(provider => ({ + ...provider, + verification: mapProviderVerification({ ...observation, state: null }) + })); + } + + const semaphore = new Sema(this.#verificationQueryConcurrency); + return await Promise.all( + providers.map(async provider => { + await semaphore.acquire(); + try { + if (signal?.aborted) { + return { ...provider, verification: mapProviderVerification({ ...observation, state: null }) }; + } + + const state = await this.#verificationClient.getProviderScreeningState(provider.owner, observation.observedHeight); + return { ...provider, verification: mapProviderVerification({ ...observation, state }) }; + } catch (error) { + this.#logger.warn({ event: "VERIFICATION_PROVIDER_STATE_UNAVAILABLE", error, owner: provider.owner, observedHeight: observation.observedHeight }); + return { ...provider, verification: mapProviderVerification({ ...observation, state: null }) }; + } finally { + semaphore.release(); + } + }) + ); + } } function isValidUrl(rawUrl: string): boolean { diff --git a/apps/provider-inventory/src/services/discovery-scheduler/discovery-scheduler.service.spec.ts b/apps/provider-inventory/src/services/discovery-scheduler/discovery-scheduler.service.spec.ts index 9b45495b54..ddfa0bb749 100644 --- a/apps/provider-inventory/src/services/discovery-scheduler/discovery-scheduler.service.spec.ts +++ b/apps/provider-inventory/src/services/discovery-scheduler/discovery-scheduler.service.spec.ts @@ -7,7 +7,7 @@ import type { ProviderIncidentRepository } from "@src/repositories/provider-inci import type { ProviderInventory, ProviderInventoryRepository } from "@src/repositories/provider-inventory/provider-inventory.repository"; import type { ChainProviderPollerService } from "@src/services/chain-provider-poller/chain-provider-poller.service"; import type { StreamLifecycleManagerService } from "@src/services/stream-lifecycle-manager/stream-lifecycle-manager.service"; -import type { ChainProvider } from "@src/types/chain-provider"; +import type { DiscoveredChainProvider } from "@src/types/chain-provider"; import type { TimerService } from "../timer/timer.service"; import { DiscoverySchedulerService } from "./discovery-scheduler.service"; @@ -78,6 +78,7 @@ describe(DiscoverySchedulerService.name, () => { expect(lifecycle.getRegistry).toHaveBeenCalled(); expect(writer.bulkUpsertProviders).toHaveBeenCalledWith([fresh]); + expect(writer.bulkUpdateVerification).toHaveBeenCalledWith([fresh]); expect(lifecycle.start).toHaveBeenCalledWith({ ...fresh, offlineSince: null }, expect.any(AbortSignal)); }); @@ -194,6 +195,17 @@ describe(DiscoverySchedulerService.name, () => { expect(lifecycle.start).not.toHaveBeenCalled(); }); + it("logs verification persistence errors without breaking provider discovery", async () => { + const provider = createProvider(); + const { writer, lifecycle, logger } = setup({ providers: [provider] }); + writer.bulkUpdateVerification.mockRejectedValueOnce(new Error("DB update failed")); + + await vi.advanceTimersByTimeAsync(0); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "UPSERT_PROVIDER_VERIFICATION_ERROR", owners: [provider.owner] })); + expect(lifecycle.start).toHaveBeenCalledWith({ ...provider, offlineSince: null }, expect.any(AbortSignal)); + }); + it("prunes incidents older than the configured retention window on the first tick", async () => { const { incidentRepository, config } = setup(); @@ -332,7 +344,7 @@ describe(DiscoverySchedulerService.name, () => { }); function setup(input?: { - providers?: ChainProvider[]; + providers?: DiscoveredChainProvider[]; pollError?: Error; pollDelay?: (config: EnvConfig) => number; onlineOwners?: Pick[]; @@ -348,6 +360,7 @@ describe(DiscoverySchedulerService.name, () => { lifecycle.stopAndDelete.mockResolvedValue(); lifecycle.waitForPendingConnections.mockResolvedValue(); writer.bulkUpsertProviders.mockResolvedValue([]); + writer.bulkUpdateVerification.mockResolvedValue(); incidentRepository.getOfflineSince.mockResolvedValue(input?.offlineSince ?? new Map()); incidentRepository.deleteEndedBefore.mockResolvedValue(0); @@ -419,13 +432,24 @@ function asyncIterableThatThrows(error: Error): AsyncGenerator { } as AsyncGenerator; } -function createProvider(overrides?: Partial): ChainProvider { +function createProvider(overrides?: Partial): DiscoveredChainProvider { return { owner: "akash1abc", hostUri: "https://provider.example.com:8443", selfAttributes: [], signedAttributes: [], auditedBy: [], + verification: { + moduleActive: null, + facts: { + attestations: [], + completeness: { attestations: false, graces: false, snapshot: false }, + graces: [], + observedAt: "2026-08-24T12:00:00.000Z", + observedHeight: "123", + snapshot: null + } + }, ...overrides }; } diff --git a/apps/provider-inventory/src/services/discovery-scheduler/discovery-scheduler.service.ts b/apps/provider-inventory/src/services/discovery-scheduler/discovery-scheduler.service.ts index 19643e9272..48d6c08dc0 100644 --- a/apps/provider-inventory/src/services/discovery-scheduler/discovery-scheduler.service.ts +++ b/apps/provider-inventory/src/services/discovery-scheduler/discovery-scheduler.service.ts @@ -11,7 +11,7 @@ import { ProviderIncidentRepository } from "@src/repositories/provider-incident/ import { ProviderInventoryRepository } from "@src/repositories/provider-inventory/provider-inventory.repository"; import { ChainProviderPollerService } from "@src/services/chain-provider-poller/chain-provider-poller.service"; import { StreamLifecycleManagerService } from "@src/services/stream-lifecycle-manager/stream-lifecycle-manager.service"; -import type { ChainProvider } from "@src/types/chain-provider"; +import type { DiscoveredChainProvider } from "@src/types/chain-provider"; import { TimerService } from "../timer/timer.service"; @singleton() @@ -215,15 +215,23 @@ export class DiscoverySchedulerService { } } - async #upsertProviders(providers: ChainProvider[]) { + async #upsertProviders(providers: DiscoveredChainProvider[]) { const owners = providers.map(p => p.owner); + let updatedProviders: Awaited>; try { - const updatedProviders = await this.#repository.bulkUpsertProviders(providers); + updatedProviders = await this.#repository.bulkUpsertProviders(providers); this.#logger.debug({ event: "UPSERT_PROVIDERS_UPSERTED", owners }); - return new Set(updatedProviders?.map(p => p.owner) ?? []); } catch (error) { this.#logger.error({ event: "UPSERT_PROVIDERS_ERROR", owners, error }); return null; } + + try { + await this.#repository.bulkUpdateVerification(providers); + } catch (error) { + this.#logger.error({ event: "UPSERT_PROVIDER_VERIFICATION_ERROR", owners, error }); + } + + return new Set(updatedProviders.map(p => p.owner)); } } diff --git a/apps/provider-inventory/src/types/chain-provider.ts b/apps/provider-inventory/src/types/chain-provider.ts index 4c892acfdc..27490e6d27 100644 --- a/apps/provider-inventory/src/types/chain-provider.ts +++ b/apps/provider-inventory/src/types/chain-provider.ts @@ -20,3 +20,8 @@ export interface ChainProvider { export interface ChainProviderWithOfflineSince extends ChainProvider { offlineSince: Date | null; } + +export interface DiscoveredChainProvider extends ChainProvider { + verification: StoredProviderVerification; +} +import type { StoredProviderVerification } from "./provider-verification"; diff --git a/apps/provider-inventory/src/types/inventory.ts b/apps/provider-inventory/src/types/inventory.ts index 881c773702..e2956f377d 100644 --- a/apps/provider-inventory/src/types/inventory.ts +++ b/apps/provider-inventory/src/types/inventory.ts @@ -1,3 +1,5 @@ +import type { ProviderVerificationFailure, ProviderVerificationSummary } from "@akashnetwork/provider-verification"; + import type { DailyDowntimeRow } from "@src/repositories/provider-incident/provider-incident.repository"; import type { ParsedGPUAttributes } from "../mappers/gpu-attribute-parser/gpu-attribute-parser"; import type { ParsedStorageAttributes } from "../mappers/storage-attribute-parser/storage-attribute-parser"; @@ -75,6 +77,25 @@ export interface BidScreeningResult { location: string | null; organization: string | null; incidents: Omit[]; + verification?: + | { outcome: "pass"; summary: ProviderVerificationSummary } + | { + outcome: "not_evaluated"; + incompleteFacts: Array<"params" | "attestations" | "graces" | "snapshot" | "module_inactive">; + summary: ProviderVerificationSummary; + }; +} + +export interface BidScreeningExclusion { + owner: string; + firstFailure: ProviderVerificationFailure; + failures: ProviderVerificationFailure[]; + summary: ProviderVerificationSummary; +} + +export interface BidScreeningSelection { + providers: BidScreeningResult[]; + exclusions?: BidScreeningExclusion[]; } export type ToJSON = T extends Uint8Array ? bigint : T extends object ? { -readonly [K in keyof T]: ToJSON> } : T; diff --git a/apps/provider-inventory/src/types/provider-verification.ts b/apps/provider-inventory/src/types/provider-verification.ts new file mode 100644 index 0000000000..fe5238e885 --- /dev/null +++ b/apps/provider-inventory/src/types/provider-verification.ts @@ -0,0 +1,33 @@ +import type { AttestationStatus, CapabilityFlag, VerificationGraceStatus, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import type { ProviderVerificationCompleteness } from "@akashnetwork/provider-verification"; + +export interface StoredVerificationAttestation { + auditor: string; + capabilities: CapabilityFlag[]; + status: AttestationStatus; + tier: VerificationTier; +} + +export interface StoredVerificationGrace { + preservedTier: VerificationTier; + status: VerificationGraceStatus; +} + +export interface StoredVerificationSnapshot { + complianceDeadline: string | null; + suspended: boolean; +} + +export interface StoredProviderVerificationFacts { + attestations: StoredVerificationAttestation[]; + completeness: ProviderVerificationCompleteness; + graces: StoredVerificationGrace[]; + observedAt: string; + observedHeight: string; + snapshot: StoredVerificationSnapshot | null; +} + +export interface StoredProviderVerification { + facts: StoredProviderVerificationFacts; + moduleActive: boolean | null; +} diff --git a/apps/provider-inventory/test/functional/discovery-pipeline.spec.ts b/apps/provider-inventory/test/functional/discovery-pipeline.spec.ts index a4e2ac44c7..1038b5899c 100644 --- a/apps/provider-inventory/test/functional/discovery-pipeline.spec.ts +++ b/apps/provider-inventory/test/functional/discovery-pipeline.spec.ts @@ -112,6 +112,34 @@ describe("DiscoveryScheduler pipeline", () => { let providers: ChainProvider[] = input.providers ?? []; const chainSDK = mockDeep(); + chainSDK.cosmos.base.tendermint.v1beta1.getLatestBlock.mockResolvedValue({ + blockId: undefined, + block: undefined, + sdkBlock: { + data: undefined, + evidence: undefined, + lastCommit: undefined, + header: { + appHash: new Uint8Array(), + chainId: "testnet", + consensusHash: new Uint8Array(), + dataHash: new Uint8Array(), + evidenceHash: new Uint8Array(), + height: 123n, + lastBlockId: undefined, + lastCommitHash: new Uint8Array(), + lastResultsHash: new Uint8Array(), + nextValidatorsHash: new Uint8Array(), + proposerAddress: "akashvaloper1proposer", + time: new Date("2026-08-24T12:00:00.000Z"), + validatorsHash: new Uint8Array(), + version: undefined + } + } + }); + chainSDK.akash.verification.v1.getParams.mockResolvedValue({ + params: mock>["params"]>>({ verificationModuleActive: false }) + }); chainSDK.akash.provider.v1beta4.getProviders.mockImplementation(() => { if (pollError) return Promise.reject(pollError); return Promise.resolve({ @@ -168,6 +196,7 @@ describe("DiscoveryScheduler pipeline", () => { function createProvider(overrides: Partial & Pick): ChainProvider { return { + auditedBy: [], selfAttributes: [], signedAttributes: [], ...overrides From 5c7d0783cf769c032ae6e57850908c5aa149c324 Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 20:36:14 -0700 Subject: [PATCH 04/12] feat(provider): expose verification views in API Attach normalized verification facts to provider responses and expose screening explanations, rollout readiness, and tier-demotion feeds. Signed-off-by: Joseph Chalabi --- apps/api/package.json | 1 + .../http-schemas/bid-screening.schema.spec.ts | 133 + .../http-schemas/bid-screening.schema.ts | 99 +- .../routes/bid-screening.router.spec.ts | 146 + .../routes/bid-screening.router.ts | 52 +- apps/api/src/core/config/env.config.ts | 5 + .../provider/provider.controller.spec.ts | 57 + .../provider/provider.controller.ts | 14 +- .../provider/http-schemas/provider.schema.ts | 5 +- ...der-verification-readiness.service.spec.ts | 63 + ...provider-verification-readiness.service.ts | 38 + ...ification-tier-demotion.repository.spec.ts | 77 + ...r-verification-tier-demotion.repository.ts | 55 + ...vider-verification-tier-demotion.schema.ts | 31 + ...verification-tier-demotion.service.spec.ts | 94 + ...ider-verification-tier-demotion.service.ts | 59 + .../provider-verification.mapper.spec.ts | 384 + .../provider-verification.mapper.ts | 490 ++ .../provider-verification.repository.spec.ts | 81 + .../provider-verification.repository.ts | 174 + .../provider-verification.schema.ts | 223 + .../provider-verification.service.spec.ts | 298 + .../provider-verification.service.ts | 403 + .../routes/providers/providers.router.ts | 10 +- .../provider/provider.service.spec.ts | 153 +- .../services/provider/provider.service.ts | 59 +- apps/api/src/routers/internalRouter.ts | 1 + apps/api/src/routes/internal/index.ts | 10 +- .../providerVerificationTierDemotions.ts | 36 + apps/api/src/types/provider.ts | 3 + apps/api/src/utils/map/provider.ts | 3 +- apps/api/swagger/openapi.json | 7064 +++++++++++------ packages/console-api-types/src/schema.d.ts | 487 +- 33 files changed, 8178 insertions(+), 2630 deletions(-) create mode 100644 apps/api/src/bid-screening/http-schemas/bid-screening.schema.spec.ts create mode 100644 apps/api/src/bid-screening/routes/bid-screening.router.spec.ts create mode 100644 apps/api/src/provider/controllers/provider/provider.controller.spec.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification-readiness.service.spec.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification-readiness.service.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification-tier-demotion.repository.spec.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification-tier-demotion.repository.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification-tier-demotion.schema.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification-tier-demotion.service.spec.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification-tier-demotion.service.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification.mapper.spec.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification.mapper.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification.repository.spec.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification.repository.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification.schema.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification.service.spec.ts create mode 100644 apps/api/src/provider/provider-verification/provider-verification.service.ts create mode 100644 apps/api/src/routes/internal/providerVerificationTierDemotions.ts diff --git a/apps/api/package.json b/apps/api/package.json index eebb667f54..90f59a0c05 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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..4c8594bb8e 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -10160,6 +10160,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 +10310,8 @@ "featEndpointCustomDomain", "workloadSupportChia", "workloadSupportChiaCapabilities", - "featEndpointIp" + "featEndpointIp", + "verification" ] } } @@ -10617,614 +10720,1638 @@ "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", - "hardwareDisk", - "featPersistentStorage", - "featPersistentStorageType", - "hardwareMemory", - "networkProvider", - "networkSpeedDown", - "networkSpeedUp", - "tier", - "featEndpointCustomDomain", - "workloadSupportChia", - "workloadSupportChiaCapabilities", - "featEndpointIp", - "uptime" - ] - } - } - } - }, - "400": { - "description": "Invalid address" - }, - "404": { - "description": "Provider not found" - } - } - } - }, - "/v1/providers/{providerAddress}/active-leases-graph-data": { - "get": { - "tags": [ - "Analytics", - "Providers" - ], - "security": [], - "parameters": [ - { - "schema": { - "type": "string", - "example": "akash18ga02jzaq8cw52anyhzkwta5wygufgu6zsz6xc" - }, - "required": true, - "name": "providerAddress", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Returns a provider's active leases graph data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "currentValue": { - "type": "number" - }, - "compareValue": { - "type": "number" - }, - "snapshots": { - "type": "array", - "items": { - "type": "object", - "properties": { - "date": { - "type": "string", - "example": "2021-07-01T00:00:00.000Z" - }, - "value": { - "type": "number", - "example": 100 - } - }, - "required": [ - "date", - "value" - ] - } - }, - "now": { - "type": "object", - "properties": { - "count": { - "type": "number", - "example": 100 - } - }, - "required": [ - "count" - ] }, - "compare": { + "verification": { "type": "object", + "nullable": true, "properties": { - "count": { - "type": "number", - "example": 100 - } - }, - "required": [ - "count" - ] - } - }, - "required": [ - "currentValue", - "compareValue", - "snapshots", - "now", - "compare" - ] - } - } - } - }, - "400": { - "description": "Invalid address" - } - } - } - }, - "/v1/auditors": { - "get": { - "tags": [ - "Providers" - ], - "security": [], - "summary": "Get a list of auditors.", - "responses": { - "200": { - "description": "List of auditors", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "address": { - "type": "string" - }, - "website": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "address", - "website" - ] - } - } - } - } - } - } - } - }, - "/v1/provider-attributes-schema": { - "get": { - "summary": "Get the provider attributes schema", - "tags": [ - "Providers" - ], - "security": [], - "responses": { - "200": { - "description": "Return the provider attributes schema", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "host": { - "type": "object", - "properties": { - "key": { + "provider": { "type": "string" }, - "type": { + "providerDeclaredTier": { "type": "string", - "enum": [ - "string", - "number", - "boolean", - "option", - "multiple-option" - ] + "nullable": true, + "description": "Legacy, self-declared provider tier attribute; not an AEP-86 attestation" }, - "required": { - "type": "boolean" + "moduleActive": { + "type": "boolean", + "nullable": true }, - "description": { - "type": "string" + "provenance": { + "type": "object", + "properties": { + "providerTier": { + "type": "string", + "enum": [ + "provider self-declared" + ] + }, + "inventory": { + "type": "string", + "enum": [ + "provider-signed inventory" + ] + }, + "attestations": { + "type": "string", + "enum": [ + "auditor-attested" + ] + } + }, + "required": [ + "providerTier", + "inventory", + "attestations" + ] }, - "values": { - "type": "array", - "nullable": true, - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "description": { + "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" - }, - "value": { - "nullable": true } }, - "required": [ - "key", - "description" - ] - } - } - }, - "required": [ - "key", - "type", - "required", - "description" - ] - }, - "email": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "string", - "number", - "boolean", - "option", - "multiple-option" + "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": [ + "bestAttestedTier", + "effectiveTier", + "capabilities", + "validAttestationCount", + "validAuditorCount", + "validAuditors", + "snapshotState", + "maintenanceState", + "reviewState" ] }, - "required": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "values": { + "attestations": { "type": "array", - "nullable": true, "items": { "type": "object", "properties": { - "key": { + "provider": { "type": "string" }, - "description": { + "auditor": { "type": "string" }, - "value": { - "nullable": true + "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": [ - "key", - "description" + "provider", + "auditor", + "tier", + "capabilities", + "evidenceHash", + "fee", + "feeStatus", + "createdAt", + "expiresAt", + "status", + "voidedReason", + "deposit", + "depositStatus", + "auditEscrowId", + "faultAttribution" ] } - } - }, - "required": [ - "key", - "type", - "required", - "description" - ] - }, - "organization": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "string", - "number", - "boolean", - "option", - "multiple-option" - ] - }, - "required": { - "type": "boolean" - }, - "description": { - "type": "string" }, - "values": { - "type": "array", + "bond": { + "type": "object", "nullable": true, - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" + "properties": { + "provider": { + "type": "string" + }, + "bondedAmount": { + "type": "object", + "nullable": true, + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } }, - "description": { - "type": "string" + "required": [ + "denom", + "amount" + ] + }, + "requiredForCurrentTier": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } }, - "value": { - "nullable": true + "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" + ] } }, - "required": [ - "key", - "description" - ] - } - } - }, - "required": [ - "key", - "type", - "required", - "description" - ] - }, - "website": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "string", - "number", - "boolean", - "option", - "multiple-option" + "slashed": { + "type": "boolean" + }, + "lastSlashTime": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "provider", + "bondedAmount", + "requiredForCurrentTier", + "unbondingEntries", + "slashed", + "lastSlashTime" ] }, - "required": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "values": { - "type": "array", + "snapshot": { + "type": "object", "nullable": true, - "items": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "description": { - "type": "string" + "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" + ] + } }, - "value": { - "nullable": true - } + "required": [ + "totalGpus", + "totalVcpus", + "totalMemoryMb", + "totalStorageMb", + "activeLeases", + "softwareVersion", + "softwareSignature", + "softwareIdentity" + ] }, - "required": [ - "key", - "description" - ] - } - } - }, - "required": [ - "key", - "type", - "required", - "description" - ] - }, - "tier": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "string", - "number", - "boolean", - "option", - "multiple-option" + "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" ] }, - "required": { - "type": "boolean" - }, - "description": { - "type": "string" + "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" + ] }, - "values": { + "auditEscrows": { "type": "array", - "nullable": true, "items": { "type": "object", "properties": { - "key": { - "type": "string" + "id": { + "type": "string", + "pattern": "^\\d+$" }, - "description": { + "provider": { "type": "string" }, - "value": { + "consumedByAuditor": { + "type": "string", "nullable": true - } - }, - "required": [ - "key", - "description" + }, + "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" ] } - } - }, - "required": [ - "key", - "type", - "required", - "description" - ] - }, - "status-page": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "string", - "number", - "boolean", - "option", - "multiple-option" - ] }, - "required": { - "type": "boolean" - }, - "description": { - "type": "string" - }, - "values": { + "maintenance": { "type": "array", - "nullable": true, "items": { "type": "object", "properties": { - "key": { - "type": "string" - }, - "description": { - "type": "string" + "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" + ] }, - "value": { - "nullable": true + "status": { + "type": "string", + "enum": [ + "unspecified", + "scheduled", + "active", + "elapsed", + "closed", + "unknown" + ] } }, "required": [ - "key", - "description" + "record", + "status" ] } - } - }, - "required": [ - "key", - "type", - "required", - "description" - ] - }, - "location-region": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "string", - "number", - "boolean", - "option", - "multiple-option" - ] - }, - "required": { - "type": "boolean" - }, - "description": { - "type": "string" }, - "values": { + "discrepancies": { "type": "array", - "nullable": true, "items": { "type": "object", "properties": { - "key": { + "id": { + "type": "string", + "pattern": "^\\d+$" + }, + "provider": { "type": "string" }, - "description": { + "auditorA": { "type": "string" }, - "value": { - "nullable": true + "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": [ - "key", - "description" + "id", + "provider", + "auditorA", + "auditorATier", + "auditorB", + "auditorBTier", + "timestamp", + "resolutionStatus", + "resolutionProposalId", + "graceRecordId", + "resolutionReason", + "faultAttribution", + "resolutionEvidenceHash" ] } - } - }, - "required": [ - "key", - "type", - "required", - "description" - ] - }, - "country": { - "type": "object", - "properties": { - "key": { - "type": "string" }, - "type": { + "observedAt": { "type": "string", - "enum": [ - "string", - "number", - "boolean", - "option", - "multiple-option" - ] + "format": "date-time" }, - "required": { - "type": "boolean" + "observedHeight": { + "type": "string", + "pattern": "^\\d+$" }, - "description": { - "type": "string" + "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" + ] + } + }, + "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", + "hardwareDisk", + "featPersistentStorage", + "featPersistentStorageType", + "hardwareMemory", + "networkProvider", + "networkSpeedDown", + "networkSpeedUp", + "tier", + "featEndpointCustomDomain", + "workloadSupportChia", + "workloadSupportChiaCapabilities", + "featEndpointIp", + "verification", + "uptime" + ] + } + } + } + }, + "400": { + "description": "Invalid address" + }, + "404": { + "description": "Provider not found" + } + } + } + }, + "/v1/providers/{providerAddress}/active-leases-graph-data": { + "get": { + "tags": [ + "Analytics", + "Providers" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "example": "akash18ga02jzaq8cw52anyhzkwta5wygufgu6zsz6xc" + }, + "required": true, + "name": "providerAddress", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns a provider's active leases graph data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currentValue": { + "type": "number" + }, + "compareValue": { + "type": "number" + }, + "snapshots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "example": "2021-07-01T00:00:00.000Z" + }, + "value": { + "type": "number", + "example": 100 + } + }, + "required": [ + "date", + "value" + ] + } + }, + "now": { + "type": "object", + "properties": { + "count": { + "type": "number", + "example": 100 + } + }, + "required": [ + "count" + ] + }, + "compare": { + "type": "object", + "properties": { + "count": { + "type": "number", + "example": 100 + } + }, + "required": [ + "count" + ] + } + }, + "required": [ + "currentValue", + "compareValue", + "snapshots", + "now", + "compare" + ] + } + } + } + }, + "400": { + "description": "Invalid address" + } + } + } + }, + "/v1/auditors": { + "get": { + "tags": [ + "Providers" + ], + "security": [], + "summary": "Get a list of auditors.", + "responses": { + "200": { + "description": "List of auditors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "address": { + "type": "string" + }, + "website": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "address", + "website" + ] + } + } + } + } + } + } + } + }, + "/v1/provider-attributes-schema": { + "get": { + "summary": "Get the provider attributes schema", + "tags": [ + "Providers" + ], + "security": [], + "responses": { + "200": { + "description": "Return the provider attributes schema", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "host": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "email": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "organization": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "website": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "tier": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "status-page": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "location-region": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "values": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "nullable": true + } + }, + "required": [ + "key", + "description" + ] + } + } + }, + "required": [ + "key", + "type", + "required", + "description" + ] + }, + "country": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "option", + "multiple-option" + ] + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" }, "values": { "type": "array", @@ -14099,56 +15226,281 @@ "total": { "type": "object", "properties": { - "active": { - "type": "number" - }, - "pending": { - "type": "number" + "active": { + "type": "number" + }, + "pending": { + "type": "number" + }, + "available": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "active", + "pending", + "available", + "total" + ] + } + }, + "required": [ + "ephemeral", + "persistent", + "total" + ] + } + }, + "required": [ + "cpu", + "gpu", + "memory", + "storage" + ] + } + }, + "required": [ + "activeProviderCount", + "resources" + ] + } + } + } + } + } + } + }, + "/v1/blocks": { + "get": { + "summary": "Get a list of recent blocks.", + "tags": [ + "Blocks" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "number", + "description": "Number of blocks to return", + "minimum": 1, + "maximum": 100, + "example": 20, + "default": 20 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns block list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "proposer": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "operatorAddress": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "avatarUrl": { + "type": "string", + "nullable": true + } + }, + "required": [ + "address", + "operatorAddress", + "moniker", + "avatarUrl" + ] + }, + "transactionCount": { + "type": "number" + }, + "totalTransactionCount": { + "type": "number" + }, + "datetime": { + "type": "string" + } + }, + "required": [ + "height", + "proposer", + "transactionCount", + "totalTransactionCount", + "datetime" + ] + } + } + } + } + } + } + } + }, + "/v1/blocks/{height}": { + "get": { + "summary": "Get a block by height.", + "tags": [ + "Blocks" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "number", + "nullable": true, + "description": "Block Height", + "example": 12121212 + }, + "required": false, + "name": "height", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Returns predicted block date", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "height": { + "type": "number" + }, + "datetime": { + "type": "string" + }, + "proposer": { + "type": "object", + "properties": { + "operatorAddress": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "address": { + "type": "string" + } + }, + "required": [ + "operatorAddress", + "moniker", + "address" + ] + }, + "hash": { + "type": "string" + }, + "gasUsed": { + "type": "number" + }, + "gasWanted": { + "type": "number" + }, + "transactions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hash": { + "type": "string" + }, + "isSuccess": { + "type": "boolean" + }, + "error": { + "type": "string", + "nullable": true + }, + "fee": { + "type": "number" + }, + "datetime": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "available": { - "type": "number" + "type": { + "type": "string" }, - "total": { + "amount": { "type": "number" } }, "required": [ - "active", - "pending", - "available", - "total" + "id", + "type", + "amount" ] } - }, - "required": [ - "ephemeral", - "persistent", - "total" - ] - } - }, - "required": [ - "cpu", - "gpu", - "memory", - "storage" - ] + } + }, + "required": [ + "hash", + "isSuccess", + "fee", + "datetime", + "messages" + ] + } } }, "required": [ - "activeProviderCount", - "resources" + "height", + "datetime", + "proposer", + "hash", + "gasUsed", + "gasWanted", + "transactions" ] } } } + }, + "400": { + "description": "Invalid height" + }, + "404": { + "description": "Block not found" } } } }, - "/v1/blocks": { + "/v1/predicted-block-date/{height}": { "get": { - "summary": "Get a list of recent blocks.", + "summary": "Get the estimated date of a future block.", "tags": [ "Blocks" ], @@ -14157,7 +15509,138 @@ { "schema": { "type": "number", - "description": "Number of blocks to return", + "description": "Block height", + "example": 20000000 + }, + "required": false, + "name": "height", + "in": "path" + }, + { + "schema": { + "type": "number", + "default": 10000, + "description": "Block window", + "example": 10000 + }, + "required": false, + "name": "blockWindow", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns predicted block date", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "predictedDate": { + "type": "string" + }, + "height": { + "type": "number", + "example": 10000000 + }, + "blockWindow": { + "type": "number", + "example": 10000 + } + }, + "required": [ + "predictedDate", + "height", + "blockWindow" + ] + } + } + } + }, + "400": { + "description": "Invalid height or block window" + } + } + } + }, + "/v1/predicted-date-height/{timestamp}": { + "get": { + "summary": "Get the estimated height of a future date and time.", + "tags": [ + "Blocks" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "number", + "description": "Unix Timestamp", + "example": 1704392968 + }, + "required": false, + "name": "timestamp", + "in": "path" + }, + { + "schema": { + "type": "number", + "default": 10000, + "description": "Block window", + "example": 10000 + }, + "required": false, + "name": "blockWindow", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Returns predicted block height", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "predictedHeight": { + "type": "number", + "example": 10000000 + }, + "date": { + "type": "string", + "example": "2024-01-04T18:29:28.000Z" + }, + "blockWindow": { + "type": "number", + "example": 10000 + } + }, + "required": [ + "predictedHeight", + "date", + "blockWindow" + ] + } + } + } + }, + "400": { + "description": "Invalid timestamp or block window" + } + } + } + }, + "/v1/transactions": { + "get": { + "summary": "Get a list of transactions.", + "tags": [ + "Transactions" + ], + "security": [], + "parameters": [ + { + "schema": { + "type": "number", + "description": "Number of transactions to return", "minimum": 1, "maximum": 100, "example": 20, @@ -14170,7 +15653,7 @@ ], "responses": { "200": { - "description": "Returns block list", + "description": "Returns transaction list", "content": { "application/json": { "schema": { @@ -14181,46 +15664,65 @@ "height": { "type": "number" }, - "proposer": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "operatorAddress": { - "type": "string" - }, - "moniker": { - "type": "string" - }, - "avatarUrl": { - "type": "string", - "nullable": true - } - }, - "required": [ - "address", - "operatorAddress", - "moniker", - "avatarUrl" - ] + "datetime": { + "type": "string" }, - "transactionCount": { + "hash": { + "type": "string" + }, + "isSuccess": { + "type": "boolean" + }, + "error": { + "type": "string", + "nullable": true + }, + "gasUsed": { "type": "number" }, - "totalTransactionCount": { + "gasWanted": { "type": "number" }, - "datetime": { + "fee": { + "type": "number" + }, + "memo": { "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "amount": { + "type": "number" + } + }, + "required": [ + "id", + "type", + "amount" + ] + } } }, "required": [ "height", - "proposer", - "transactionCount", - "totalTransactionCount", - "datetime" + "datetime", + "hash", + "isSuccess", + "error", + "gasUsed", + "gasWanted", + "fee", + "memo", + "messages" ] } } @@ -14230,23 +15732,23 @@ } } }, - "/v1/blocks/{height}": { + "/v1/transactions/{hash}": { "get": { - "summary": "Get a block by height.", + "summary": "Get a transaction by hash.", "tags": [ - "Blocks" + "Transactions" ], "security": [], "parameters": [ { "schema": { - "type": "number", - "nullable": true, - "description": "Block Height", - "example": 12121212 + "type": "string", + "minLength": 1, + "description": "Transaction hash", + "example": "A19F1950D97E576F0D7B591D71A8D0366AA8BA0A7F3DA76F44769188644BE9EB" }, - "required": false, - "name": "height", + "required": true, + "name": "hash", "in": "path" } ], @@ -14264,87 +15766,63 @@ "datetime": { "type": "string" }, - "proposer": { - "type": "object", - "properties": { - "operatorAddress": { - "type": "string" - }, - "moniker": { - "type": "string" - }, - "avatarUrl": { - "type": "string" - }, - "address": { - "type": "string" - } - }, - "required": [ - "operatorAddress", - "moniker", - "address" - ] - }, "hash": { "type": "string" }, + "isSuccess": { + "type": "boolean" + }, + "multisigThreshold": { + "type": "number" + }, + "signers": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": { + "type": "string", + "nullable": true + }, "gasUsed": { "type": "number" }, "gasWanted": { "type": "number" }, - "transactions": { + "fee": { + "type": "number" + }, + "memo": { + "type": "string" + }, + "messages": { "type": "array", "items": { "type": "object", "properties": { - "hash": { + "id": { "type": "string" }, - "isSuccess": { - "type": "boolean" - }, - "error": { - "type": "string", - "nullable": true - }, - "fee": { - "type": "number" - }, - "datetime": { + "type": { "type": "string" }, - "messages": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string" - }, - "amount": { - "type": "number" - } - }, - "required": [ - "id", - "type", - "amount" - ] + "data": { + "type": "object", + "additionalProperties": { + "type": "string" } + }, + "relatedDeploymentId": { + "type": "string", + "nullable": true } }, "required": [ - "hash", - "isSuccess", - "fee", - "datetime", - "messages" + "id", + "type", + "data" ] } } @@ -14352,450 +15830,581 @@ "required": [ "height", "datetime", - "proposer", "hash", + "isSuccess", + "signers", + "error", "gasUsed", "gasWanted", - "transactions" + "fee", + "memo", + "messages" ] } } } }, - "400": { - "description": "Invalid height" - }, "404": { - "description": "Block not found" + "description": "Transaction not found" } } } }, - "/v1/predicted-block-date/{height}": { + "/v1/market-data/{coin?}": { "get": { - "summary": "Get the estimated date of a future block.", "tags": [ - "Blocks" + "Analytics" ], "security": [], "parameters": [ { "schema": { - "type": "number", - "description": "Block height", - "example": 20000000 + "type": "string", + "enum": [ + "akash-network", + "akt" + ], + "default": "akt", + "example": "akt" }, "required": false, - "name": "height", + "name": "coin", "in": "path" - }, - { - "schema": { - "type": "number", - "default": 10000, - "description": "Block window", - "example": 10000 - }, - "required": false, - "name": "blockWindow", - "in": "query" } ], "responses": { "200": { - "description": "Returns predicted block date", + "description": "Returns market stats", "content": { "application/json": { "schema": { "type": "object", "properties": { - "predictedDate": { - "type": "string" + "price": { + "type": "number" }, - "height": { - "type": "number", - "example": 10000000 + "volume": { + "type": "number" + }, + "marketCap": { + "type": "number" + }, + "marketCapRank": { + "type": "number" + }, + "priceChange24h": { + "type": "number" + }, + "priceChangePercentage24": { + "type": "number" + } + }, + "required": [ + "price", + "volume", + "marketCap", + "marketCapRank", + "priceChange24h", + "priceChangePercentage24" + ] + } + } + } + } + } + } + }, + "/v1/validators": { + "get": { + "tags": [ + "Validators" + ], + "security": [], + "responses": { + "200": { + "description": "Returns validators", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "operatorAddress": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "votingPower": { + "type": "number" + }, + "commission": { + "type": "number" + }, + "identity": { + "type": "string" + }, + "votingPowerRatio": { + "type": "number" + }, + "rank": { + "type": "number" + }, + "keybaseAvatarUrl": { + "type": "string", + "nullable": true + } }, - "blockWindow": { - "type": "number", - "example": 10000 - } - }, - "required": [ - "predictedDate", - "height", - "blockWindow" - ] + "required": [ + "operatorAddress", + "moniker", + "votingPower", + "commission", + "identity", + "votingPowerRatio", + "rank", + "keybaseAvatarUrl" + ] + } } } } - }, - "400": { - "description": "Invalid height or block window" } } } }, - "/v1/predicted-date-height/{timestamp}": { + "/v1/validators/{address}": { "get": { - "summary": "Get the estimated height of a future date and time.", "tags": [ - "Blocks" + "Validators" ], "security": [], "parameters": [ { "schema": { - "type": "number", - "description": "Unix Timestamp", - "example": 1704392968 + "type": "string", + "description": "Validator Address", + "example": "akashvaloper14mt78hz73d9tdwpdvkd59ne9509kxw8yj7qy8f" }, - "required": false, - "name": "timestamp", + "required": true, + "name": "address", "in": "path" - }, - { - "schema": { - "type": "number", - "default": 10000, - "description": "Block window", - "example": 10000 - }, - "required": false, - "name": "blockWindow", - "in": "query" } ], "responses": { "200": { - "description": "Returns predicted block height", + "description": "Return a validator information", "content": { "application/json": { "schema": { "type": "object", "properties": { - "predictedHeight": { - "type": "number", - "example": 10000000 + "operatorAddress": { + "type": "string" }, - "date": { + "address": { "type": "string", - "example": "2024-01-04T18:29:28.000Z" + "nullable": true }, - "blockWindow": { - "type": "number", - "example": 10000 + "moniker": { + "type": "string" + }, + "keybaseUsername": { + "type": "string", + "nullable": true + }, + "keybaseAvatarUrl": { + "type": "string", + "nullable": true + }, + "votingPower": { + "type": "number" + }, + "commission": { + "type": "number" + }, + "maxCommission": { + "type": "number" + }, + "maxCommissionChange": { + "type": "number" + }, + "identity": { + "type": "string" + }, + "description": { + "type": "string" + }, + "website": { + "type": "string" + }, + "rank": { + "type": "number" } }, "required": [ - "predictedHeight", - "date", - "blockWindow" + "operatorAddress", + "address", + "moniker", + "keybaseUsername", + "keybaseAvatarUrl", + "votingPower", + "commission", + "maxCommission", + "maxCommissionChange", + "identity", + "description", + "website", + "rank" ] } } } }, "400": { - "description": "Invalid timestamp or block window" + "description": "Invalid address" + }, + "404": { + "description": "Validator not found" } } } }, - "/v1/transactions": { - "get": { - "summary": "Get a list of transactions.", + "/v1/pricing": { + "post": { "tags": [ - "Transactions" + "Other" ], "security": [], - "parameters": [ - { - "schema": { - "type": "number", - "description": "Number of transactions to return", - "minimum": 1, - "maximum": 100, - "example": 20, - "default": 20 - }, - "required": false, - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "Returns transaction list", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { + "summary": "Estimate the price of a deployment on akash and other cloud providers.", + "requestBody": { + "description": "Deployment specs to use for the price estimation. **An array of specs can also be sent, in that case an array of estimations will be returned in the same order.**", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { "type": "object", "properties": { - "height": { - "type": "number" - }, - "datetime": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "isSuccess": { - "type": "boolean" - }, - "error": { - "type": "string", - "nullable": true - }, - "gasUsed": { - "type": "number" - }, - "gasWanted": { - "type": "number" - }, - "fee": { - "type": "number" + "cpu": { + "type": "number", + "minimum": 0, + "description": "CPU in thousandths of a core. 1000 = 1 core", + "example": 1000 }, - "memo": { - "type": "string" + "memory": { + "type": "number", + "description": "Memory in bytes", + "example": 1000000000 }, - "messages": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string" - }, - "amount": { - "type": "number" - } - }, - "required": [ - "id", - "type", - "amount" - ] + "storage": { + "type": "number", + "description": "Storage in bytes", + "example": 1000000000 + } + }, + "required": [ + "cpu", + "memory", + "storage" + ] + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "cpu": { + "type": "number", + "minimum": 0, + "description": "CPU in thousandths of a core. 1000 = 1 core", + "example": 1000 + }, + "memory": { + "type": "number", + "description": "Memory in bytes", + "example": 1000000000 + }, + "storage": { + "type": "number", + "description": "Storage in bytes", + "example": 1000000000 } - } + }, + "required": [ + "cpu", + "memory", + "storage" + ] }, - "required": [ - "height", - "datetime", - "hash", - "isSuccess", - "error", - "gasUsed", - "gasWanted", - "fee", - "memo", - "messages" - ] + "minItems": 1, + "maxItems": 10 } - } + ] } } } - } - } - }, - "/v1/transactions/{hash}": { - "get": { - "summary": "Get a transaction by hash.", - "tags": [ - "Transactions" - ], - "security": [], - "parameters": [ - { - "schema": { - "type": "string", - "minLength": 1, - "description": "Transaction hash", - "example": "A19F1950D97E576F0D7B591D71A8D0366AA8BA0A7F3DA76F44769188644BE9EB" - }, - "required": true, - "name": "hash", - "in": "path" - } - ], + }, "responses": { "200": { - "description": "Returns predicted block date", + "description": "Returns a list of deployment templates grouped by categories", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "height": { - "type": "number" - }, - "datetime": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "isSuccess": { - "type": "boolean" - }, - "multisigThreshold": { - "type": "number" - }, - "signers": { - "type": "array", - "items": { - "type": "string" - } - }, - "error": { - "type": "string", - "nullable": true - }, - "gasUsed": { - "type": "number" - }, - "gasWanted": { - "type": "number" - }, - "fee": { - "type": "number" - }, - "memo": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "cpu": { + "type": "number", + "minimum": 0, + "description": "CPU in thousandths of a core. 1000 = 1 core", + "example": 1000 + }, + "memory": { + "type": "number", + "description": "Memory in bytes", + "example": 1000000000 + }, + "storage": { + "type": "number", + "description": "Storage in bytes", + "example": 1000000000 + } + }, + "required": [ + "cpu", + "memory", + "storage" + ] + }, + "akash": { + "type": "number", + "description": "Akash price estimation (USD/month)" + }, + "aws": { + "type": "number", + "description": "AWS price estimation (USD/month)" + }, + "gcp": { + "type": "number", + "description": "GCP price estimation (USD/month)" + }, + "azure": { + "type": "number", + "description": "Azure price estimation (USD/month)" + } + }, + "required": [ + "spec", + "akash", + "aws", + "gcp", + "azure" + ] }, - "messages": { + { "type": "array", "items": { "type": "object", "properties": { - "id": { - "type": "string" + "spec": { + "type": "object", + "properties": { + "cpu": { + "type": "number", + "minimum": 0, + "description": "CPU in thousandths of a core. 1000 = 1 core", + "example": 1000 + }, + "memory": { + "type": "number", + "description": "Memory in bytes", + "example": 1000000000 + }, + "storage": { + "type": "number", + "description": "Storage in bytes", + "example": 1000000000 + } + }, + "required": [ + "cpu", + "memory", + "storage" + ] }, - "type": { - "type": "string" + "akash": { + "type": "number", + "description": "Akash price estimation (USD/month)" }, - "data": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "aws": { + "type": "number", + "description": "AWS price estimation (USD/month)" }, - "relatedDeploymentId": { - "type": "string", - "nullable": true + "gcp": { + "type": "number", + "description": "GCP price estimation (USD/month)" + }, + "azure": { + "type": "number", + "description": "Azure price estimation (USD/month)" } }, "required": [ - "id", - "type", - "data" + "spec", + "akash", + "aws", + "gcp", + "azure" ] } } - }, - "required": [ - "height", - "datetime", - "hash", - "isSuccess", - "signers", - "error", - "gasUsed", - "gasWanted", - "fee", - "memo", - "messages" ] } } } }, - "404": { - "description": "Transaction not found" + "400": { + "description": "Invalid parameters" } } } }, - "/v1/market-data/{coin?}": { + "/v1/gpu": { "get": { + "summary": "Get a list of gpu models and their availability.", "tags": [ - "Analytics" + "Gpu" ], "security": [], "parameters": [ { "schema": { - "type": "string", - "enum": [ - "akash-network", - "akt" - ], - "default": "akt", - "example": "akt" + "type": "string" + }, + "required": false, + "name": "provider", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "vendor", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "model", + "in": "query" + }, + { + "schema": { + "type": "string" }, "required": false, - "name": "coin", - "in": "path" + "name": "memory_size", + "in": "query" } ], "responses": { "200": { - "description": "Returns market stats", + "description": "List of gpu models and their availability.", "content": { "application/json": { "schema": { "type": "object", "properties": { - "price": { - "type": "number" - }, - "volume": { - "type": "number" - }, - "marketCap": { - "type": "number" - }, - "marketCapRank": { - "type": "number" - }, - "priceChange24h": { - "type": "number" - }, - "priceChangePercentage24": { - "type": "number" + "gpus": { + "type": "object", + "properties": { + "total": { + "type": "object", + "properties": { + "allocatable": { + "type": "number" + }, + "allocated": { + "type": "number" + } + }, + "required": [ + "allocatable", + "allocated" + ] + }, + "details": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "ram": { + "type": "string" + }, + "interface": { + "type": "string" + }, + "allocatable": { + "type": "number" + }, + "allocated": { + "type": "number" + } + }, + "required": [ + "model", + "ram", + "interface", + "allocatable", + "allocated" + ] + } + } + } + }, + "required": [ + "total", + "details" + ] } }, "required": [ - "price", - "volume", - "marketCap", - "marketCapRank", - "priceChange24h", - "priceChangePercentage24" + "gpus" ] } } } + }, + "400": { + "description": "Invalid provider parameter, should be a valid akash address or host uri" } } } }, - "/v1/validators": { + "/v1/gpu-models": { "get": { + "summary": "Get a list of gpu models per vendor. Based on the content from https://raw.githubusercontent.com/akash-network/provider-configs/main/devices/pcie/gpus.json.", "tags": [ - "Validators" + "Gpu" ], "security": [], "responses": { "200": { - "description": "Returns validators", + "description": "List of gpu models per.", "content": { "application/json": { "schema": { @@ -14803,535 +16412,519 @@ "items": { "type": "object", "properties": { - "operatorAddress": { - "type": "string" - }, - "moniker": { - "type": "string" - }, - "votingPower": { - "type": "number" - }, - "commission": { - "type": "number" - }, - "identity": { + "name": { "type": "string" }, - "votingPowerRatio": { - "type": "number" - }, - "rank": { - "type": "number" - }, - "keybaseAvatarUrl": { + "displayName": { "type": "string", - "nullable": true + "example": "NVIDIA" + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "displayName": { + "type": "string", + "example": "RTX 4090" + }, + "memory": { + "type": "array", + "items": { + "type": "string" + } + }, + "interface": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "displayName", + "memory", + "interface" + ] + } } }, "required": [ - "operatorAddress", - "moniker", - "votingPower", - "commission", - "identity", - "votingPowerRatio", - "rank", - "keybaseAvatarUrl" + "name", + "displayName", + "models" ] - } - } - } - } - } - } - } - }, - "/v1/validators/{address}": { - "get": { - "tags": [ - "Validators" - ], - "security": [], - "parameters": [ - { - "schema": { - "type": "string", - "description": "Validator Address", - "example": "akashvaloper14mt78hz73d9tdwpdvkd59ne9509kxw8yj7qy8f" - }, - "required": true, - "name": "address", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Return a validator information", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "operatorAddress": { - "type": "string" - }, - "address": { - "type": "string", - "nullable": true - }, - "moniker": { - "type": "string" - }, - "keybaseUsername": { - "type": "string", - "nullable": true - }, - "keybaseAvatarUrl": { - "type": "string", - "nullable": true - }, - "votingPower": { - "type": "number" - }, - "commission": { - "type": "number" - }, - "maxCommission": { - "type": "number" - }, - "maxCommissionChange": { - "type": "number" - }, - "identity": { - "type": "string" - }, - "description": { - "type": "string" - }, - "website": { - "type": "string" - }, - "rank": { - "type": "number" - } - }, - "required": [ - "operatorAddress", - "address", - "moniker", - "keybaseUsername", - "keybaseAvatarUrl", - "votingPower", - "commission", - "maxCommission", - "maxCommissionChange", - "identity", - "description", - "website", - "rank" - ] + } } } } - }, - "400": { - "description": "Invalid address" - }, - "404": { - "description": "Validator not found" } } } }, - "/v1/pricing": { - "post": { + "/v1/gpu-breakdown": { + "get": { "tags": [ - "Other" + "Gpu" ], "security": [], - "summary": "Estimate the price of a deployment on akash and other cloud providers.", - "requestBody": { - "description": "Deployment specs to use for the price estimation. **An array of specs can also be sent, in that case an array of estimations will be returned in the same order.**", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { + "summary": "Gets gpu analytics breakdown by vendor and model. If no vendor or model is provided, all GPUs are returned.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": false, + "name": "vendor", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "model", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Gets gpu analytics breakdown by vendor and model. If no vendor or model is provided, all GPUs are returned.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "type": "object", "properties": { - "cpu": { - "type": "number", - "minimum": 0, - "description": "CPU in thousandths of a core. 1000 = 1 core", - "example": 1000 + "date": { + "type": "string" }, - "memory": { - "type": "number", - "description": "Memory in bytes", - "example": 1000000000 + "vendor": { + "type": "string" }, - "storage": { - "type": "number", - "description": "Storage in bytes", - "example": 1000000000 + "model": { + "type": "string" + }, + "providerCount": { + "type": "number" + }, + "nodeCount": { + "type": "number" + }, + "totalGpus": { + "type": "number" + }, + "leasedGpus": { + "type": "number" + }, + "gpuUtilization": { + "type": "number" } }, "required": [ - "cpu", - "memory", - "storage" + "date", + "vendor", + "model", + "providerCount", + "nodeCount", + "totalGpus", + "leasedGpus", + "gpuUtilization" ] - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "cpu": { - "type": "number", - "minimum": 0, - "description": "CPU in thousandths of a core. 1000 = 1 core", - "example": 1000 - }, - "memory": { - "type": "number", - "description": "Memory in bytes", - "example": 1000000000 - }, - "storage": { - "type": "number", - "description": "Storage in bytes", - "example": 1000000000 - } - }, - "required": [ - "cpu", - "memory", - "storage" - ] - }, - "minItems": 1, - "maxItems": 10 } - ] + } } } } - }, + } + } + }, + "/v1/gpu-prices": { + "get": { + "summary": "Get a list of gpu models with their availability and pricing.", + "operationId": "listGpuPrices", + "tags": [ + "Gpu" + ], + "security": [], "responses": { "200": { - "description": "Returns a list of deployment templates grouped by categories", + "description": "List of gpu models with their availability and pricing.", "content": { "application/json": { "schema": { - "anyOf": [ - { + "type": "object", + "properties": { + "availability": { "type": "object", "properties": { - "spec": { - "type": "object", - "properties": { - "cpu": { - "type": "number", - "minimum": 0, - "description": "CPU in thousandths of a core. 1000 = 1 core", - "example": 1000 - }, - "memory": { - "type": "number", - "description": "Memory in bytes", - "example": 1000000000 - }, - "storage": { - "type": "number", - "description": "Storage in bytes", - "example": 1000000000 - } - }, - "required": [ - "cpu", - "memory", - "storage" - ] - }, - "akash": { - "type": "number", - "description": "Akash price estimation (USD/month)" - }, - "aws": { - "type": "number", - "description": "AWS price estimation (USD/month)" - }, - "gcp": { - "type": "number", - "description": "GCP price estimation (USD/month)" + "total": { + "type": "number" }, - "azure": { - "type": "number", - "description": "Azure price estimation (USD/month)" + "available": { + "type": "number" } }, "required": [ - "spec", - "akash", - "aws", - "gcp", - "azure" + "total", + "available" ] }, - { + "models": { "type": "array", "items": { "type": "object", "properties": { - "spec": { + "vendor": { + "type": "string" + }, + "model": { + "type": "string" + }, + "ram": { + "type": "string" + }, + "interface": { + "type": "string" + }, + "availability": { "type": "object", "properties": { - "cpu": { - "type": "number", - "minimum": 0, - "description": "CPU in thousandths of a core. 1000 = 1 core", - "example": 1000 + "total": { + "type": "number" + }, + "available": { + "type": "number" + } + }, + "required": [ + "total", + "available" + ] + }, + "providerAvailability": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "available": { + "type": "number" + } + }, + "required": [ + "total", + "available" + ] + }, + "price": { + "type": "object", + "nullable": true, + "properties": { + "currency": { + "type": "string", + "example": "USD" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" }, - "memory": { - "type": "number", - "description": "Memory in bytes", - "example": 1000000000 + "avg": { + "type": "number" }, - "storage": { - "type": "number", - "description": "Storage in bytes", - "example": 1000000000 + "weightedAverage": { + "type": "number" + }, + "med": { + "type": "number" } }, "required": [ - "cpu", - "memory", - "storage" + "currency", + "min", + "max", + "avg", + "weightedAverage", + "med" ] - }, - "akash": { - "type": "number", - "description": "Akash price estimation (USD/month)" - }, - "aws": { - "type": "number", - "description": "AWS price estimation (USD/month)" - }, - "gcp": { - "type": "number", - "description": "GCP price estimation (USD/month)" - }, - "azure": { - "type": "number", - "description": "Azure price estimation (USD/month)" } }, "required": [ - "spec", - "akash", - "aws", - "gcp", - "azure" + "vendor", + "model", + "ram", + "interface", + "availability", + "providerAvailability", + "price" ] } } + }, + "required": [ + "availability", + "models" ] } } } - }, - "400": { - "description": "Invalid parameters" } } } }, - "/v1/gpu": { + "/v1/proposals": { "get": { - "summary": "Get a list of gpu models and their availability.", "tags": [ - "Gpu" + "Proposals" + ], + "security": [], + "responses": { + "200": { + "description": "Returns a list of proposals", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "submitTime": { + "type": "string" + }, + "votingStartTime": { + "type": "string" + }, + "votingEndTime": { + "type": "string" + }, + "totalDeposit": { + "type": "number" + } + }, + "required": [ + "id", + "title", + "status", + "submitTime", + "votingStartTime", + "votingEndTime", + "totalDeposit" + ] + } + } + } + } + } + } + } + }, + "/v1/proposals/{id}": { + "get": { + "tags": [ + "Proposals" ], "security": [], "parameters": [ { "schema": { - "type": "string" - }, - "required": false, - "name": "provider", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "vendor", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "model", - "in": "query" - }, - { - "schema": { - "type": "string" + "type": "number", + "nullable": true, + "description": "Proposal ID", + "example": 1 }, "required": false, - "name": "memory_size", - "in": "query" + "name": "id", + "in": "path" } ], "responses": { "200": { - "description": "List of gpu models and their availability.", + "description": "Return a proposal by id", "content": { "application/json": { "schema": { "type": "object", "properties": { - "gpus": { + "id": { + "type": "number" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "submitTime": { + "type": "string" + }, + "votingStartTime": { + "type": "string" + }, + "votingEndTime": { + "type": "string" + }, + "totalDeposit": { + "type": "number" + }, + "tally": { "type": "object", "properties": { + "yes": { + "type": "number" + }, + "abstain": { + "type": "number" + }, + "no": { + "type": "number" + }, + "noWithVeto": { + "type": "number" + }, "total": { - "type": "object", - "properties": { - "allocatable": { - "type": "number" - }, - "allocated": { - "type": "number" - } + "type": "number" + } + }, + "required": [ + "yes", + "abstain", + "no", + "noWithVeto", + "total" + ] + }, + "paramChanges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subspace": { + "type": "string" }, - "required": [ - "allocatable", - "allocated" - ] - }, - "details": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "properties": { - "model": { - "type": "string" - }, - "ram": { - "type": "string" - }, - "interface": { - "type": "string" - }, - "allocatable": { - "type": "number" - }, - "allocated": { - "type": "number" - } - }, - "required": [ - "model", - "ram", - "interface", - "allocatable", - "allocated" - ] - } + "key": { + "type": "string" + }, + "value": { + "nullable": true } - } - }, - "required": [ - "total", - "details" - ] + }, + "required": [ + "subspace", + "key" + ] + } } }, "required": [ - "gpus" + "id", + "title", + "description", + "status", + "submitTime", + "votingStartTime", + "votingEndTime", + "totalDeposit", + "tally", + "paramChanges" ] } } } }, "400": { - "description": "Invalid provider parameter, should be a valid akash address or host uri" + "description": "Invalid proposal id" + }, + "404": { + "description": "Proposal not found" } } } }, - "/v1/gpu-models": { + "/v1/templates-list": { "get": { - "summary": "Get a list of gpu models per vendor. Based on the content from https://raw.githubusercontent.com/akash-network/provider-configs/main/devices/pcie/gpus.json.", "tags": [ - "Gpu" + "Other" ], "security": [], "responses": { "200": { - "description": "List of gpu models per.", + "description": "Returns a list of deployment templates grouped by categories", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "displayName": { - "type": "string", - "example": "NVIDIA" - }, - "models": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "displayName": { - "type": "string", - "example": "RTX 4090" - }, - "memory": { - "type": "array", - "items": { - "type": "string" - } - }, - "interface": { - "type": "array", - "items": { - "type": "string" - } - } + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" }, - "required": [ - "name", - "displayName", - "memory", - "interface" - ] - } + "templates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "logoUrl": { + "type": "string", + "nullable": true + }, + "summary": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "logoUrl", + "summary" + ] + } + } + }, + "required": [ + "title", + "templates" + ] } - }, - "required": [ - "name", - "displayName", - "models" - ] - } + } + }, + "required": [ + "data" + ] } } } @@ -15339,422 +16932,443 @@ } } }, - "/v1/gpu-breakdown": { + "/v1/templates/{id}": { "get": { "tags": [ - "Gpu" + "Other" ], "security": [], - "summary": "Gets gpu analytics breakdown by vendor and model. If no vendor or model is provided, all GPUs are returned.", "parameters": [ { "schema": { - "type": "string" - }, - "required": false, - "name": "vendor", - "in": "query" - }, - { - "schema": { - "type": "string" + "type": "string", + "pattern": "^[^/\\\\]+$", + "description": "Template ID", + "example": "akash-network-cosmos-omnibus-agoric" }, - "required": false, - "name": "model", - "in": "query" + "required": true, + "name": "id", + "in": "path" } ], "responses": { "200": { - "description": "Gets gpu analytics breakdown by vendor and model. If no vendor or model is provided, all GPUs are returned.", + "description": "Return a template by id", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "date": { - "type": "string" - }, - "vendor": { - "type": "string" - }, - "model": { - "type": "string" - }, - "providerCount": { - "type": "number" - }, - "nodeCount": { - "type": "number" - }, - "totalGpus": { - "type": "number" - }, - "leasedGpus": { - "type": "number" + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "logoUrl": { + "type": "string", + "nullable": true + }, + "summary": { + "type": "string" + }, + "readme": { + "type": "string" + }, + "deploy": { + "type": "string" + }, + "persistentStorageEnabled": { + "type": "boolean" + }, + "guide": { + "type": "string" + }, + "githubUrl": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "ssh": { + "type": "boolean" + } + } + } }, - "gpuUtilization": { - "type": "number" - } - }, - "required": [ - "date", - "vendor", - "model", - "providerCount", - "nodeCount", - "totalGpus", - "leasedGpus", - "gpuUtilization" - ] - } + "required": [ + "id", + "name", + "path", + "logoUrl", + "summary", + "readme", + "deploy", + "persistentStorageEnabled", + "githubUrl", + "config" + ] + } + }, + "required": [ + "data" + ] } } } + }, + "400": { + "description": "Invalid template ID" + }, + "404": { + "description": "Template not found" } } } }, - "/v1/gpu-prices": { + "/v1/leases-duration/{owner}": { "get": { - "summary": "Get a list of gpu models with their availability and pricing.", - "operationId": "listGpuPrices", + "summary": "Get leases durations.", "tags": [ - "Gpu" + "Analytics" ], "security": [], + "parameters": [ + { + "schema": { + "type": "string", + "example": "akash13265twfqejnma6cc93rw5dxk4cldyz2zyy8cdm" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^d+$" + }, + "required": false, + "name": "dseq", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "YYYY-MM-DD", + "default": "2000-01-01" + }, + "required": false, + "name": "startDate", + "in": "query" + }, + { + "schema": { + "type": "string", + "format": "YYYY-MM-DD", + "default": "2100-01-01" + }, + "required": false, + "name": "endDate", + "in": "query" + } + ], "responses": { "200": { - "description": "List of gpu models with their availability and pricing.", + "description": "List of leases durations and total duration.", "content": { "application/json": { "schema": { "type": "object", "properties": { - "availability": { - "type": "object", - "properties": { - "total": { - "type": "number" - }, - "available": { - "type": "number" - } - }, - "required": [ - "total", - "available" - ] + "leaseCount": { + "type": "number" }, - "models": { + "totalDurationInSeconds": { + "type": "number" + }, + "totalDurationInHours": { + "type": "number" + }, + "leases": { "type": "array", "items": { "type": "object", "properties": { - "vendor": { - "type": "string" + "dseq": { + "type": "string", + "pattern": "^d+$" }, - "model": { + "oseq": { + "type": "number" + }, + "gseq": { + "type": "number" + }, + "provider": { "type": "string" }, - "ram": { + "startHeight": { + "type": "number" + }, + "startDate": { "type": "string" }, - "interface": { + "closedHeight": { + "type": "number" + }, + "closedDate": { "type": "string" }, - "availability": { - "type": "object", - "properties": { - "total": { - "type": "number" - }, - "available": { - "type": "number" - } - }, - "required": [ - "total", - "available" - ] + "durationInBlocks": { + "type": "number" }, - "providerAvailability": { - "type": "object", - "properties": { - "total": { - "type": "number" - }, - "available": { - "type": "number" - } - }, - "required": [ - "total", - "available" - ] + "durationInSeconds": { + "type": "number" }, - "price": { - "type": "object", - "nullable": true, - "properties": { - "currency": { - "type": "string", - "example": "USD" - }, - "min": { - "type": "number" - }, - "max": { - "type": "number" - }, - "avg": { - "type": "number" - }, - "weightedAverage": { - "type": "number" - }, - "med": { - "type": "number" - } - }, - "required": [ - "currency", - "min", - "max", - "avg", - "weightedAverage", - "med" - ] + "durationInHours": { + "type": "number" } }, "required": [ - "vendor", - "model", - "ram", - "interface", - "availability", - "providerAvailability", - "price" + "dseq", + "oseq", + "gseq", + "provider", + "startHeight", + "startDate", + "closedHeight", + "closedDate", + "durationInBlocks", + "durationInSeconds", + "durationInHours" ] } } }, "required": [ - "availability", - "models" - ] - } - } - } - } - } - } - }, - "/v1/proposals": { - "get": { - "tags": [ - "Proposals" - ], - "security": [], - "responses": { - "200": { - "description": "Returns a list of proposals", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "title": { - "type": "string" - }, - "status": { - "type": "string" - }, - "submitTime": { - "type": "string" - }, - "votingStartTime": { - "type": "string" - }, - "votingEndTime": { - "type": "string" - }, - "totalDeposit": { - "type": "number" - } - }, - "required": [ - "id", - "title", - "status", - "submitTime", - "votingStartTime", - "votingEndTime", - "totalDeposit" - ] - } + "leaseCount", + "totalDurationInSeconds", + "totalDurationInHours", + "leases" + ] } } } + }, + "400": { + "description": "Invalid start date, must be in the following format: YYYY-MM-DD" } } } }, - "/v1/proposals/{id}": { + "/v1/addresses/{address}": { "get": { + "summary": "Get address details", "tags": [ - "Proposals" + "Addresses" ], "security": [], "parameters": [ { "schema": { - "type": "number", - "nullable": true, - "description": "Proposal ID", - "example": 1 + "type": "string", + "description": "Account Address", + "example": "akash13265twfqejnma6cc93rw5dxk4cldyz2zyy8cdm" }, - "required": false, - "name": "id", + "required": true, + "name": "address", "in": "path" } ], "responses": { "200": { - "description": "Return a proposal by id", + "description": "Returns address details", "content": { "application/json": { "schema": { "type": "object", "properties": { - "id": { + "total": { "type": "number" }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "status": { - "type": "string" - }, - "submitTime": { - "type": "string" + "delegations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "validator": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "operatorAddress": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + } + } + }, + "amount": { + "type": "number" + }, + "reward": { + "type": "number", + "nullable": true + } + }, + "required": [ + "validator", + "amount", + "reward" + ] + } }, - "votingStartTime": { - "type": "string" + "available": { + "type": "number" }, - "votingEndTime": { - "type": "string" + "delegated": { + "type": "number" }, - "totalDeposit": { + "rewards": { "type": "number" }, - "tally": { - "type": "object", - "properties": { - "yes": { - "type": "number" - }, - "abstain": { - "type": "number" - }, - "no": { - "type": "number" - }, - "noWithVeto": { - "type": "number" + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "symbol": { + "type": "string" + }, + "ibcToken": { + "type": "string" + }, + "logoUrl": { + "type": "string" + }, + "description": { + "type": "string" + }, + "amount": { + "type": "number" + } }, - "total": { - "type": "number" - } - }, - "required": [ - "yes", - "abstain", - "no", - "noWithVeto", - "total" - ] + "required": [ + "amount" + ] + } }, - "paramChanges": { + "redelegations": { "type": "array", "items": { "type": "object", "properties": { - "subspace": { - "type": "string" + "srcAddress": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "operatorAddress": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + } + } }, - "key": { + "dstAddress": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "moniker": { + "type": "string" + }, + "operatorAddress": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + } + } + }, + "creationHeight": { + "type": "number" + }, + "completionTime": { "type": "string" }, - "value": { - "nullable": true + "amount": { + "type": "number" } }, "required": [ - "subspace", - "key" + "srcAddress", + "dstAddress", + "creationHeight", + "completionTime", + "amount" ] } - } - }, - "required": [ - "id", - "title", - "description", - "status", - "submitTime", - "votingStartTime", - "votingEndTime", - "totalDeposit", - "tally", - "paramChanges" - ] - } - } - } - }, - "400": { - "description": "Invalid proposal id" - }, - "404": { - "description": "Proposal not found" - } - } - } - }, - "/v1/templates-list": { - "get": { - "tags": [ - "Other" - ], - "security": [], - "responses": { - "200": { - "description": "Returns a list of deployment templates grouped by categories", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { + }, + "commission": { + "type": "number" + }, + "latestTransactions": { "type": "array", "items": { "type": "object", "properties": { - "title": { + "height": { + "type": "number" + }, + "datetime": { + "type": "string" + }, + "hash": { "type": "string" }, - "templates": { + "isSuccess": { + "type": "boolean" + }, + "error": { + "type": "string", + "nullable": true + }, + "gasUsed": { + "type": "number" + }, + "gasWanted": { + "type": "number" + }, + "fee": { + "type": "number" + }, + "memo": { + "type": "string", + "nullable": true + }, + "isSigner": { + "type": "boolean" + }, + "messages": { "type": "array", "items": { "type": "object", @@ -15762,1148 +17376,1406 @@ "id": { "type": "string" }, - "name": { + "type": { "type": "string" }, - "logoUrl": { - "type": "string", - "nullable": true - }, - "summary": { - "type": "string" + "amount": { + "type": "number" }, - "tags": { - "type": "array", - "items": { - "type": "string" - } + "isReceiver": { + "type": "boolean" } }, "required": [ "id", - "name", - "logoUrl", - "summary" + "type", + "amount", + "isReceiver" ] } } }, "required": [ - "title", - "templates" + "height", + "datetime", + "hash", + "isSuccess", + "error", + "gasUsed", + "gasWanted", + "fee", + "memo", + "isSigner", + "messages" ] } } }, "required": [ - "data" - ] - } - } - } - } - } - } - }, - "/v1/templates/{id}": { - "get": { - "tags": [ - "Other" - ], - "security": [], - "parameters": [ - { - "schema": { - "type": "string", - "pattern": "^[^/\\\\]+$", - "description": "Template ID", - "example": "akash-network-cosmos-omnibus-agoric" - }, - "required": true, - "name": "id", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Return a template by id", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "logoUrl": { - "type": "string", - "nullable": true - }, - "summary": { - "type": "string" - }, - "readme": { - "type": "string" - }, - "deploy": { - "type": "string" - }, - "persistentStorageEnabled": { - "type": "boolean" - }, - "guide": { - "type": "string" - }, - "githubUrl": { - "type": "string" - }, - "config": { - "type": "object", - "properties": { - "ssh": { - "type": "boolean" - } - } - } - }, - "required": [ - "id", - "name", - "path", - "logoUrl", - "summary", - "readme", - "deploy", - "persistentStorageEnabled", - "githubUrl", - "config" - ] - } - }, - "required": [ - "data" + "total", + "delegations", + "available", + "delegated", + "rewards", + "assets", + "redelegations", + "commission", + "latestTransactions" ] } } } }, "400": { - "description": "Invalid template ID" - }, - "404": { - "description": "Template not found" + "description": "Invalid address" } } } }, - "/v1/leases-duration/{owner}": { + "/v1/addresses/{address}/transactions/{skip}/{limit}": { "get": { - "summary": "Get leases durations.", + "summary": "Get a list of transactions for a given address.", "tags": [ - "Analytics" + "Addresses", + "Transactions" ], "security": [], "parameters": [ { "schema": { "type": "string", + "description": "Wallet Address", "example": "akash13265twfqejnma6cc93rw5dxk4cldyz2zyy8cdm" }, "required": true, - "name": "owner", + "name": "address", "in": "path" }, { "schema": { - "type": "string", - "pattern": "^d+$" - }, - "required": false, - "name": "dseq", - "in": "query" - }, - { - "schema": { - "type": "string", - "format": "YYYY-MM-DD", - "default": "2000-01-01" + "type": "number", + "nullable": true, + "minimum": 0, + "description": "Transactions to skip", + "example": 10 }, "required": false, - "name": "startDate", - "in": "query" + "name": "skip", + "in": "path" }, { "schema": { - "type": "string", - "format": "YYYY-MM-DD", - "default": "2100-01-01" + "type": "number", + "minimum": 1, + "maximum": 100, + "description": "Transactions to return", + "example": 10 }, - "required": false, - "name": "endDate", - "in": "query" + "required": true, + "name": "limit", + "in": "path" } ], "responses": { "200": { - "description": "List of leases durations and total duration.", + "description": "Returns transaction list", "content": { "application/json": { "schema": { "type": "object", "properties": { - "leaseCount": { - "type": "number" - }, - "totalDurationInSeconds": { - "type": "number" - }, - "totalDurationInHours": { + "count": { "type": "number" }, - "leases": { + "results": { "type": "array", "items": { "type": "object", "properties": { - "dseq": { - "type": "string", - "pattern": "^d+$" - }, - "oseq": { - "type": "number" - }, - "gseq": { + "height": { "type": "number" }, - "provider": { + "datetime": { "type": "string" }, - "startHeight": { - "type": "number" - }, - "startDate": { + "hash": { "type": "string" }, - "closedHeight": { - "type": "number" + "isSuccess": { + "type": "boolean" }, - "closedDate": { - "type": "string" + "error": { + "type": "string", + "nullable": true }, - "durationInBlocks": { + "gasUsed": { "type": "number" }, - "durationInSeconds": { + "gasWanted": { "type": "number" }, - "durationInHours": { + "fee": { "type": "number" + }, + "memo": { + "type": "string", + "nullable": true + }, + "isSigner": { + "type": "boolean" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "isReceiver": { + "type": "boolean" + } + }, + "required": [ + "id", + "type", + "amount", + "isReceiver" + ] + } } }, "required": [ - "dseq", - "oseq", - "gseq", - "provider", - "startHeight", - "startDate", - "closedHeight", - "closedDate", - "durationInBlocks", - "durationInSeconds", - "durationInHours" + "height", + "datetime", + "hash", + "isSuccess", + "error", + "gasUsed", + "gasWanted", + "fee", + "memo", + "isSigner", + "messages" ] } } }, "required": [ - "leaseCount", - "totalDurationInSeconds", - "totalDurationInHours", - "leases" + "count", + "results" ] } } } }, "400": { - "description": "Invalid start date, must be in the following format: YYYY-MM-DD" + "description": "Invalid address or parameters" } } } }, - "/v1/addresses/{address}": { + "/v1/blockchain-status": { "get": { - "summary": "Get address details", + "summary": "Get blockchain reachability status", "tags": [ - "Addresses" + "Chain" ], "security": [], - "parameters": [ - { - "schema": { - "type": "string", - "description": "Account Address", - "example": "akash13265twfqejnma6cc93rw5dxk4cldyz2zyy8cdm" - }, - "required": true, - "name": "address", - "in": "path" - } - ], "responses": { "200": { - "description": "Returns address details", + "description": "Returns blockchain reachability status", "content": { "application/json": { "schema": { "type": "object", "properties": { - "total": { - "type": "number" - }, - "delegations": { - "type": "array", - "items": { + "isBlockchainReachable": { + "type": "boolean" + } + }, + "required": [ + "isBlockchainReachable" + ] + } + } + } + } + } + } + }, + "/v1/bid-screening": { + "post": { + "operationId": "screenProviders", + "summary": "Screen providers by deployment resource requirements", + "tags": [ + "Bid Screening" + ], + "security": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "requirements": { + "type": "object", + "properties": { + "signedBy": { "type": "object", "properties": { - "validator": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "moniker": { - "type": "string" - }, - "operatorAddress": { - "type": "string" - }, - "avatarUrl": { - "type": "string" - } - } - }, - "amount": { - "type": "number" + "allOf": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] }, - "reward": { - "type": "number", - "nullable": true + "anyOf": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] } }, - "required": [ - "validator", - "amount", - "reward" - ] - } - }, - "available": { - "type": "number" - }, - "delegated": { - "type": "number" - }, - "rewards": { - "type": "number" - }, - "assets": { - "type": "array", - "items": { - "type": "object", - "properties": { - "symbol": { - "type": "string" - }, - "ibcToken": { - "type": "string" - }, - "logoUrl": { - "type": "string" - }, - "description": { - "type": "string" + "default": {} + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", + "description": "Attribute key", + "example": "persistent" + }, + "value": { + "type": "string", + "description": "Attribute value", + "example": "false" + } }, - "amount": { - "type": "number" - } + "required": [ + "key", + "value" + ] }, - "required": [ - "amount" - ] - } - }, - "redelegations": { - "type": "array", - "items": { + "default": [] + }, + "verification": { "type": "object", "properties": { - "srcAddress": { - "type": "object", - "properties": { - "address": { - "type": "string" + "minTier": { + "anyOf": [ + { + "type": "number", + "enum": [ + 0 + ] }, - "moniker": { - "type": "string" + { + "type": "number", + "enum": [ + 1 + ] }, - "operatorAddress": { - "type": "string" + { + "type": "number", + "enum": [ + 2 + ] }, - "avatarUrl": { - "type": "string" + { + "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": [] }, - "dstAddress": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "moniker": { - "type": "string" + "requiredAuditors": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "default": [] + }, + "auditorMode": { + "anyOf": [ + { + "type": "number", + "enum": [ + 0 + ] }, - "operatorAddress": { - "type": "string" + { + "type": "number", + "enum": [ + 1 + ] }, - "avatarUrl": { - "type": "string" + { + "type": "number", + "enum": [ + 2 + ] } - } - }, - "creationHeight": { - "type": "number" + ], + "default": 0 }, - "completionTime": { - "type": "string" - }, - "amount": { - "type": "number" + "minAuditorCount": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "default": 0 } }, "required": [ - "srcAddress", - "dstAddress", - "creationHeight", - "completionTime", - "amount" + "minTier" ] } }, - "commission": { - "type": "number" - }, - "latestTransactions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "height": { - "type": "number" - }, - "datetime": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "isSuccess": { - "type": "boolean" - }, - "error": { - "type": "string", - "nullable": true - }, - "gasUsed": { - "type": "number" - }, - "gasWanted": { - "type": "number" - }, - "fee": { - "type": "number" - }, - "memo": { - "type": "string", - "nullable": true - }, - "isSigner": { - "type": "boolean" - }, - "messages": { - "type": "array", - "items": { + "default": {} + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "resource": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Resource unit ID", + "example": 1 + }, + "cpu": { "type": "object", "properties": { - "id": { - "type": "string" + "units": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 80 + } + }, + "required": [ + "val" + ] }, - "type": { - "type": "string" + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", + "description": "Attribute key", + "example": "persistent" + }, + "value": { + "type": "string", + "description": "Attribute value", + "example": "false" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units" + ] + }, + "memory": { + "type": "object", + "properties": { + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 80 + } + }, + "required": [ + "val" + ] }, - "amount": { - "type": "number" + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", + "description": "Attribute key", + "example": "persistent" + }, + "value": { + "type": "string", + "description": "Attribute value", + "example": "false" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "quantity" + ] + }, + "gpu": { + "type": "object", + "properties": { + "units": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 80 + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", + "description": "Attribute key", + "example": "persistent" + }, + "value": { + "type": "string", + "description": "Attribute value", + "example": "false" + } + }, + "required": [ + "key", + "value" + ] + } + } + }, + "required": [ + "units" + ] + }, + "storage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Storage volume name", + "example": "default" + }, + "quantity": { + "type": "object", + "properties": { + "val": { + "type": "string", + "maxLength": 80 + } + }, + "required": [ + "val" + ] + }, + "attributes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", + "description": "Attribute key", + "example": "persistent" + }, + "value": { + "type": "string", + "description": "Attribute value", + "example": "false" + } + }, + "required": [ + "key", + "value" + ] + } + } }, - "isReceiver": { - "type": "boolean" + "required": [ + "name", + "quantity" + ] + } + }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "SHARED_HTTP", + "RANDOM_PORT", + "LEASED_IP", + "UNRECOGNIZED" + ] + }, + "sequenceNumber": { + "type": "integer", + "nullable": true, + "minimum": 0 + } } - }, - "required": [ - "id", - "type", - "amount", - "isReceiver" - ] + } } - } + }, + "required": [ + "id", + "cpu", + "memory", + "gpu", + "storage" + ] }, - "required": [ - "height", - "datetime", - "hash", - "isSuccess", - "error", - "gasUsed", - "gasWanted", - "fee", - "memo", - "isSigner", - "messages" - ] - } - } + "count": { + "type": "integer", + "minimum": 1, + "description": "Replica count", + "example": 1 + }, + "price": { + "type": "object", + "properties": { + "denom": { + "type": "string" + }, + "amount": { + "type": "string", + "pattern": "^\\d+$" + } + }, + "required": [ + "denom", + "amount" + ] + } + }, + "required": [ + "resource", + "count", + "price" + ] + }, + "description": "Resource units with replica counts" }, - "required": [ - "total", - "delegations", - "available", - "delegated", - "rewards", - "assets", - "redelegations", - "commission", - "latestTransactions" - ] - } + "timezone": { + "type": "string", + "description": "Client IANA timezone, validated against zones the runtime recognizes", + "example": "America/Chicago" + }, + "reclamationWindow": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "Optional reclamation window in seconds; if provided, only providers with a reclamationWindow greater than or equal to this value will be considered", + "example": 3600 + } + }, + "required": [ + "resources", + "timezone" + ] } } - }, - "400": { - "description": "Invalid address" - } - } - } - }, - "/v1/addresses/{address}/transactions/{skip}/{limit}": { - "get": { - "summary": "Get a list of transactions for a given address.", - "tags": [ - "Addresses", - "Transactions" - ], - "security": [], - "parameters": [ - { - "schema": { - "type": "string", - "description": "Wallet Address", - "example": "akash13265twfqejnma6cc93rw5dxk4cldyz2zyy8cdm" - }, - "required": true, - "name": "address", - "in": "path" - }, - { - "schema": { - "type": "number", - "nullable": true, - "minimum": 0, - "description": "Transactions to skip", - "example": 10 - }, - "required": false, - "name": "skip", - "in": "path" - }, - { - "schema": { - "type": "number", - "minimum": 1, - "maximum": 100, - "description": "Transactions to return", - "example": 10 - }, - "required": true, - "name": "limit", - "in": "path" } - ], + }, "responses": { "200": { - "description": "Returns transaction list", + "description": "Returns matching providers", "content": { "application/json": { "schema": { "type": "object", "properties": { - "count": { - "type": "number" - }, - "results": { + "providers": { "type": "array", "items": { "type": "object", "properties": { - "height": { - "type": "number" - }, - "datetime": { - "type": "string" - }, - "hash": { - "type": "string" - }, - "isSuccess": { - "type": "boolean" - }, - "error": { + "owner": { "type": "string", - "nullable": true + "description": "Provider address", + "example": "akash1q7spv2cw06yszgfp4f9ed59lkka6ytn8g4tkjf" }, - "gasUsed": { - "type": "number" + "hostUri": { + "type": "string", + "description": "Provider HTTPS endpoint", + "example": "https://provider.europlots.com:8443" }, - "gasWanted": { - "type": "number" + "isAudited": { + "type": "boolean", + "description": "True if signed by a known auditor" }, - "fee": { - "type": "number" + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp marking when the provider was first enrolled in the inventory", + "example": "2026-01-01T00:00:00.000Z" }, - "memo": { + "location": { "type": "string", - "nullable": true + "nullable": true, + "description": "Provider region from the location-region attribute (signed preferred, else self-declared); null if unset", + "example": "us-west" }, - "isSigner": { - "type": "boolean" + "organization": { + "type": "string", + "nullable": true, + "description": "Provider organization from the organization attribute (signed preferred, else self-declared); null if unset", + "example": "Akash" }, - "messages": { + "incidents": { "type": "array", "items": { "type": "object", "properties": { - "id": { - "type": "string" + "date": { + "type": "string", + "description": "Local calendar day, YYYY-MM-DD", + "example": "2026-06-01" }, - "type": { - "type": "string" + "hasOpenIncident": { + "type": "boolean", + "description": "True if the provider currently has any open incident" }, - "amount": { - "type": "number" + "incidentCount": { + "type": "integer", + "description": "Number of incident intervals overlapping that day" }, - "isReceiver": { - "type": "boolean" + "downtimeSeconds": { + "type": "integer", + "description": "Downtime clipped to that day, in seconds (max 86400)" } }, "required": [ - "id", - "type", - "amount", - "isReceiver" + "date", + "hasOpenIncident", + "incidentCount", + "downtimeSeconds" ] - } + }, + "description": "Per-day downtime over a rolling 7-day window" + }, + "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" + ] + } + ] } }, "required": [ - "height", - "datetime", - "hash", - "isSuccess", - "error", - "gasUsed", - "gasWanted", - "fee", - "memo", - "isSigner", - "messages" + "owner", + "hostUri", + "isAudited", + "createdAt", + "location", + "organization", + "incidents" ] } - } - }, - "required": [ - "count", - "results" - ] - } - } - } - }, - "400": { - "description": "Invalid address or parameters" - } - } - } - }, - "/v1/blockchain-status": { - "get": { - "summary": "Get blockchain reachability status", - "tags": [ - "Chain" - ], - "security": [], - "responses": { - "200": { - "description": "Returns blockchain reachability status", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "isBlockchainReachable": { - "type": "boolean" - } - }, - "required": [ - "isBlockchainReachable" - ] - } - } - } - } - } - } - }, - "/v1/bid-screening": { - "post": { - "operationId": "screenProviders", - "summary": "Screen providers by deployment resource requirements", - "tags": [ - "Bid Screening" - ], - "security": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "requirements": { - "type": "object", - "properties": { - "signedBy": { + }, + "exclusions": { + "type": "array", + "items": { "type": "object", "properties": { - "allOf": { - "type": "array", - "items": { - "type": "string" - }, - "default": [] + "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" + ] + } + ] }, - "anyOf": { + "failures": { "type": "array", "items": { - "type": "string" - }, - "default": [] - } - }, - "default": {} - }, - "attributes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", - "description": "Attribute key", - "example": "persistent" - }, - "value": { - "type": "string", - "description": "Attribute value", - "example": "false" - } - }, - "required": [ - "key", - "value" - ] - }, - "default": [] - } - }, - "default": {} - }, - "resources": { - "type": "array", - "items": { - "type": "object", - "properties": { - "resource": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Resource unit ID", - "example": 1 - }, - "cpu": { - "type": "object", - "properties": { - "units": { + "oneOf": [ + { "type": "object", "properties": { - "val": { + "code": { "type": "string", - "maxLength": 80 + "enum": [ + "snapshot_not_posted" + ] } }, "required": [ - "val" + "code" ] }, - "attributes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", - "description": "Attribute key", - "example": "persistent" - }, - "value": { - "type": "string", - "description": "Attribute value", - "example": "false" - } - }, - "required": [ - "key", - "value" - ] - } - } - }, - "required": [ - "units" - ] - }, - "memory": { - "type": "object", - "properties": { - "quantity": { + { "type": "object", "properties": { - "val": { + "code": { "type": "string", - "maxLength": 80 + "enum": [ + "snapshot_suspended" + ] } }, "required": [ - "val" + "code" ] }, - "attributes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", - "description": "Attribute key", - "example": "persistent" - }, - "value": { - "type": "string", - "description": "Attribute value", - "example": "false" - } - }, - "required": [ - "key", - "value" - ] - } - } - }, - "required": [ - "quantity" - ] - }, - "gpu": { - "type": "object", - "properties": { - "units": { + { "type": "object", "properties": { - "val": { + "code": { "type": "string", - "maxLength": 80 + "enum": [ + "snapshot_stale" + ] } }, "required": [ - "val" + "code" ] }, - "attributes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", - "description": "Attribute key", - "example": "persistent" - }, - "value": { - "type": "string", - "description": "Attribute value", - "example": "false" - } + { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "insufficient_tier" + ] }, - "required": [ - "key", - "value" - ] - } - } - }, - "required": [ - "units" - ] - }, - "storage": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Storage volume name", - "example": "default" - }, - "quantity": { - "type": "object", - "properties": { - "val": { - "type": "string", - "maxLength": 80 - } + "actual": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] }, - "required": [ - "val" - ] + "required": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] + } }, - "attributes": { - "type": "array", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^([a-zA-Z][\\w/.-]{1,126}[\\w*]?)$", - "description": "Attribute key", - "example": "persistent" - }, - "value": { - "type": "string", - "description": "Attribute value", - "example": "false" - } - }, - "required": [ - "key", - "value" + "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" + ] }, - "required": [ - "name", - "quantity" - ] - } - }, - "endpoints": { - "type": "array", - "items": { - "nullable": true - } - } - }, - "required": [ - "id", - "cpu", - "memory", - "gpu", - "storage" - ] - }, - "count": { - "type": "integer", - "minimum": 1, - "description": "Replica count", - "example": 1 - }, - "price": { - "type": "object", - "properties": { - "denom": { - "type": "string" + { + "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" + ] + } + ] }, - "amount": { - "type": "string", - "pattern": "^\\d+$" - } - }, - "required": [ - "denom", - "amount" - ] - } - }, - "required": [ - "resource", - "count", - "price" - ] - }, - "description": "Resource units with replica counts" - }, - "timezone": { - "type": "string", - "description": "Client IANA timezone, validated against zones the runtime recognizes", - "example": "America/Chicago" - }, - "reclamationWindow": { - "type": "integer", - "minimum": 0, - "exclusiveMinimum": true, - "description": "Optional reclamation window in seconds; if provided, only providers with a reclamationWindow greater than or equal to this value will be considered", - "example": 3600 - } - }, - "required": [ - "resources", - "timezone" - ] - } - } - } - }, - "responses": { - "200": { - "description": "Returns matching providers", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "providers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "owner": { - "type": "string", - "description": "Provider address", - "example": "akash1q7spv2cw06yszgfp4f9ed59lkka6ytn8g4tkjf" - }, - "hostUri": { - "type": "string", - "description": "Provider HTTPS endpoint", - "example": "https://provider.europlots.com:8443" - }, - "isAudited": { - "type": "boolean", - "description": "True if signed by a known auditor" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp marking when the provider was first enrolled in the inventory", - "example": "2026-01-01T00:00:00.000Z" - }, - "location": { - "type": "string", - "nullable": true, - "description": "Provider region from the location-region attribute (signed preferred, else self-declared); null if unset", - "example": "us-west" - }, - "organization": { - "type": "string", - "nullable": true, - "description": "Provider organization from the organization attribute (signed preferred, else self-declared); null if unset", - "example": "Akash" + "minItems": 1 }, - "incidents": { - "type": "array", - "items": { - "type": "object", - "properties": { - "date": { - "type": "string", - "description": "Local calendar day, YYYY-MM-DD", - "example": "2026-06-01" - }, - "hasOpenIncident": { - "type": "boolean", - "description": "True if the provider currently has any open incident" - }, - "incidentCount": { - "type": "integer", - "description": "Number of incident intervals overlapping that day" - }, - "downtimeSeconds": { + "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", - "description": "Downtime clipped to that day, in seconds (max 86400)" + "enum": [ + 0, + 1, + 2, + 3, + 4, + -1 + ] } }, - "required": [ - "date", - "hasOpenIncident", - "incidentCount", - "downtimeSeconds" - ] + "validAttestationCount": { + "type": "integer", + "minimum": 0 + }, + "validAuditors": { + "type": "array", + "items": { + "type": "string" + } + }, + "snapshotState": { + "type": "string", + "enum": [ + "unknown", + "not_posted", + "current", + "stale", + "suspended" + ] + }, + "observedHeight": { + "type": "string" + } }, - "description": "Per-day downtime over a rolling 7-day window" + "required": [ + "bestStatusValidTier", + "tierGateTier", + "capabilities", + "validAttestationCount", + "validAuditors", + "snapshotState", + "observedHeight" + ] } }, "required": [ "owner", - "hostUri", - "isAudited", - "createdAt", - "location", - "organization", - "incidents" + "firstFailure", + "failures", + "summary" ] } } diff --git a/packages/console-api-types/src/schema.d.ts b/packages/console-api-types/src/schema.d.ts index bb9c72a8ec..75b2580d52 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; @@ -1647,7 +1675,9 @@ export interface paths { "application/json": { data: { /** @description Whether auto top-up is enabled for this deployment */ - autoTopUpEnabled: boolean; + 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; @@ -1857,7 +1909,9 @@ export interface paths { "application/json": { data: { /** @description Whether auto top-up is enabled for this deployment */ - autoTopUpEnabled: boolean; + 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 +1947,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 +1969,17 @@ export interface paths { }; }; }; + /** @description Runtime limit changed concurrently */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + }; + }; + }; }; }; trace?: never; @@ -3333,6 +3409,29 @@ 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; }[]; }; }; @@ -3466,6 +3565,241 @@ export interface paths { isOnline: boolean; checkDate: string; }[]; + 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; }; }; }; @@ -8127,7 +8461,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 +8961,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 +9054,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 @@ -8785,6 +9134,136 @@ export interface operations { /** @description Downtime clipped to that day, in seconds (max 86400) */ downtimeSeconds: number; }[]; + 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; + }; + }; + }[]; + 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; + }; }[]; }; }; From 34c57e913e2ae670983290a60710696093c713cd Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 20:36:32 -0700 Subject: [PATCH 05/12] feat(provider): add Console verification workflows Let tenants configure verification policy, preflight provider availability, inspect eligible bids, and compare lease policy with current provider facts. Signed-off-by: Joseph Chalabi --- apps/deploy-web/.env.local.sample | 6 + apps/deploy-web/env/.env.sample | 4 + .../ConfigureDeploymentHeader.spec.tsx | 77 ++- .../ConfigureDeploymentHeader.tsx | 46 +- .../verificationProviderPreflight.spec.ts | 67 +++ .../verificationProviderPreflight.ts | 40 ++ .../PlacementCard/PlacementCard.spec.tsx | 23 +- .../PlacementCard/PlacementCard.tsx | 6 +- .../PlacementVerificationEditor.spec.tsx | 188 ++++++ .../PlacementVerificationEditor.tsx | 139 +++++ .../MarketplacePane/MarketplacePane.spec.tsx | 24 +- .../MarketplacePane/MarketplacePane.tsx | 27 +- .../MarketplaceProviderCell.tsx | 67 ++- .../MarketplaceProvidersTable.spec.tsx | 98 ++++ .../MarketplaceProvidersTable.tsx | 188 +++++- .../PlacementCard.spec.tsx | 94 ++- .../DeploymentPlacements/PlacementCard.tsx | 18 +- .../PlacementVerificationPanel.spec.tsx | 214 +++++++ .../PlacementVerificationPanel.tsx | 298 ++++++++++ .../placementVerificationModel.spec.ts | 83 +++ .../placementVerificationModel.ts | 62 ++ .../components/providers/ProviderDetail.tsx | 14 +- .../src/components/providers/ProviderList.tsx | 13 +- .../components/providers/ProviderSummary.tsx | 4 +- .../components/providers/ProviderTable.tsx | 7 +- .../components/providers/ProviderTableRow.tsx | 9 +- .../providers/ProviderVerificationDetails.tsx | 546 ++++++++++++++++++ .../ProviderVerificationListCell.spec.tsx | 217 +++++++ .../ProviderVerificationListCell.tsx | 107 ++++ .../providers/providerListFilters.spec.ts | 29 + .../providers/providerListFilters.ts | 7 + .../src/components/sdl/PlacementFormModal.tsx | 15 + .../PlacementVerificationFormControl.spec.tsx | 196 +++++++ .../sdl/PlacementVerificationFormControl.tsx | 269 +++++++++ .../src/config/browser-env.config.ts | 4 + .../src/config/env-config.schema.ts | 4 + .../ServicesProvider.spec.tsx | 31 +- .../ServicesProvider/ServicesProvider.tsx | 16 +- .../src/queries/usePlacementOffers.spec.ts | 153 ++++- .../src/queries/usePlacementOffers.ts | 64 +- .../src/queries/useScreenedProviders.spec.tsx | 74 ++- .../src/queries/useScreenedProviders.ts | 140 ++++- apps/deploy-web/src/types/deployment.ts | 24 + apps/deploy-web/src/types/feature-flags.ts | 3 +- apps/deploy-web/src/types/provider.ts | 182 +++++- .../src/types/sdlBuilder/sdlBuilder.spec.ts | 71 ++- .../src/types/sdlBuilder/sdlBuilder.ts | 23 +- .../src/utils/sdl/sdlGenerator.spec.ts | 45 ++ apps/deploy-web/src/utils/sdl/sdlGenerator.ts | 32 + .../src/utils/sdl/sdlImport.spec.ts | 128 ++++ apps/deploy-web/src/utils/sdl/sdlImport.ts | 28 +- packages/network-store/package.json | 5 +- .../network-store/src/network.config.spec.ts | 52 ++ packages/network-store/src/network.config.ts | 70 ++- 54 files changed, 4235 insertions(+), 116 deletions(-) create mode 100644 apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/verificationProviderPreflight.spec.ts create mode 100644 apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/verificationProviderPreflight.ts create mode 100644 apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/PlacementCard/PlacementVerificationEditor.spec.tsx create mode 100644 apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/PlacementCard/PlacementVerificationEditor.tsx create mode 100644 apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementVerificationPanel.spec.tsx create mode 100644 apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementVerificationPanel.tsx create mode 100644 apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/placementVerificationModel.spec.ts create mode 100644 apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/placementVerificationModel.ts create mode 100644 apps/deploy-web/src/components/providers/ProviderVerificationDetails.tsx create mode 100644 apps/deploy-web/src/components/providers/ProviderVerificationListCell.spec.tsx create mode 100644 apps/deploy-web/src/components/providers/ProviderVerificationListCell.tsx create mode 100644 apps/deploy-web/src/components/providers/providerListFilters.spec.ts create mode 100644 apps/deploy-web/src/components/providers/providerListFilters.ts create mode 100644 apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.spec.tsx create mode 100644 apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.tsx create mode 100644 packages/network-store/src/network.config.spec.ts diff --git a/apps/deploy-web/.env.local.sample b/apps/deploy-web/.env.local.sample index 2e7cc98ca7..11f3af3e83 100644 --- a/apps/deploy-web/.env.local.sample +++ b/apps/deploy-web/.env.local.sample @@ -27,6 +27,12 @@ NEXT_PUBLIC_BASE_API_TESTNET_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_BASE_API_SANDBOX_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEFAULT_NETWORK_ID=mainnet +# Set all four values to point the sandbox network at a private chain. +# NEXT_PUBLIC_AKASH_SANDBOX_CHAIN_ID=aep-86 +# NEXT_PUBLIC_AKASH_SANDBOX_RPC_URL=http://localhost:26657 +# NEXT_PUBLIC_AKASH_SANDBOX_REST_API_URL=http://localhost:1317 +# NEXT_PUBLIC_AKASH_SANDBOX_GENESIS_URL=http://localhost:8080/genesis.json + API_HOST_WITH_DEFAULT=${API_HOST:-localhost} BASE_API_MAINNET_URL=http://${API_HOST_WITH_DEFAULT}:3080 diff --git a/apps/deploy-web/env/.env.sample b/apps/deploy-web/env/.env.sample index fe1b486e10..4f1b8bfca3 100644 --- a/apps/deploy-web/env/.env.sample +++ b/apps/deploy-web/env/.env.sample @@ -18,6 +18,10 @@ NEXT_PUBLIC_BASE_API_MAINNET_URL= NEXT_PUBLIC_BASE_API_SANDBOX_URL= NEXT_PUBLIC_BASE_API_TESTNET_URL= NEXT_PUBLIC_API_BASE_URL= +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= NEXT_PUBLIC_SENTRY_APPLICATION_KEY= NEXT_PUBLIC_SENTRY_DSN= NEXT_PUBLIC_SENTRY_SERVER_NAME= diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx index d4624f5c13..2abd2d6be0 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.spec.tsx @@ -20,12 +20,62 @@ const GENERATED_SDL = 'version: "2.0" # generated'; describe(ConfigureDeploymentHeader.name, () => { it("requests quotes with the SDL generated from the submitted form values, not a stale snapshot", async () => { const requestQuotes = vi.fn(); - const { enqueueSnackbar } = setup({ phase: "configuring", requestQuotes }); + const { enqueueSnackbar, checkVerificationProviderAvailability } = setup({ phase: "configuring", requestQuotes }); fireEvent.click(screen.getByRole("button", { name: /request quotes/i })); await waitFor(() => expect(requestQuotes).toHaveBeenCalledWith(GENERATED_SDL)); expect(enqueueSnackbar).not.toHaveBeenCalled(); + expect(checkVerificationProviderAvailability).not.toHaveBeenCalled(); + }); + + it("checks verified placements before requesting quotes", async () => { + const requestQuotes = vi.fn(); + const { checkVerificationProviderAvailability } = setup({ + phase: "configuring", + requestQuotes, + placements: [verifiedPlacement("west")] + }); + + await userEvent.click(screen.getByRole("button", { name: /request quotes/i })); + + await waitFor(() => expect(requestQuotes).toHaveBeenCalledWith(GENERATED_SDL)); + expect(checkVerificationProviderAvailability).toHaveBeenCalledWith({ sdl: GENERATED_SDL, placements: [verifiedPlacement("west")] }); + }); + + it("blocks quote requests when a verified placement has no matching provider", async () => { + const requestQuotes = vi.fn(); + const { enqueueSnackbar } = setup({ + phase: "configuring", + requestQuotes, + placements: [verifiedPlacement("west")], + unavailablePlacements: ["west"] + }); + + await userEvent.click(screen.getByRole("button", { name: /request quotes/i })); + + await waitFor(() => expect(enqueueSnackbar).toHaveBeenCalledTimes(1)); + expect(requestQuotes).not.toHaveBeenCalled(); + render(enqueueSnackbar.mock.calls[0][0] as ReactNode); + expect(screen.getByText("No matching providers")).toBeInTheDocument(); + expect(screen.getByText(/west: No providers currently meet/i)).toBeInTheDocument(); + }); + + it("blocks quote requests when verified provider availability cannot be checked", async () => { + const requestQuotes = vi.fn(); + const { enqueueSnackbar } = setup({ + phase: "configuring", + requestQuotes, + placements: [verifiedPlacement("west")], + availabilityError: new Error("screening unavailable") + }); + + await userEvent.click(screen.getByRole("button", { name: /request quotes/i })); + + await waitFor(() => expect(enqueueSnackbar).toHaveBeenCalledTimes(1)); + expect(requestQuotes).not.toHaveBeenCalled(); + render(enqueueSnackbar.mock.calls[0][0] as ReactNode); + expect(screen.getByText("Provider availability couldn't be checked")).toBeInTheDocument(); }); it("blocks a trial deployment whose GPU resolves to a blocked selection and surfaces the trial message", async () => { @@ -236,7 +286,7 @@ describe(ConfigureDeploymentHeader.name, () => { phase: DeploymentFlow["phase"]; requestQuotes?: (sdl: string) => void; validationErrors?: string[]; - placements?: { id: string }[]; + placements?: TestPlacement[]; selections?: Record; onDeploy?: () => void; allPlacementsHaveBids?: boolean; @@ -249,6 +299,8 @@ describe(ConfigureDeploymentHeader.name, () => { cancelAndEdit?: () => void; isRestricted?: boolean; services?: Array<{ profile: { hasGpu?: boolean; gpuModels?: Array<{ vendor: string; name?: string }> } }>; + unavailablePlacements?: string[]; + availabilityError?: Error; }) { const flow = mock({ phase: input.phase, @@ -263,6 +315,10 @@ describe(ConfigureDeploymentHeader.name, () => { flow.selections = input.selections ?? {}; const enqueueSnackbar = vi.fn(); const useDeploymentCost = vi.fn(() => input.cost ?? null); + const checkVerificationProviderAvailability = vi.fn(async () => { + if (input.availabilityError) throw input.availabilityError; + return input.unavailablePlacements ?? []; + }); const dependencies: typeof DEPENDENCIES = { useDeploymentResourceSummary: (() => "1 vCPU") as never, useDeploymentHasGpu: () => input.hasGpu ?? true, @@ -274,7 +330,8 @@ describe(ConfigureDeploymentHeader.name, () => { PriceValue: ({ value }) => {String(value)}, useQuoteExpiry: () => input.expiry ?? null, CustomTooltip: ({ children }) => <>{children}, - useTrialGate: () => ({ isRestricted: input.isRestricted ?? false, isWalletReady: true }) + useTrialGate: () => ({ isRestricted: input.isRestricted ?? false, isWalletReady: true }), + useVerificationProviderPreflight: () => checkVerificationProviderAvailability }; render( @@ -287,7 +344,7 @@ describe(ConfigureDeploymentHeader.name, () => { /> ); - return { enqueueSnackbar, useDeploymentCost }; + return { enqueueSnackbar, useDeploymentCost, checkVerificationProviderAvailability }; } function Wrapper({ @@ -296,10 +353,20 @@ describe(ConfigureDeploymentHeader.name, () => { services }: { children: ReactNode; - placements?: { id: string }[]; + placements?: TestPlacement[]; services?: Array<{ profile: { hasGpu?: boolean; gpuModels?: Array<{ vendor: string; name?: string }> } }>; }) { const form = useForm({ defaultValues: { placements: placements ?? [], services: services ?? [] } }); return {children}; } }); + +type TestPlacement = { + id: string; + name?: string; + verification?: { minTier: number; capabilities: []; auditors: [] }; +}; + +function verifiedPlacement(name: string): TestPlacement { + return { id: name, name, verification: { minTier: 1, capabilities: [], auditors: [] } }; +} diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.tsx index 90a3fafc9f..9afaf2e165 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentHeader/ConfigureDeploymentHeader.tsx @@ -1,4 +1,5 @@ import type { FC, ReactNode } from "react"; +import { useState } from "react"; import { useFormContext, useWatch } from "react-hook-form"; import { Button, CustomTooltip, Snackbar } from "@akashnetwork/ui/components"; import { cn } from "@akashnetwork/ui/utils"; @@ -18,6 +19,7 @@ import { useDeploymentCost } from "../useDeploymentCost/useDeploymentCost"; import type { DeploymentFlow } from "../useDeploymentFlow/useDeploymentFlow"; import type { QuoteExpiry } from "../useQuoteExpiry/useQuoteExpiry"; import { useQuoteExpiry } from "../useQuoteExpiry/useQuoteExpiry"; +import { useVerificationProviderPreflight } from "./verificationProviderPreflight"; export const DEPENDENCIES = { useDeploymentResourceSummary, @@ -32,7 +34,8 @@ export const DEPENDENCIES = { PriceValue, useQuoteExpiry, CustomTooltip, - useTrialGate + useTrialGate, + useVerificationProviderPreflight }; type Props = { flow: DeploymentFlow; sdl: string; onDeploy: () => void; allPlacementsHaveBids: boolean; dependencies?: typeof DEPENDENCIES }; @@ -43,6 +46,8 @@ export const ConfigureDeploymentHeader: FC = ({ flow, sdl, onDeploy, allP const { control, handleSubmit, getValues } = useFormContext(); const { enqueueSnackbar } = d.useSnackbar(); const { isRestricted } = d.useTrialGate(); + const checkVerificationProviderAvailability = d.useVerificationProviderPreflight(); + const [isCheckingProviders, setIsCheckingProviders] = useState(false); const placements = useWatch({ control, name: "placements" }); const cost = d.useDeploymentCost({ dseq: flow.dseq, sdl, placements, selections: flow.selections }); const expiry = d.useQuoteExpiry({ dseq: flow.dseq, enabled: flow.phase === "quoting" }); @@ -71,7 +76,7 @@ export const ConfigureDeploymentHeader: FC = ({ flow, sdl, onDeploy, allP * Either failure surfaces the errors to the user; otherwise the same freshly generated SDL is what gets * submitted, so validation and creation can never disagree about which spec they acted on. */ - const onRequestQuotes = handleSubmit(values => { + const onRequestQuotes = handleSubmit(async values => { const sdl = d.generateSdl(values); const errors = [...d.validateGeneratedSdl(sdl)]; // Load-bearing trial guard: enabling the GPU card leaves the model at the empty default without ever @@ -97,6 +102,38 @@ export const ConfigureDeploymentHeader: FC = ({ flow, sdl, onDeploy, allP ); return; } + + if (values.placements.some(placement => placement.verification !== undefined)) { + setIsCheckingProviders(true); + try { + const unavailablePlacements = await checkVerificationProviderAvailability({ sdl, placements: values.placements }); + if (unavailablePlacements.length > 0) { + enqueueSnackbar( + + {unavailablePlacements.map(placement => ( +
  • {placement}: No providers currently meet its resources and verification requirements.
  • + ))} + + } + iconVariant="error" + />, + { variant: "error" } + ); + return; + } + } catch { + enqueueSnackbar(, { + variant: "error" + }); + return; + } finally { + setIsCheckingProviders(false); + } + } + flow.actions.requestQuotes(sdl); }); @@ -125,8 +162,9 @@ export const ConfigureDeploymentHeader: FC = ({ flow, sdl, onDeploy, allP {isEditable ? ( - ) : quotesExpired && !hasOpenBids ? ( + + { + if (!open) cancel(); + }} + > + event.stopPropagation()} + > + + Provider verification · {placementName} + + Provider verification requirements for {placementName} + + + +
    + +
    +
    + + + + +
    +
    + + ); +}; + +export function getPlacementVerificationSummary(verification: PlacementVerificationType | undefined): string { + if (!verification) return "Not required"; + + const parts = [`L${verification.minTier} minimum`]; + + if (verification.minAuditorCount) { + parts.push(`${verification.minAuditorCount} ${verification.minAuditorCount === 1 ? "auditor" : "auditors"}`); + } + + if (verification.capabilities?.length) { + parts.push(`${verification.capabilities.length} ${verification.capabilities.length === 1 ? "capability" : "capabilities"}`); + } + + if (verification.auditors?.length) { + parts.push(`${verification.auditors.length} named ${verification.auditors.length === 1 ? "auditor" : "auditors"}`); + } + + return parts.join(" · "); +} diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplacePane.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplacePane.spec.tsx index 3455665008..abe12f9658 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplacePane.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplacePane.spec.tsx @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; import type { PlacementOffer } from "@src/queries/usePlacementOffers"; +import type { ProviderVerificationExclusion } from "@src/queries/useScreenedProviders"; import type { DeploymentFlowPhase } from "../useDeploymentFlow/useDeploymentFlow"; import { ProviderSearchInput } from "./ProviderSearchInput/ProviderSearchInput"; import type { DEPENDENCIES } from "./MarketplacePane"; @@ -14,7 +16,14 @@ describe(MarketplacePane.name, () => { it("reads offers for the current phase, dseq, sdl, placement and region", () => { const { usePlacementOffers } = setup({ sdl: "version: 2.0", placementName: "dcloud", region: "na-us-west", phase: "quoting", dseq: "100" }); - expect(usePlacementOffers).toHaveBeenCalledWith({ sdl: "version: 2.0", placementName: "dcloud", region: "na-us-west", phase: "quoting", dseq: "100" }); + expect(usePlacementOffers).toHaveBeenCalledWith({ + sdl: "version: 2.0", + placementName: "dcloud", + region: "na-us-west", + phase: "quoting", + dseq: "100", + verificationEnabled: false + }); }); it("shows the placement name in the header", () => { @@ -30,6 +39,13 @@ describe(MarketplacePane.name, () => { expect(MarketplaceProvidersTable).toHaveBeenCalledWith(expect.objectContaining({ providers: offers, isLoading: false }), expect.anything()); }); + it("passes verification exclusions to the table only when the feature is enabled", () => { + const exclusions = [mock({ owner: "akash1excluded" })]; + const { MarketplaceProvidersTable } = setup({ providerVerificationEnabled: true, exclusions }); + + expect(MarketplaceProvidersTable).toHaveBeenCalledWith(expect.objectContaining({ exclusions, verificationEnabled: true }), expect.anything()); + }); + it("passes the spec's GPU count to the table", () => { const { MarketplaceProvidersTable } = setup({ gpuCount: 8, offers: [buildOffer()] }); @@ -131,6 +147,8 @@ describe(MarketplacePane.name, () => { isSearchActive?: boolean; gpuCount?: number; isOnboarded?: boolean; + providerVerificationEnabled?: boolean; + exclusions?: ReturnType["exclusions"]; selectedPlacementId?: string; selectedBidId?: string; onSelectProvider?: (placementId: string, bidId: string) => void; @@ -138,6 +156,7 @@ describe(MarketplacePane.name, () => { ) { const usePlacementOffers = vi.fn(() => ({ offers: input.offers ?? [], + exclusions: input.exclusions ?? [], isLoading: input.isLoading ?? false, isError: input.isError ?? false, isInvalid: input.isInvalid ?? false @@ -161,7 +180,8 @@ describe(MarketplacePane.name, () => { MarketplaceProvidersTable: MarketplaceProvidersTable as never, ProviderSearchInput, useDeploymentGpuCount, - useIsOnboarded: () => input.isOnboarded ?? true + useIsOnboarded: () => input.isOnboarded ?? true, + useFlag: () => input.providerVerificationEnabled ?? false }; const user = userEvent.setup(); diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplacePane.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplacePane.tsx index 2d0a2c2c98..c9016499cf 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplacePane.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplacePane.tsx @@ -1,14 +1,25 @@ import type { FC } from "react"; +import { useMemo } from "react"; +import { useFlag } from "@src/hooks/useFlag"; import { useIsOnboarded } from "@src/hooks/useIsOnboarded"; import { usePlacementOffers } from "@src/queries/usePlacementOffers"; +import { hasPlacementVerificationRequirement } from "@src/queries/useScreenedProviders"; import { useDeploymentGpuCount } from "../DeploymentResourceSummary/useDeploymentResourceSummary"; import type { DeploymentFlowPhase } from "../useDeploymentFlow/useDeploymentFlow"; import { MarketplaceProvidersTable } from "./MarketplaceProvidersTable/MarketplaceProvidersTable"; import { useProviderSearch } from "./MarketplaceProvidersTable/useProviderSearch/useProviderSearch"; import { ProviderSearchInput } from "./ProviderSearchInput/ProviderSearchInput"; -export const DEPENDENCIES = { usePlacementOffers, useProviderSearch, MarketplaceProvidersTable, ProviderSearchInput, useDeploymentGpuCount, useIsOnboarded }; +export const DEPENDENCIES = { + usePlacementOffers, + useProviderSearch, + MarketplaceProvidersTable, + ProviderSearchInput, + useDeploymentGpuCount, + useIsOnboarded, + useFlag +}; interface Props { sdl: string; @@ -33,9 +44,18 @@ export const MarketplacePane: FC = ({ onSelectProvider, dependencies: d = DEPENDENCIES }) => { - const { offers, isLoading, isError, isInvalid } = d.usePlacementOffers({ phase, dseq: dseq ?? undefined, sdl, placementName, region }); + const isProviderVerificationEnabled = d.useFlag("provider_verification"); + const { offers, exclusions, isLoading, isError, isInvalid } = d.usePlacementOffers({ + phase, + dseq: dseq ?? undefined, + sdl, + placementName, + region, + verificationEnabled: isProviderVerificationEnabled + }); const { query, setQuery, clear, filteredProviders, isSearchActive } = d.useProviderSearch(offers); const hasFailedWithoutData = isError && offers.length === 0; + const verificationRequired = useMemo(() => hasPlacementVerificationRequirement(sdl, placementName), [placementName, sdl]); const gpuCount = d.useDeploymentGpuCount(selectedPlacementId); /** Provider names link out only once the user is onboarded: the route gate bounces a not-yet-onboarded user back into the funnel, so the link would dead-end. */ const showProviderLink = d.useIsOnboarded(); @@ -66,6 +86,9 @@ export const MarketplacePane: FC = ({ ) : ( = ({ offer, showProviderLink }) => { +export const MarketplaceProviderCell: FC = ({ offer, showProviderLink, verificationEnabled = false }) => { const displayName = providerDisplayName(offer); const host = getProviderHost(offer.hostUri); const subtitle = host && host !== displayName ? host : null; @@ -50,6 +53,68 @@ export const MarketplaceProviderCell: FC = ({ offer, showProviderLink }) {name} )} {subtitle && {subtitle}} + {verificationEnabled && offer.verification && } ); }; + +function VerificationFacts({ verification }: { verification: NonNullable }) { + if (verification.outcome === "not_evaluated") { + return ( +
    + + Verification not evaluated + +
    + ); + } + + const { summary } = verification; + const auditorCount = summary.validAuditors.length; + const capabilities = summary.capabilities.map(formatCapability).filter(Boolean); + + return ( +
    +
    + + Auditor-attested {formatTier(summary.tierGateTier)} + + + {auditorCount} {auditorCount === 1 ? "auditor" : "auditors"} + +
    + {capabilities.length > 0 &&

    {capabilities.join(", ")}

    } +

    Provider-signed inventory: {summary.snapshotState.replace("_", " ")}

    +
    + ); +} + +function formatTier(tier: number): string { + switch (tier) { + 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 "L0"; + } +} + +function formatCapability(capability: number): string { + switch (capability) { + case CapabilityFlag.capability_tee_hardware_attestation: + return "TEE hardware"; + case CapabilityFlag.capability_confidential_computing: + return "Confidential computing"; + case CapabilityFlag.capability_persistent_storage: + return "Persistent storage"; + case CapabilityFlag.capability_bare_metal: + return "Bare metal"; + default: + return ""; + } +} diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplaceProvidersTable/MarketplaceProvidersTable.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplaceProvidersTable/MarketplaceProvidersTable.spec.tsx index f369b1c697..28b8a9f079 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplaceProvidersTable/MarketplaceProvidersTable.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplaceProvidersTable/MarketplaceProvidersTable.spec.tsx @@ -1,10 +1,12 @@ import { IntlProvider } from "react-intl"; +import { CapabilityFlag, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; import { TooltipProvider } from "@akashnetwork/ui/components"; import { format, subDays } from "date-fns"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import type { PlacementOffer } from "@src/queries/usePlacementOffers"; +import type { ProviderVerificationExclusion } from "@src/queries/useScreenedProviders"; import { MarketplaceProvidersTable } from "./MarketplaceProvidersTable"; import { render, screen, within } from "@testing-library/react"; @@ -33,6 +35,29 @@ describe(MarketplaceProvidersTable.name, () => { expect(screen.getAllByText("—").length).toBeGreaterThan(0); }); + it("shows auditor-attested facts for an eligible provider when verification is enabled", () => { + setup({ providers: [verifiedOffer()], verificationEnabled: true }); + + expect(screen.getByText("Auditor-attested L2")).toBeInTheDocument(); + expect(screen.getByText("2 auditors")).toBeInTheDocument(); + expect(screen.getByText("Persistent storage")).toBeInTheDocument(); + expect(screen.getByText("Provider-signed inventory: current")).toBeInTheDocument(); + }); + + it("does not expose verification facts while the feature flag is off", () => { + setup({ providers: [verifiedOffer()], verificationEnabled: false }); + + expect(screen.queryByText("Auditor-attested L2")).not.toBeInTheDocument(); + }); + + it("shows the first exclusion reason and provides the full failure set", async () => { + setup({ providers: [verifiedOffer()], verificationEnabled: true, exclusions: [verificationExclusion()] }); + + expect(screen.getAllByText(/Auditor-attested tier is L1; L2 is required/)).not.toHaveLength(0); + await userEvent.click(screen.getByText("View all 2 reasons")); + expect(screen.getByText("Missing auditor-attested capability: Persistent storage")).toBeInTheDocument(); + }); + it("sorts rows by provider host when the Provider header is toggled ascending", async () => { setup({ providers: [buildOffer({ hostUri: "https://zeta.example:8443" }), buildOffer({ hostUri: "https://alpha.example:8443" })] @@ -124,6 +149,22 @@ describe(MarketplaceProvidersTable.name, () => { expect(screen.queryByRole("button", { name: /clear search/i })).not.toBeInTheDocument(); }); + it("shows an actionable empty state when no provider meets the verification policy", () => { + setup({ providers: [], verificationEnabled: true, verificationRequired: true }); + + expect(screen.getByText("No providers currently meet this verification policy.")).toBeInTheDocument(); + expect(screen.getByText(/lower tier/i)).toBeInTheDocument(); + expect(screen.queryByText("No providers found.")).not.toBeInTheDocument(); + }); + + it("keeps the actionable verification empty state above provider exclusions", () => { + setup({ providers: [], verificationEnabled: true, verificationRequired: true, exclusions: [verificationExclusion()] }); + + expect(screen.getByText("No providers currently meet this verification policy.")).toBeInTheDocument(); + expect(screen.getByText(/review the exclusions below/i)).toBeInTheDocument(); + expect(screen.getAllByText(/Auditor-attested tier is L1; L2 is required/)).not.toHaveLength(0); + }); + it("omits the clear action in the search empty state when no clear handler is provided", () => { setup({ providers: [], isSearchActive: true }); @@ -329,6 +370,57 @@ describe(MarketplaceProvidersTable.name, () => { }); } + function verifiedOffer(): PlacementOffer { + return mock({ + offerState: "searching", + owner: "akash1verified", + organization: "Verified Provider", + hostUri: "https://verified.example:8443", + location: "us-west", + incidents: [], + verification: { + outcome: "pass", + summary: { + bestStatusValidTier: VerificationTier.verification_tier_verified, + tierGateTier: VerificationTier.verification_tier_verified, + capabilities: [CapabilityFlag.capability_persistent_storage], + validAttestationCount: 2, + validAuditors: ["akash1auditor1", "akash1auditor2"], + snapshotState: "current", + observedHeight: "123" + } + } + }); + } + + function verificationExclusion(): ProviderVerificationExclusion { + return { + 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: { + bestStatusValidTier: VerificationTier.verification_tier_identified, + tierGateTier: VerificationTier.verification_tier_identified, + capabilities: [], + validAttestationCount: 1, + validAuditors: ["akash1auditor"], + snapshotState: "not_posted", + observedHeight: "123" + } + }; + } + function setup(input: { providers: PlacementOffer[]; isLoading?: boolean; @@ -338,6 +430,9 @@ describe(MarketplaceProvidersTable.name, () => { isSelectable?: boolean; gpuCount?: number; showProviderLink?: boolean; + verificationEnabled?: boolean; + verificationRequired?: boolean; + exclusions?: ProviderVerificationExclusion[]; }) { const onSelect = vi.fn(); const user = userEvent.setup(); @@ -347,6 +442,9 @@ describe(MarketplaceProvidersTable.name, () => { = { interface Props { providers: PlacementOffer[]; + exclusions?: ProviderVerificationExclusion[]; + verificationEnabled?: boolean; + verificationRequired?: boolean; isLoading?: boolean; isSearchActive?: boolean; onClearSearch?: () => void; @@ -51,6 +56,9 @@ interface Props { export const MarketplaceProvidersTable: FC = ({ providers, + exclusions = [], + verificationEnabled = false, + verificationRequired = false, isLoading, isSearchActive, onClearSearch, @@ -68,8 +76,18 @@ export const MarketplaceProvidersTable: FC = ({ /** Cost only makes sense once bids arrive: a submitted bid is priced and a closed/expired one keeps its last price, but a screened-only candidate has none. */ const showCost = providers.some(provider => !!provider.price); const columns = useMemo( - () => buildColumns(uptimeByOwner, { selectedBidId, onSelect, isSelectable, showCost, showStatus: isMerged, gpuCount, showProviderLink }), - [uptimeByOwner, selectedBidId, onSelect, isSelectable, showCost, isMerged, gpuCount, showProviderLink] + () => + buildColumns(uptimeByOwner, { + selectedBidId, + onSelect, + isSelectable, + showCost, + showStatus: isMerged, + gpuCount, + showProviderLink, + verificationEnabled + }), + [uptimeByOwner, selectedBidId, onSelect, isSelectable, showCost, isMerged, gpuCount, showProviderLink, verificationEnabled] ); const table = useReactTable({ @@ -98,6 +116,25 @@ export const MarketplaceProvidersTable: FC = ({ ); } + if (verificationRequired && exclusions.length > 0) { + return ( +
    +
    +

    No providers currently meet this verification policy.

    +

    Review the exclusions below or relax the placement requirements.

    +
    + +
    + ); + } + if (verificationRequired) { + return ( +
    +

    No providers currently meet this verification policy.

    +

    Try a lower tier, fewer required capabilities, or different resource requirements.

    +
    + ); + } return

    No providers found.

    ; } @@ -112,35 +149,38 @@ export const MarketplaceProvidersTable: FC = ({ const columnCount = table.getVisibleFlatColumns().length; return ( -
    - - - {table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map(header => ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {biddableRows.map(row => ( - - ))} - {noBidRows.length > 0 && biddableRows.length > 0 && ( - - - didn't bid - - - )} - {noBidRows.map(row => ( - - ))} - -
    +
    +
    + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {biddableRows.map(row => ( + + ))} + {noBidRows.length > 0 && biddableRows.length > 0 && ( + + + didn't bid + + + )} + {noBidRows.map(row => ( + + ))} + +
    +
    + {verificationEnabled && exclusions.length > 0 && }
    ); }; @@ -241,13 +281,16 @@ function buildColumns( showStatus: boolean; gpuCount: number; showProviderLink: boolean; + verificationEnabled: boolean; } ) { return [ columnHelper.accessor(providerDisplayName, { id: "hostUri", header: ({ column }) => , - cell: info => + cell: info => ( + + ) }), columnHelper.accessor("location", { header: ({ column }) => , @@ -300,3 +343,84 @@ function buildColumns( : []) ]; } + +function VerificationExclusions({ exclusions }: { exclusions: ProviderVerificationExclusion[] }) { + return ( +
    +

    Excluded by verification policy ({exclusions.length})

    +
    + {exclusions.map(exclusion => ( +
    +

    + {exclusion.owner} + : {formatVerificationFailure(exclusion.firstFailure)} +

    + {exclusion.failures.length > 1 && ( +
    + View all {exclusion.failures.length} reasons +
      + {exclusion.failures.map((failure, index) => ( +
    • {formatVerificationFailure(failure)}
    • + ))} +
    +
    + )} +
    + ))} +
    +
    + ); +} + +function formatVerificationFailure(failure: ProviderVerificationFailure): string { + switch (failure.code) { + case "snapshot_not_posted": + return "No provider-signed inventory snapshot is posted"; + case "snapshot_suspended": + return "Provider-signed inventory snapshot is suspended"; + case "snapshot_stale": + return "Provider-signed inventory snapshot is stale"; + case "insufficient_tier": + return `Auditor-attested tier is ${formatTier(failure.actual)}; ${formatTier(failure.required)} is required`; + case "missing_capability": + return `Missing auditor-attested capability: ${formatCapability(failure.capability)}`; + case "insufficient_auditor_count": + return `${failure.actual} qualifying auditors; ${failure.required} required`; + case "required_auditor_not_found": + return `${formatAuditorMode(failure.mode)} named-auditor policy is not satisfied`; + } +} + +function formatTier(tier: number): string { + switch (tier) { + 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 "L0"; + } +} + +function formatCapability(capability: number): string { + switch (capability) { + case CapabilityFlag.capability_tee_hardware_attestation: + return "TEE hardware attestation"; + case CapabilityFlag.capability_confidential_computing: + return "Confidential computing"; + case CapabilityFlag.capability_persistent_storage: + return "Persistent storage"; + case CapabilityFlag.capability_bare_metal: + return "Bare metal"; + default: + return "Unknown capability"; + } +} + +function formatAuditorMode(mode: number): string { + return mode === AuditorSelectionMode.auditor_selection_mode_all ? "All" : "Any"; +} diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementCard.spec.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementCard.spec.tsx index ac3407c5d8..5c37cd760f 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementCard.spec.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementCard.spec.tsx @@ -4,10 +4,11 @@ import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import type { ForwardedPort, LeaseServiceStatus, LeaseStatusDto } from "@src/queries/useLeaseQuery"; -import type { DeploymentGroup, LeaseDto } from "@src/types/deployment"; -import type { ApiProviderList } from "@src/types/provider"; +import type { DeploymentGroup, LeaseDto, RpcVerificationRequirement } from "@src/types/deployment"; +import type { ApiProviderDetail, ApiProviderList, ProviderVerificationView } from "@src/types/provider"; import { DEPENDENCIES, PlacementCard } from "./PlacementCard"; import type { ManifestServiceDetail } from "./placementModel"; +import type { PlacementVerificationPanelProps } from "./PlacementVerificationPanel"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -166,30 +167,73 @@ describe(PlacementCard.name, () => { expect(screen.queryByText("Reclaiming")).not.toBeInTheDocument(); }); + it("passes the on-chain policy and API provider facts to the verification panel when enabled", () => { + const PlacementVerificationPanel = vi.fn((_props: PlacementVerificationPanelProps) =>
    verification-panel
    ); + const verification = mock({ provider: "akash1p" }); + + setup({ + lease: buildLease({ verification: buildVerificationRequirement(), signedBy: { all_of: ["akash1legacy"], any_of: [] } }), + providerDetail: buildProviderDetail(verification), + dependencies: { useFlag: () => true, PlacementVerificationPanel } + }); + + expect(screen.getByText("verification-panel")).toBeInTheDocument(); + expect(PlacementVerificationPanel.mock.calls[0][0]).toEqual( + expect.objectContaining({ + placementName: "dcloud", + policy: { + legacySignedBy: { allOf: ["akash1legacy"], anyOf: [] }, + verification: expect.objectContaining({ minTier: "L2" }) + }, + verification + }) + ); + }); + + it("does not mount the verification panel while the feature flag is disabled", () => { + const PlacementVerificationPanel = vi.fn((_props: PlacementVerificationPanelProps) =>
    verification-panel
    ); + const useProviderDetail = vi.fn(() => mock>({ data: null })); + + setup({ + lease: buildLease({ verification: buildVerificationRequirement() }), + dependencies: { useFlag: () => false, useProviderDetail, PlacementVerificationPanel } + }); + + expect(PlacementVerificationPanel).not.toHaveBeenCalled(); + expect(useProviderDetail).toHaveBeenCalledWith("akash1p", { enabled: false }); + }); + function buildLease(input?: { groupName?: string; state?: string; groupState?: string; gpuAmount?: number; gpuAttributes?: { key: string; value: string }[]; + signedBy?: { all_of: string[]; any_of: string[] }; + verification?: RpcVerificationRequirement; }) { - return mock({ + const lease = mock({ id: "1", provider: "akash1p", state: input?.state ?? "active", cpuAmount: 6, gpuAmount: input?.gpuAmount ?? 0, memoryAmount: 1_000_000, - storageAmount: 2_000_000, - group: mock({ - state: input?.groupState ?? "active", - group_spec: { - name: input?.groupName ?? "dcloud", - requirements: { attributes: [] as { key: string; value: string }[] }, - resources: input?.gpuAttributes ? [{ resource: { gpu: { attributes: input.gpuAttributes } } }] : ([] as DeploymentGroup["group_spec"]["resources"]) - } - } as Partial) + storageAmount: 2_000_000 }); + lease.group = { + state: input?.groupState ?? "active", + group_spec: { + name: input?.groupName ?? "dcloud", + requirements: { + signed_by: input?.signedBy ?? { all_of: [], any_of: [] }, + verification: input?.verification, + attributes: [] + }, + resources: input?.gpuAttributes ? [{ resource: { gpu: { attributes: input.gpuAttributes } } }] : [] + } + } as unknown as DeploymentGroup; + return lease; } function buildStatus(serviceNames: string[], forwardedPorts: Record = {}) { @@ -201,12 +245,29 @@ describe(PlacementCard.name, () => { } function buildProvider(input?: { region?: string }) { - return mock({ + const provider = mock({ owner: "akash1p", organization: "Meridian Cloud", locationRegion: "", - attributes: input?.region ? [{ key: "region", value: input.region, auditedBy: [] }] : [] + attributes: input?.region ? [{ key: "region", value: input.region, auditedBy: [] }] : [], + verification: null }); + provider.verification = null; + return provider; + } + + function buildProviderDetail(verification: ProviderVerificationView): ApiProviderDetail { + return mock({ owner: "akash1p", verification }); + } + + function buildVerificationRequirement(): RpcVerificationRequirement { + return { + min_tier: "verification_tier_verified", + required_capabilities: ["capability_persistent_storage"], + required_auditors: ["akash1auditor"], + auditor_mode: "auditor_selection_mode_any", + min_auditor_count: 1 + }; } it.each([502, 503])("warns that the provider is not responding when lease status fails with %s", status => { @@ -233,6 +294,7 @@ describe(PlacementCard.name, () => { function setup(input?: { lease?: LeaseDto; provider?: ApiProviderList; + providerDetail?: ApiProviderDetail | null; leaseStatus?: LeaseStatusDto | null; leaseStatusError?: unknown; isLeaseStatusPending?: boolean; @@ -248,6 +310,8 @@ describe(PlacementCard.name, () => { isPending: !leaseStatus && !input?.leaseStatusError, isLoading: input?.isLeaseStatusPending ?? false }); + const useProviderDetail: typeof DEPENDENCIES.useProviderDetail = () => + mock>({ data: input?.providerDetail ?? null }); const useTeeResourceCarveouts: typeof DEPENDENCIES.useTeeResourceCarveouts = () => []; return render( @@ -260,7 +324,7 @@ describe(PlacementCard.name, () => { placementServices={input?.placementServices} dseq="123" onClosed={vi.fn()} - dependencies={MockComponents(DEPENDENCIES, { useLeaseStatus, useTeeResourceCarveouts, ...input?.dependencies })} + dependencies={MockComponents(DEPENDENCIES, { useLeaseStatus, useProviderDetail, useTeeResourceCarveouts, ...input?.dependencies })} /> ); diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementCard.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementCard.tsx index 9eee72d752..2384d50606 100644 --- a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementCard.tsx +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementCard.tsx @@ -4,8 +4,10 @@ import { useState } from "react"; import { MapPin, NavArrowRight, Server } from "iconoir-react"; import Link from "next/link"; +import { useFlag } from "@src/hooks/useFlag"; import { useTeeResourceCarveouts } from "@src/hooks/useTeeResourceCarveouts"; import { useLeaseStatus } from "@src/queries/useLeaseQuery"; +import { useProviderDetail } from "@src/queries/useProvidersQuery"; import { isProviderUnavailableError } from "@src/services/query-error-policy/query-error-policy"; import type { LeaseDto } from "@src/types/deployment"; import type { ApiProviderList } from "@src/types/provider"; @@ -25,14 +27,19 @@ import { formatGpuLabel, getPlacementGpuModels, getPlacementName, getProviderReg import { PlacementServiceRow } from "./PlacementServiceRow"; import type { PlacementStat } from "./PlacementStats"; import { PlacementStats } from "./PlacementStats"; +import { getPlacementSecurityPolicy } from "./placementVerificationModel"; +import { PlacementVerificationPanel } from "./PlacementVerificationPanel"; export const DEPENDENCIES = { + useFlag, useLeaseStatus, + useProviderDetail, useTeeResourceCarveouts, ReclamationCard, ConfidentialComputeResources, DownloadAttestationEvidence, - PlacementServiceRow + PlacementServiceRow, + PlacementVerificationPanel }; export interface PlacementCardProps { @@ -66,6 +73,10 @@ export const PlacementCard: FC = ({ const isLeaseStatusPending = isLeaseActive && !!provider && isLoading; const isProviderUnreachable = isLeaseActive && isProviderUnavailableError(leaseStatusError); const carveouts = d.useTeeResourceCarveouts(lease); + const isProviderVerificationEnabled = d.useFlag("provider_verification"); + const { data: providerDetail } = d.useProviderDetail(provider?.owner ?? "", { + enabled: isProviderVerificationEnabled && !!provider?.owner + }); const [expanded, setExpanded] = useState>(() => new Set()); const isReclaimed = isProviderReclaimed(lease); @@ -76,6 +87,7 @@ export const PlacementCard: FC = ({ const services = placementServices ?? manifestServices; const serviceNames = leaseStatus ? Object.keys(leaseStatus.services) : Object.keys(services); const providerName = provider ? providerDisplayName(provider) : undefined; + const securityPolicy = getPlacementSecurityPolicy(lease.group); const allExpanded = serviceNames.length > 0 && serviceNames.every(serviceName => expanded.has(serviceName)); function toggleAll() { @@ -129,6 +141,10 @@ export const PlacementCard: FC = ({
    + {isProviderVerificationEnabled && ( + + )} + {(isReclaimed || carveouts.length > 0 || (isLeaseActive && !!provider && !!teeType)) && (
    {isReclaimed && } diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementVerificationPanel.spec.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementVerificationPanel.spec.tsx new file mode 100644 index 0000000000..da4ee5f43b --- /dev/null +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementVerificationPanel.spec.tsx @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { ProviderVerificationView } from "@src/types/provider"; +import type { PlacementSecurityPolicy, PlacementVerificationPolicy } from "./placementVerificationModel"; +import { PlacementVerificationPanel } from "./PlacementVerificationPanel"; + +import { fireEvent, render, screen } from "@testing-library/react"; + +describe(PlacementVerificationPanel.name, () => { + it("shows a legacy-only policy separately from AEP-86", () => { + setup({ policy: buildPolicy({ legacy: true }) }); + + expect(screen.getByText("Legacy auditor policy")).toBeInTheDocument(); + expect(screen.queryByText("Legacy signedBy")).not.toBeInTheDocument(); + openDetails(); + + expect(screen.getByText("Legacy signedBy")).toBeInTheDocument(); + expect(screen.getByText("akash1legacy")).toBeInTheDocument(); + expect(screen.queryByText("AEP-86 policy")).not.toBeInTheDocument(); + }); + + it("shows a verification-only policy next to current provider facts", () => { + setup({ + policy: buildPolicy({ verification: buildRequirement() }), + verification: buildVerification() + }); + + expect(screen.getByText("Requires L2 · 1 auditor · Persistent storage")).toBeInTheDocument(); + expect(screen.getByText("L2 - Verified")).toBeInTheDocument(); + expect(screen.queryByText("AEP-86 policy")).not.toBeInTheDocument(); + openDetails(); + + expect(screen.queryByText("Legacy signedBy")).not.toBeInTheDocument(); + expect(screen.getByText("AEP-86 policy")).toBeInTheDocument(); + expect(screen.getAllByText("L2 - Verified")).toHaveLength(3); + expect(screen.getByText("Any named auditor")).toBeInTheDocument(); + expect(screen.getByText("akash1auditor")).toBeInTheDocument(); + expect(screen.getByText("Current")).toBeInTheDocument(); + }); + + it("shows legacy signedBy and AEP-86 requirements together", () => { + setup({ + policy: buildPolicy({ legacy: true, verification: buildRequirement({ auditorMode: "all" }) }), + verification: buildVerification() + }); + + openDetails(); + + expect(screen.getByText("Legacy signedBy")).toBeInTheDocument(); + expect(screen.getByText("AEP-86 policy")).toBeInTheDocument(); + expect(screen.getByText("All named auditors")).toBeInTheDocument(); + }); + + it("shows current provider facts when the placement carries neither policy", () => { + setup({ policy: buildPolicy({}), verification: buildVerification() }); + + expect(screen.getByText("No verification requirement")).toBeInTheDocument(); + openDetails(); + + expect(screen.getByText("Auditor-attested tier")).toBeInTheDocument(); + }); + + it("renders nothing when neither a placement policy nor provider facts are available", () => { + const { container } = setup({ policy: buildPolicy({}), verification: null }); + + expect(container).toBeEmptyDOMElement(); + }); + + it("warns about a tier demotion without implying the lease closes", () => { + setup({ + policy: buildPolicy({ verification: buildRequirement({ minTier: "L3" }) }), + verification: buildVerification({ effectiveTier: "L1" }) + }); + + expect(screen.getByText("Provider tier is below policy")).toBeInTheDocument(); + openDetails(); + + expect(screen.getByText("The current L1 tier is below this placement's L3 policy. The lease remains open.")).toBeInTheDocument(); + }); + + it("shows discrepancy grace without treating it as a lease close", () => { + setup({ + policy: buildPolicy({ verification: buildRequirement({ minTier: "L2" }) }), + verification: buildVerification({ effectiveTier: "L2", reviewState: "grace" }) + }); + + expect(screen.getByText("Verification grace active")).toBeInTheDocument(); + openDetails(); + + expect(screen.getByText(/grace preserves the policy tier temporarily; the lease remains open/i)).toBeInTheDocument(); + }); + + it("shows an active maintenance window", () => { + setup({ + policy: buildPolicy({ verification: buildRequirement() }), + verification: buildVerification({ maintenanceState: "active", maintenanceStatus: "active" }) + }); + + expect(screen.getByText("Provider maintenance active")).toBeInTheDocument(); + openDetails(); + + expect(screen.getByText(/expected to end/i)).toBeInTheDocument(); + }); + + it("shows a scheduled maintenance window", () => { + setup({ + policy: buildPolicy({ verification: buildRequirement() }), + verification: buildVerification({ maintenanceState: "scheduled", maintenanceStatus: "scheduled" }) + }); + + expect(screen.getByText("L2 - Verified")).toBeInTheDocument(); + openDetails(); + + expect(screen.getByText("Provider maintenance scheduled")).toBeInTheDocument(); + expect(screen.getByText(/scheduled to start/i)).toBeInTheDocument(); + }); + + it("labels incomplete state as not fully evaluated", () => { + setup({ + policy: buildPolicy({ verification: buildRequirement() }), + verification: buildVerification({ complete: false, effectiveTier: null }) + }); + + expect(screen.getByText("Not fully evaluated")).toBeInTheDocument(); + openDetails(); + + expect(screen.getByText("Verification status incomplete")).toBeInTheDocument(); + expect(screen.getAllByText("Not evaluated").length).toBeGreaterThan(0); + }); + + function setup(input: { policy: PlacementSecurityPolicy; verification?: ProviderVerificationView | null }) { + return render(); + } + + function openDetails() { + fireEvent.click(screen.getByRole("button", { name: "View details" })); + expect(screen.getByRole("dialog", { name: "Provider verification · dcloud" })).toBeInTheDocument(); + } +}); + +function buildPolicy(input: { legacy?: boolean; verification?: PlacementVerificationPolicy }): PlacementSecurityPolicy { + return { + legacySignedBy: input.legacy ? { allOf: ["akash1legacy"], anyOf: [] } : null, + verification: input.verification ?? null + }; +} + +function buildRequirement(overrides: Partial = {}): PlacementVerificationPolicy { + return { + minTier: "L2", + requiredCapabilities: ["persistent_storage"], + requiredAuditors: ["akash1auditor"], + auditorMode: "any", + minAuditorCount: 1, + ...overrides + }; +} + +function buildVerification( + input: { + effectiveTier?: ProviderVerificationView["summary"]["effectiveTier"]; + reviewState?: ProviderVerificationView["summary"]["reviewState"]; + maintenanceState?: ProviderVerificationView["summary"]["maintenanceState"]; + maintenanceStatus?: "active" | "scheduled"; + complete?: boolean; + } = {} +): ProviderVerificationView { + const complete = input.complete ?? true; + const maintenanceStatus = input.maintenanceStatus; + + return mock({ + provider: "akash1provider", + moduleActive: true, + summary: { + bestAttestedTier: "L2", + effectiveTier: input.effectiveTier === undefined ? "L2" : input.effectiveTier, + capabilities: ["persistent_storage"], + validAttestationCount: 1, + validAuditorCount: 1, + validAuditors: ["akash1auditor"], + snapshotState: "current", + maintenanceState: input.maintenanceState ?? "none", + reviewState: input.reviewState ?? "none" + }, + maintenance: maintenanceStatus + ? [ + { + status: maintenanceStatus, + record: { + id: "1", + provider: "akash1provider", + maintenanceType: "planned", + startsAt: "2026-08-26T12:00:00.000Z", + expectedEndsAt: "2026-08-26T14:00:00.000Z", + openedAt: "2026-08-25T12:00:00.000Z", + closedAt: null, + metadataHash: null + } + } + ] + : [], + completeness: { + params: complete, + attestations: complete, + graces: complete, + snapshot: complete, + bond: complete, + auditEscrows: complete, + maintenance: complete, + discrepancies: complete + } + }); +} diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementVerificationPanel.tsx b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementVerificationPanel.tsx new file mode 100644 index 0000000000..7650a36ea4 --- /dev/null +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/PlacementVerificationPanel.tsx @@ -0,0 +1,298 @@ +import { type FC, type ReactNode, useId, useState } from "react"; +import { + Alert, + AlertDescription, + AlertTitle, + Button, + DialogV2, + DialogV2Body, + DialogV2Content, + DialogV2Description, + DialogV2Footer, + DialogV2Header, + DialogV2Title +} from "@akashnetwork/ui/components"; +import { InfoCircle, NavArrowRight, WarningTriangle } from "iconoir-react"; +import { ShieldCheck } from "lucide-react"; + +import type { ProviderVerificationCapability, ProviderVerificationTier, ProviderVerificationView } from "@src/types/provider"; +import { StatusBadge, type StatusTone } from "../DeploymentStatusBadge"; +import type { PlacementSecurityPolicy, PlacementVerificationPolicy } from "./placementVerificationModel"; +import { isTierBelow } from "./placementVerificationModel"; + +export interface PlacementVerificationPanelProps { + placementName: string; + policy: PlacementSecurityPolicy; + verification?: ProviderVerificationView | null; +} + +const TIER_LABELS: Record = { + L0: "L0 - Unverified", + L1: "L1 - Identified", + L2: "L2 - Verified", + L3: "L3 - Established", + L4: "L4 - Trusted", + unknown: "Not evaluated" +}; + +const CAPABILITY_LABELS: Record = { + unspecified: "Unspecified", + tee_hardware_attestation: "TEE hardware attestation", + confidential_computing: "Confidential computing", + persistent_storage: "Persistent storage", + bare_metal: "Bare metal", + unknown: "Unknown" +}; + +const SNAPSHOT_LABELS: Record = { + unknown: "Not evaluated", + not_posted: "Not posted", + current: "Current", + stale: "Stale", + suspended: "Suspended" +}; + +export const PlacementVerificationPanel: FC = ({ placementName, policy, verification }) => { + const headingId = useId(); + const descriptionId = useId(); + const [isOpen, setIsOpen] = useState(false); + if (!policy.legacySignedBy && !policy.verification && !verification) return null; + + const notices = buildNotices(policy.verification, verification); + const currentTier = verification?.summary.effectiveTier ?? null; + const capabilities = verification?.summary.capabilities; + const status = getCompactStatus(notices, currentTier); + + return ( +
    +
    +
    +
    + +
    + + +
    +
    + + + + + Provider verification · {placementName} + + Placement requirements and current provider verification facts for {placementName} + + + +
    +
    +

    Placement policy

    + {policy.legacySignedBy && } + {policy.verification && } + {!policy.legacySignedBy && !policy.verification &&

    No verification requirement

    } +
    + +
    +

    Current provider facts

    + + + + + + +
    +
    + + {notices.length > 0 && ( +
    + {notices.map(notice => ( + + {notice.tone === "warning" ? : } + {notice.title} + {notice.description} + + ))} +
    + )} +
    + + + +
    +
    +
    + ); +}; + +function formatPolicySummary(policy: PlacementSecurityPolicy): string { + const parts: string[] = []; + + if (policy.legacySignedBy) parts.push("Legacy auditor policy"); + if (policy.verification) { + parts.push(`Requires ${policy.verification.minTier}`); + parts.push(`${policy.verification.minAuditorCount} ${policy.verification.minAuditorCount === 1 ? "auditor" : "auditors"}`); + if (policy.verification.requiredCapabilities.length > 0) { + parts.push(formatCapabilities(policy.verification.requiredCapabilities)); + } + } + + return parts.length > 0 ? parts.join(" · ") : "No verification requirement"; +} + +function getCompactStatus(notices: VerificationNotice[], currentTier: ProviderVerificationTier | null): { label: string; tone: StatusTone } { + const warning = notices.find(notice => notice.tone === "warning"); + if (warning) return { label: warning.title, tone: "warning" }; + if (notices.some(notice => notice.key === "inactive")) return { label: "Verification inactive", tone: "pending" }; + if (notices.some(notice => notice.key === "incomplete")) return { label: "Not fully evaluated", tone: "loading" }; + if (!currentTier || currentTier === "unknown") return { label: "Not evaluated", tone: "loading" }; + + return { label: TIER_LABELS[currentTier], tone: getTierTone(currentTier) }; +} + +function getTierTone(tier: ProviderVerificationTier | null): StatusTone { + if (!tier || tier === "unknown") return "loading"; + return tier === "L0" ? "pending" : "running"; +} + +const LegacyPolicy: FC<{ policy: NonNullable }> = ({ policy }) => ( +
    +

    Legacy signedBy

    + {policy.allOf.length > 0 && } + {policy.anyOf.length > 0 && } +
    +); + +const VerificationPolicy: FC<{ policy: PlacementVerificationPolicy }> = ({ policy }) => ( +
    +

    AEP-86 policy

    + + + + {policy.requiredAuditors.length > 0 && ( + + )} +
    +); + +const Fact: FC<{ label: string; value?: string; children?: ReactNode }> = ({ label, value, children }) => ( +
    + {label} + {children ?? {value}} +
    +); + +const AddressPolicy: FC<{ label: string; addresses: string[] }> = ({ label, addresses }) => ( +
    +

    {label}

    +
      + {addresses.map(address => ( +
    • + {address} +
    • + ))} +
    +
    +); + +function formatCapabilities(capabilities: ProviderVerificationCapability[] | null | undefined): string { + if (capabilities === null || capabilities === undefined) return "Not evaluated"; + if (capabilities.length === 0) return "None"; + return capabilities.map(capability => CAPABILITY_LABELS[capability]).join(", "); +} + +interface VerificationNotice { + key: string; + title: string; + description: string; + tone: "default" | "warning"; +} + +function buildNotices(policy: PlacementVerificationPolicy | null, verification: ProviderVerificationView | null | undefined): VerificationNotice[] { + const notices: VerificationNotice[] = []; + const currentTier = verification?.summary.effectiveTier ?? null; + + if (policy && verification?.moduleActive === false) { + notices.push({ + key: "inactive", + title: "Provider verification is not active", + description: "This placement policy is recorded, but verification is not active on this network.", + tone: "default" + }); + } + + if ((policy || verification) && (!verification || !Object.values(verification.completeness).every(Boolean))) { + notices.push({ + key: "incomplete", + title: "Verification status incomplete", + description: "Current verification facts are still syncing and are not fully evaluated.", + tone: "default" + }); + } + + if (policy && isTierBelow(currentTier, policy.minTier)) { + notices.push({ + key: "demotion", + title: "Provider tier is below policy", + description: `The current ${currentTier} tier is below this placement's ${policy.minTier} policy. The lease remains open.`, + tone: "warning" + }); + } + + if (verification?.summary.reviewState === "grace") { + notices.push({ + key: "grace", + title: "Verification grace active", + description: "The provider's attested tier is under review. Grace preserves the policy tier temporarily; the lease remains open.", + tone: "warning" + }); + } else if (verification?.summary.reviewState === "under_review") { + notices.push({ + key: "review", + title: "Verification under review", + description: "A provider verification discrepancy is being reviewed. The lease remains open.", + tone: "warning" + }); + } + + if (verification?.summary.maintenanceState === "active") { + notices.push({ + key: "maintenance-active", + title: "Provider maintenance active", + description: formatMaintenanceDescription(verification, "active"), + tone: "warning" + }); + } else if (verification?.summary.maintenanceState === "scheduled") { + notices.push({ + key: "maintenance-scheduled", + title: "Provider maintenance scheduled", + description: formatMaintenanceDescription(verification, "scheduled"), + tone: "default" + }); + } + + return notices; +} + +function formatMaintenanceDescription(verification: ProviderVerificationView, status: "active" | "scheduled"): string { + const maintenance = verification.maintenance.find(item => item.status === status)?.record; + const timestamp = status === "active" ? maintenance?.expectedEndsAt : maintenance?.startsAt; + if (!timestamp) return status === "active" ? "The provider reports an active maintenance window." : "The provider reports an upcoming maintenance window."; + + const label = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(timestamp)); + return status === "active" ? `Expected to end ${label}.` : `Scheduled to start ${label}.`; +} diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/placementVerificationModel.spec.ts b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/placementVerificationModel.spec.ts new file mode 100644 index 0000000000..9f4f181db6 --- /dev/null +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/placementVerificationModel.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import type { DeploymentGroup } from "@src/types/deployment"; +import { getPlacementSecurityPolicy, isTierBelow } from "./placementVerificationModel"; + +describe("placementVerificationModel", () => { + it("normalizes the on-chain placement requirement without merging legacy signedBy", () => { + const result = getPlacementSecurityPolicy({ + group_spec: { + requirements: { + signed_by: { all_of: ["akash1legacy"], any_of: [] }, + attributes: [], + verification: { + min_tier: "verification_tier_established", + required_capabilities: ["capability_confidential_computing", "capability_bare_metal"], + required_auditors: ["akash1auditor"], + auditor_mode: "auditor_selection_mode_all", + min_auditor_count: 2 + } + } + } + } as unknown as DeploymentGroup); + + expect(result).toEqual({ + legacySignedBy: { allOf: ["akash1legacy"], anyOf: [] }, + verification: { + minTier: "L3", + requiredCapabilities: ["confidential_computing", "bare_metal"], + requiredAuditors: ["akash1auditor"], + auditorMode: "all", + minAuditorCount: 2 + } + }); + }); + + it("collapses empty legacy signedBy while retaining the verification policy", () => { + const result = getPlacementSecurityPolicy({ + group_spec: { + requirements: { + signed_by: { all_of: [], any_of: [] }, + attributes: [], + verification: { + min_tier: "verification_tier_identified", + required_capabilities: [], + required_auditors: [], + auditor_mode: "auditor_selection_mode_unspecified", + min_auditor_count: 0 + } + } + } + } as unknown as DeploymentGroup); + + expect(result.legacySignedBy).toBeNull(); + expect(result.verification).toEqual({ minTier: "L1", requiredCapabilities: [], requiredAuditors: [], auditorMode: "unknown", minAuditorCount: 0 }); + }); + + it("detects only comparable tier demotions", () => { + expect(isTierBelow("L1", "L2")).toBe(true); + expect(isTierBelow("L3", "L2")).toBe(false); + expect(isTierBelow(null, "L2")).toBe(false); + expect(isTierBelow("unknown", "L2")).toBe(false); + }); + + it("maps the proto default tier to L0", () => { + const result = getPlacementSecurityPolicy({ + group_spec: { + requirements: { + signed_by: { all_of: [], any_of: [] }, + attributes: [], + verification: { + min_tier: "verification_tier_unspecified", + required_capabilities: [], + required_auditors: [], + auditor_mode: "auditor_selection_mode_unspecified", + min_auditor_count: 0 + } + } + } + } as unknown as DeploymentGroup); + + expect(result.verification?.minTier).toBe("L0"); + }); +}); diff --git a/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/placementVerificationModel.ts b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/placementVerificationModel.ts new file mode 100644 index 0000000000..ab428f6a75 --- /dev/null +++ b/apps/deploy-web/src/components/deployments/DeploymentDetail/DeploymentPlacements/placementVerificationModel.ts @@ -0,0 +1,62 @@ +import type { DeploymentGroup, RpcVerificationCapability, RpcVerificationTier } from "@src/types/deployment"; +import type { ProviderVerificationCapability, ProviderVerificationTier } from "@src/types/provider"; + +export interface PlacementVerificationPolicy { + minTier: ProviderVerificationTier; + requiredCapabilities: ProviderVerificationCapability[]; + requiredAuditors: string[]; + auditorMode: "any" | "all" | "unknown"; + minAuditorCount: number; +} + +export interface PlacementSecurityPolicy { + legacySignedBy: { + allOf: string[]; + anyOf: string[]; + } | null; + verification: PlacementVerificationPolicy | null; +} + +const TIERS: Record = { + verification_tier_unspecified: "L0", + verification_tier_identified: "L1", + verification_tier_verified: "L2", + verification_tier_established: "L3", + verification_tier_trusted: "L4" +}; + +const CAPABILITIES: Record = { + capability_unspecified: "unspecified", + capability_tee_hardware_attestation: "tee_hardware_attestation", + capability_confidential_computing: "confidential_computing", + capability_persistent_storage: "persistent_storage", + capability_bare_metal: "bare_metal" +}; + +export function getPlacementSecurityPolicy(group: DeploymentGroup | undefined): PlacementSecurityPolicy { + const requirements = group?.group_spec?.requirements; + const signedBy = requirements?.signed_by; + const allOf = signedBy?.all_of ?? []; + const anyOf = signedBy?.any_of ?? []; + const verification = requirements?.verification; + + return { + legacySignedBy: allOf.length > 0 || anyOf.length > 0 ? { allOf, anyOf } : null, + verification: verification + ? { + minTier: TIERS[verification.min_tier] ?? "unknown", + requiredCapabilities: verification.required_capabilities.map(capability => CAPABILITIES[capability] ?? "unknown"), + requiredAuditors: verification.required_auditors, + auditorMode: + verification.auditor_mode === "auditor_selection_mode_all" ? "all" : verification.auditor_mode === "auditor_selection_mode_any" ? "any" : "unknown", + minAuditorCount: verification.min_auditor_count + } + : null + }; +} + +export function isTierBelow(current: ProviderVerificationTier | null, required: ProviderVerificationTier): boolean { + const rank = { L0: 0, L1: 1, L2: 2, L3: 3, L4: 4 } as const; + if (current === null || current === "unknown" || required === "unknown") return false; + return rank[current] < rank[required]; +} diff --git a/apps/deploy-web/src/components/providers/ProviderDetail.tsx b/apps/deploy-web/src/components/providers/ProviderDetail.tsx index ff4f474881..896fa15d82 100644 --- a/apps/deploy-web/src/components/providers/ProviderDetail.tsx +++ b/apps/deploy-web/src/components/providers/ProviderDetail.tsx @@ -11,6 +11,7 @@ import dynamic from "next/dynamic"; import { LabelValue } from "@src/components/shared/LabelValue"; import { useWallet } from "@src/context/WalletProvider"; +import { useFlag } from "@src/hooks/useFlag"; import { useAllLeases } from "@src/queries/useLeaseQuery"; import { useProviderAttributesSchema, useProviderDetail, useProviderStatus } from "@src/queries/useProvidersQuery"; import type { ApiProviderDetail, ClientProviderDetailWithStatus } from "@src/types/provider"; @@ -22,6 +23,7 @@ import { Title } from "../shared/Title"; import { ActiveLeasesGraph } from "./ActiveLeasesGraph"; import ProviderDetailLayout, { ProviderDetailTabs } from "./ProviderDetailLayout"; import { ProviderSpecs } from "./ProviderSpecs"; +import { ProviderVerificationDetails } from "./ProviderVerificationDetails"; const NetworkCapacity = dynamic(() => import("./NetworkCapacity/NetworkCapacity"), { ssr: false @@ -33,6 +35,7 @@ type Props = { }; export const ProviderDetail: React.FunctionComponent = ({ owner, _provider }) => { + const isProviderVerificationEnabled = useFlag("provider_verification"); const [provider, setProvider] = useState(_provider as ClientProviderDetailWithStatus); const { address } = useWallet(); const { @@ -174,6 +177,15 @@ export const ProviderDetail: React.FunctionComponent = ({ owner, _provide )} + {provider && isProviderVerificationEnabled && ( +
    + + Provider verification + + +
    + )} + {provider && providerAttributesSchema && ( <>
    @@ -197,7 +209,7 @@ export const ProviderDetail: React.FunctionComponent = ({ owner, _provide - +
    diff --git a/apps/deploy-web/src/components/providers/ProviderList.tsx b/apps/deploy-web/src/components/providers/ProviderList.tsx index df675b048f..c9b06ab029 100644 --- a/apps/deploy-web/src/components/providers/ProviderList.tsx +++ b/apps/deploy-web/src/components/providers/ProviderList.tsx @@ -21,6 +21,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useLocalNotes } from "@src/components/LocalNoteManager"; import { useWallet } from "@src/context/WalletProvider"; +import { useFlag } from "@src/hooks/useFlag"; import { useAllLeases } from "@src/queries/useLeaseQuery"; import { useNetworkCapacity, useProviderList } from "@src/queries/useProvidersQuery"; import networkStore from "@src/store/networkStore"; @@ -30,6 +31,7 @@ import { domainName, UrlService } from "@src/utils/urlUtils"; import Layout from "../layout/Layout"; import { CustomNextSeo } from "../shared/CustomNextSeo"; import { Title } from "../shared/Title"; +import { hasAuditOrAttestation } from "./providerListFilters"; import { ProviderMap } from "./ProviderMap"; import { ProviderTable } from "./ProviderTable"; @@ -49,6 +51,7 @@ const sortOptions: { id: SortId; title: string }[] = [ export const ProviderList: React.FunctionComponent = () => { const { address } = useWallet(); + const isProviderVerificationEnabled = useFlag("provider_verification"); const [pageIndex, setPageIndex] = useState(0); const [isFilteringActive, setIsFilteringActive] = useState(true); const [isFilteringFavorites, setIsFilteringFavorites] = useState(false); @@ -109,7 +112,7 @@ export const ProviderList: React.FunctionComponent = () => { } if (isFilteringAudited) { - filteredProviders = filteredProviders.filter(x => x.isAudited); + filteredProviders = filteredProviders.filter(x => hasAuditOrAttestation(x, isProviderVerificationEnabled)); } filteredProviders = filteredProviders.sort((a, b) => { @@ -132,7 +135,7 @@ export const ProviderList: React.FunctionComponent = () => { setFilteredProviders(filteredProviders); } - }, [providers, isFilteringActive, isFilteringFavorites, isFilteringAudited, favoriteProviders, search, sort, leases]); + }, [providers, isFilteringActive, isFilteringFavorites, isFilteringAudited, favoriteProviders, search, sort, leases, isProviderVerificationEnabled]); const refresh = () => { getProviders(); @@ -228,7 +231,11 @@ export const ProviderList: React.FunctionComponent = () => {
    - +
    diff --git a/apps/deploy-web/src/components/providers/ProviderSummary.tsx b/apps/deploy-web/src/components/providers/ProviderSummary.tsx index 10f02d73fb..51b1e9bc56 100644 --- a/apps/deploy-web/src/components/providers/ProviderSummary.tsx +++ b/apps/deploy-web/src/components/providers/ProviderSummary.tsx @@ -8,6 +8,7 @@ import { Uptime } from "@src/components/providers/Uptime"; import { FavoriteButton } from "@src/components/shared/FavoriteButton"; import { LabelValue } from "@src/components/shared/LabelValue"; import { StatusPill } from "@src/components/shared/StatusPill"; +import { useFlag } from "@src/hooks/useFlag"; import type { ApiProviderList, ClientProviderDetailWithStatus } from "@src/types/provider"; import { ProviderMap } from "./ProviderMap"; @@ -17,6 +18,7 @@ type Props = { export const ProviderSummary: React.FunctionComponent = ({ provider }) => { const { favoriteProviders, updateFavoriteProviders } = useLocalNotes(); + const isProviderVerificationEnabled = useFlag("provider_verification"); const isFavorite = favoriteProviders.some(x => provider.owner === x); const onStarClick: MouseEventHandler = event => { @@ -50,7 +52,7 @@ export const ProviderSummary: React.FunctionComponent = ({ provider }) => } /> diff --git a/apps/deploy-web/src/components/providers/ProviderTable.tsx b/apps/deploy-web/src/components/providers/ProviderTable.tsx index 550ad1cce7..0f2d897dc4 100644 --- a/apps/deploy-web/src/components/providers/ProviderTable.tsx +++ b/apps/deploy-web/src/components/providers/ProviderTable.tsx @@ -2,6 +2,7 @@ import { Table, TableBody, TableHead, TableHeader, TableRow } from "@akashnetwork/ui/components"; import { cn } from "@akashnetwork/ui/utils"; +import { useFlag } from "@src/hooks/useFlag"; import type { ClientProviderList } from "@src/types/provider"; import { ProviderListRow } from "./ProviderTableRow"; @@ -11,6 +12,7 @@ type Props = { }; export const ProviderTable: React.FunctionComponent = ({ providers, sortOption }) => { + const isProviderVerificationEnabled = useFlag("provider_verification"); const isSortingLeases = sortOption === "active-leases-desc" || sortOption === "active-leases-asc" || sortOption === "my-leases-desc" || sortOption === "my-active-leases-desc"; @@ -19,6 +21,7 @@ export const ProviderTable: React.FunctionComponent = ({ providers, sortO Name + {isProviderVerificationEnabled && Verification} Location Uptime (7d) Active Leases @@ -26,14 +29,14 @@ export const ProviderTable: React.FunctionComponent = ({ providers, sortO GPU Memory Disk - Audited + {isProviderVerificationEnabled ? "Legacy audit" : "Audited"} Favorite {providers.map(provider => { - return ; + return ; })} diff --git a/apps/deploy-web/src/components/providers/ProviderTableRow.tsx b/apps/deploy-web/src/components/providers/ProviderTableRow.tsx index 656b2d8d1f..a13511d75a 100644 --- a/apps/deploy-web/src/components/providers/ProviderTableRow.tsx +++ b/apps/deploy-web/src/components/providers/ProviderTableRow.tsx @@ -17,13 +17,15 @@ import { UrlService } from "@src/utils/urlUtils"; import { FavoriteButton } from "../shared/FavoriteButton"; import { AuditorButton } from "./AuditorButton"; import { CapacityIcon } from "./CapacityIcon"; +import { ProviderVerificationListCell } from "./ProviderVerificationListCell"; import { Uptime } from "./Uptime"; type Props = { provider: ClientProviderList; + showVerification: boolean; }; -export const ProviderListRow: React.FunctionComponent = ({ provider }) => { +export const ProviderListRow: React.FunctionComponent = ({ provider, showVerification }) => { const router = useRouter(); const { favoriteProviders, updateFavoriteProviders } = useLocalNotes(); const isFavorite = favoriteProviders.some(x => provider.owner === x); @@ -86,6 +88,11 @@ export const ProviderListRow: React.FunctionComponent = ({ provider }) => )} )} + {showVerification && ( + + + + )} {provider.ipRegion && provider.ipCountry && ( = { + unspecified: "Unspecified", + tee_hardware_attestation: "TEE hardware attestation", + confidential_computing: "Confidential computing", + persistent_storage: "Persistent storage", + bare_metal: "Bare metal", + unknown: "Unknown" +}; + +type Props = { + providerDeclaredTier: string | null; + verification: ProviderVerificationView | null; +}; + +export const ProviderVerificationDetails: React.FunctionComponent = ({ providerDeclaredTier, verification }) => { + if (!verification) { + return ( + + +

    Provider verification has not been evaluated.

    +

    No indexed AEP-86 state is available for this provider.

    +
    +
    + ); + } + + const openDiscrepancies = verification.discrepancies.filter(discrepancy => ["pending", "timed_out"].includes(discrepancy.resolutionStatus)); + const activeMaintenance = verification.maintenance.filter(item => ["scheduled", "active"].includes(item.status)); + + return ( + + + {verification.moduleActive === false && ( + + The verification module is inactive. These records are visible, but verification placement requirements are not enforced. + + )} + + {(verification.summary.reviewState === "under_review" || verification.summary.reviewState === "grace") && ( + + {verification.summary.reviewState === "under_review" + ? "Conflicting auditor attestations are under governance review." + : `Verification grace is active${verification.grace ? ` at ${verification.grace.preservedTier}` : ""}.`} + + )} + + {activeMaintenance.length > 0 && ( + + This provider has {activeMaintenance.some(item => item.status === "active") ? "active" : "scheduled"} maintenance. + + )} + +
    + + + + + +
    + +
    +
    +
    +

    + Auditor-attested capabilities +

    +

    Current capabilities from valid attestation records.

    +
    + +
    +
    + {verification.summary.capabilities === null ? ( + Not evaluated + ) : verification.summary.capabilities.length === 0 ? ( + No capabilities attested + ) : ( + verification.summary.capabilities.map(capability => ( + + {CAPABILITY_LABELS[capability]} + + )) + )} +
    +
    + +
    + + +
    + + +
    + + + + Auditor + Tier and capabilities + Status + Fee + Auditor deposit + Created and expires + Escrow + Evidence hash + + + + {!verification.completeness.attestations ? ( + Not evaluated + ) : verification.attestations.length === 0 ? ( + No current attestations + ) : ( + verification.attestations.map(attestation => ( + + {attestation.auditor} + + +
    + {attestation.capabilities.length > 0 ? attestation.capabilities.map(value => CAPABILITY_LABELS[value]).join(", ") : "No capabilities"} +
    +
    + + +
    Reason: {humanize(attestation.voidedReason)}
    +
    Fault: {humanize(attestation.faultAttribution)}
    +
    + +
    {formatCoin(attestation.fee)}
    +
    {humanize(attestation.feeStatus)}
    +
    + +
    {formatCoin(attestation.deposit)}
    +
    {humanize(attestation.depositStatus)}
    +
    + +
    {formatTimestamp(attestation.createdAt)}
    +
    Expires {formatTimestamp(attestation.expiresAt)}
    +
    + #{attestation.auditEscrowId} + {attestation.evidenceHash || "Not recorded"} +
    + )) + )} +
    +
    +
    +
    + + +
    + + + + ID + Requested tier + Auditor + Fee + Provider deposit + Status + Timing + Settlement + + + + {!verification.completeness.auditEscrows ? ( + Not evaluated + ) : verification.auditEscrows.length === 0 ? ( + No audit escrows + ) : ( + verification.auditEscrows.map(escrow => ( + + #{escrow.id} + + + + {escrow.consumedByAuditor || "Awaiting auditor"} + +
    {formatCoin(escrow.fee)}
    +
    {humanize(escrow.feeStatus)}
    +
    + +
    {formatCoin(escrow.providerDeposit)}
    +
    {humanize(escrow.providerDepositStatus)}
    +
    + + + + +
    Opened {formatTimestamp(escrow.openedAt)}
    +
    Consumed {formatTimestamp(escrow.consumedAt)}
    +
    Expires {formatTimestamp(escrow.expiresAt)}
    +
    + +
    {humanize(escrow.settlementReason)}
    +
    Fault: {humanize(escrow.faultAttribution)}
    +
    +
    + )) + )} +
    +
    +
    +
    + + +
    + + + + ID + Type + Status + Starts + Expected end + Closed + + + + {!verification.completeness.maintenance ? ( + Not evaluated + ) : verification.maintenance.length === 0 ? ( + No maintenance windows + ) : ( + verification.maintenance.map((maintenance, index) => ( + + {maintenance.record ? `#${maintenance.record.id}` : "Not recorded"} + {maintenance.record ? humanize(maintenance.record.maintenanceType) : "Not evaluated"} + + + + {formatTimestamp(maintenance.record?.startsAt ?? null)} + {formatTimestamp(maintenance.record?.expectedEndsAt ?? null)} + {formatTimestamp(maintenance.record?.closedAt ?? null)} + + )) + )} + +
    +
    +
    + + + {verification.grace ? ( +
    + + + + + + 0 ? verification.grace.sourceDiscrepancyIds.map(id => `#${id}`).join(", ") : "None"} + /> +
    + ) : !verification.completeness.graces ? ( +
    + Grace not evaluated +
    + ) : null} + +
    + + + + ID + Auditor A + Auditor B + Status + Proposal + Resolution + Observed + + + + {!verification.completeness.discrepancies ? ( + Not evaluated + ) : openDiscrepancies.length === 0 ? ( + No open discrepancies + ) : ( + openDiscrepancies.map(discrepancy => ( + + #{discrepancy.id} + +
    {discrepancy.auditorA}
    + +
    + +
    {discrepancy.auditorB}
    + +
    + + + + + {discrepancy.resolutionProposalId === "0" ? "Not submitted" : `#${discrepancy.resolutionProposalId}`} + + +
    {humanize(discrepancy.resolutionReason)}
    +
    Fault: {humanize(discrepancy.faultAttribution)}
    + {discrepancy.resolutionEvidenceHash && ( +
    {discrepancy.resolutionEvidenceHash}
    + )} +
    + {formatTimestamp(discrepancy.timestamp)} +
    + )) + )} +
    +
    +
    +
    +
    +
    + ); +}; + +function SnapshotSection({ verification }: { verification: ProviderVerificationView }) { + const snapshot = verification.snapshot; + + return ( +
    +
    +
    + {!verification.completeness.snapshot ? ( + Not evaluated + ) : !snapshot ? ( + Not posted + ) : ( +
    +
    + + + + +
    + {snapshot.resourceSummary && ( +
    + + + + + + +
    + )} + + {snapshot.resourceSummary?.softwareSignature && } + {snapshot.resourceSummary?.softwareIdentity && ( +
    + + + + +
    + )} +
    + )} +
    + ); +} + +function BondSection({ verification }: { verification: ProviderVerificationView }) { + const bond = verification.bond; + + return ( +
    +
    +
    + {!verification.completeness.bond ? ( + Not evaluated + ) : !bond ? ( + No provider bond posted + ) : ( +
    +
    + + + + + +
    + {bond.unbondingEntries.length > 0 && ( +
    + {bond.unbondingEntries.map((entry, index) => ( +
    + {formatCoin(entry.amount)} + Completes {formatTimestamp(entry.completionTime)} +
    + ))} +
    + )} +
    + )} +
    + ); +} + +function DataSection({ title, description, children }: { title: string; description: string; children: React.ReactNode }) { + return ( +
    +
    +

    {title}

    +

    {description}

    +
    + {children} +
    + ); +} + +function SummaryItem({ icon: Icon, label, value }: { icon: typeof ShieldCheck; label: string; value: string }) { + return ( +
    +
    +
    +

    {value}

    +
    + ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
    +

    {label}

    +

    {value}

    +
    + ); +} + +function HashValue({ label, value }: { label: string; value: string | null }) { + return ( +
    +

    {label}

    +

    {value || "Not recorded"}

    +
    + ); +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return

    {children}

    ; +} + +function EmptyTableRow({ columns, children }: { columns: number; children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +function ObservedState({ verification }: { verification: ProviderVerificationView }) { + return ( + + Indexed at height {verification.observedHeight} · {formatTimestamp(verification.observedAt)} + + ); +} + +function TierBadge({ tier }: { tier: ProviderVerificationTier }) { + return ( + + {tier === "unknown" ? "Not evaluated" : tier} + + ); +} + +function StateBadge({ value, positive }: { value: string; positive?: string }) { + const isPositive = value === positive; + const isNegative = ["expired", "voided", "revoked", "removed", "cancelled", "slashed", "timed_out"].includes(value); + + return ( + + {humanize(value)} + + ); +} + +function formatOptionalInteger(value: number | null): string { + return value === null ? "Not evaluated" : String(value); +} + +function formatCoin(value: ProviderVerificationCoin | null): string { + return value ? `${formatInteger(value.amount)} ${value.denom}` : "Not recorded"; +} + +function formatInteger(value: string): string { + try { + return new Intl.NumberFormat("en-US").format(BigInt(value)); + } catch { + return value; + } +} + +function formatTimestamp(value: string | null): string { + if (!value) return "Not recorded"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Not recorded"; + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + timeZoneName: "short" + }).format(date); +} + +function reviewLabel(value: ProviderVerificationView["summary"]["reviewState"]): string { + if (value === "under_review") return "Under review"; + if (value === "grace") return "Grace active"; + return stateLabel(value); +} + +function stateLabel(value: string): string { + if (value === "unknown") return "Not evaluated"; + if (value === "not_posted") return "Not posted"; + return humanize(value); +} + +function humanize(value: string): string { + const text = value.replaceAll("_", " "); + return text.charAt(0).toUpperCase() + text.slice(1); +} diff --git a/apps/deploy-web/src/components/providers/ProviderVerificationListCell.spec.tsx b/apps/deploy-web/src/components/providers/ProviderVerificationListCell.spec.tsx new file mode 100644 index 0000000000..718701602c --- /dev/null +++ b/apps/deploy-web/src/components/providers/ProviderVerificationListCell.spec.tsx @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; + +import type { ProviderVerificationView } from "@src/types/provider"; +import { ProviderVerificationDetails } from "./ProviderVerificationDetails"; +import { ProviderVerificationListCell } from "./ProviderVerificationListCell"; + +import { render, screen } from "@testing-library/react"; + +describe(ProviderVerificationListCell.name, () => { + it("shows the complete compact verification state", () => { + render(); + + expect(screen.getByText("L3")).toBeInTheDocument(); + expect(screen.getByText("2 auditors")).toBeInTheDocument(); + expect(screen.getByText("Persistent storage")).toBeInTheDocument(); + expect(screen.getByText("Bare metal")).toBeInTheDocument(); + expect(screen.getByLabelText("Provider-signed inventory: Current")).toBeInTheDocument(); + expect(screen.getByLabelText("Maintenance: Active")).toBeInTheDocument(); + expect(screen.getByLabelText("Discrepancy review: Grace active")).toBeInTheDocument(); + }); + + it("does not infer a pass from missing indexed state", () => { + render(); + + expect(screen.getByText("Not evaluated")).toBeInTheDocument(); + }); +}); + +describe(ProviderVerificationDetails.name, () => { + it("renders the normalized provider verification lifecycle with provenance", () => { + render(); + + expect(screen.getByText("Auditor-attested capabilities")).toBeInTheDocument(); + expect(screen.getByText("Latest provider-signed inventory")).toBeInTheDocument(); + expect(screen.getByText("Attestation records")).toBeInTheDocument(); + expect(screen.getByText("Audit escrow lifecycle")).toBeInTheDocument(); + expect(screen.getByText("Maintenance windows")).toBeInTheDocument(); + expect(screen.getByText("Open discrepancies and grace")).toBeInTheDocument(); + expect(screen.getByText("community")).toBeInTheDocument(); + expect(screen.getAllByText("L3").length).toBeGreaterThan(0); + expect(screen.getAllByText("akash1auditoralpha").length).toBeGreaterThan(0); + expect(screen.getAllByText("100,000,000 uakt").length).toBeGreaterThan(0); + expect(screen.getAllByText("#23").length).toBeGreaterThan(0); + }); + + it("labels incomplete checks as not evaluated", () => { + const verification = buildVerification(); + verification.summary.effectiveTier = null; + verification.summary.capabilities = null; + verification.summary.validAuditorCount = null; + verification.summary.snapshotState = "unknown"; + verification.snapshot = null; + verification.bond = null; + verification.attestations = []; + verification.auditEscrows = []; + verification.maintenance = []; + verification.discrepancies = []; + verification.grace = null; + verification.completeness = { + params: true, + attestations: false, + graces: false, + snapshot: false, + bond: false, + auditEscrows: false, + maintenance: false, + discrepancies: false + }; + + render(); + + expect(screen.getAllByText("Not evaluated").length).toBeGreaterThanOrEqual(6); + expect(screen.queryByText("Passed")).not.toBeInTheDocument(); + }); +}); + +function buildVerification(): ProviderVerificationView { + return { + provider: "akash1provider", + providerDeclaredTier: "community", + moduleActive: true, + provenance: { + providerTier: "provider self-declared", + inventory: "provider-signed inventory", + attestations: "auditor-attested" + }, + summary: { + bestAttestedTier: "L3", + effectiveTier: "L3", + capabilities: ["persistent_storage", "bare_metal"], + validAttestationCount: 2, + validAuditorCount: 2, + validAuditors: ["akash1auditoralpha", "akash1auditorbeta"], + snapshotState: "current", + maintenanceState: "active", + reviewState: "grace" + }, + attestations: [ + { + provider: "akash1provider", + auditor: "akash1auditoralpha", + tier: "L3", + capabilities: ["persistent_storage"], + evidenceHash: "ZXZpZGVuY2U=", + fee: { denom: "uakt", amount: "10000000" }, + feeStatus: "escrowed", + createdAt: "2026-08-23T12:00:00.000Z", + expiresAt: "2027-08-23T12:00:00.000Z", + status: "valid", + voidedReason: "unspecified", + deposit: { denom: "uakt", amount: "100000000" }, + depositStatus: "escrowed", + auditEscrowId: "23", + faultAttribution: "unspecified" + } + ], + bond: { + provider: "akash1provider", + bondedAmount: { denom: "uakt", amount: "1000000000" }, + requiredForCurrentTier: { denom: "uakt", amount: "1000000000" }, + unbondingEntries: [], + slashed: false, + lastSlashTime: null + }, + snapshot: { + provider: "akash1provider", + snapshotHash: "c25hcHNob3Q=", + resourceSummary: { + totalGpus: 1, + totalVcpus: 16, + totalMemoryMb: "65536", + totalStorageMb: "1048576", + activeLeases: 4, + softwareVersion: "v0.16.0-a4", + softwareSignature: "c2lnbmF0dXJl", + softwareIdentity: null + }, + postedAt: "2026-08-24T10:00:00.000Z", + snapshotTimestamp: "2026-08-24T09:59:00.000Z", + complianceDeadline: "2026-08-24T11:00:00.000Z", + suspended: false + }, + grace: { + id: "31", + provider: "akash1provider", + preservedTier: "L3", + sourceDiscrepancyIds: ["8"], + startedAt: "2026-08-24T08:00:00.000Z", + expiresAt: "2026-08-25T08:00:00.000Z", + status: "active" + }, + auditEscrows: [ + { + id: "23", + provider: "akash1provider", + consumedByAuditor: "akash1auditoralpha", + requestedTier: "L3", + requestedCapabilities: ["persistent_storage"], + fee: { denom: "uakt", amount: "10000000" }, + feeStatus: "escrowed", + providerDeposit: { denom: "uakt", amount: "100000000" }, + providerDepositStatus: "escrowed", + status: "consumed", + openedAt: "2026-08-23T10:00:00.000Z", + consumedAt: "2026-08-23T12:00:00.000Z", + expiresAt: "2026-08-25T10:00:00.000Z", + metadataHash: null, + settlementReason: "unspecified", + faultAttribution: "unspecified" + } + ], + maintenance: [ + { + record: { + id: "4", + provider: "akash1provider", + maintenanceType: "planned", + startsAt: "2026-08-24T10:00:00.000Z", + expectedEndsAt: "2026-08-24T12:00:00.000Z", + openedAt: "2026-08-23T10:00:00.000Z", + closedAt: null, + metadataHash: null + }, + status: "active" + } + ], + discrepancies: [ + { + id: "8", + provider: "akash1provider", + auditorA: "akash1auditoralpha", + auditorATier: "L3", + auditorB: "akash1auditorbeta", + auditorBTier: "L1", + timestamp: "2026-08-24T08:00:00.000Z", + resolutionStatus: "pending", + resolutionProposalId: "0", + graceRecordId: "31", + resolutionReason: "unspecified", + faultAttribution: "unspecified", + resolutionEvidenceHash: null + } + ], + observedAt: "2026-08-24T10:05:00.000Z", + observedHeight: "1020781", + completeness: { + params: true, + attestations: true, + graces: true, + snapshot: true, + bond: true, + auditEscrows: true, + maintenance: true, + discrepancies: true + } + }; +} diff --git a/apps/deploy-web/src/components/providers/ProviderVerificationListCell.tsx b/apps/deploy-web/src/components/providers/ProviderVerificationListCell.tsx new file mode 100644 index 0000000000..cd01931957 --- /dev/null +++ b/apps/deploy-web/src/components/providers/ProviderVerificationListCell.tsx @@ -0,0 +1,107 @@ +"use client"; +import { Badge } from "@akashnetwork/ui/components"; +import { Database, Scale, Users, Wrench } from "lucide-react"; + +import type { ProviderVerificationCapability, ProviderVerificationListView } from "@src/types/provider"; + +const CAPABILITY_LABELS: Record = { + unspecified: "Unspecified", + tee_hardware_attestation: "TEE hardware", + confidential_computing: "Confidential compute", + persistent_storage: "Persistent storage", + bare_metal: "Bare metal", + unknown: "Unknown" +}; + +type Props = { + verification: ProviderVerificationListView | null; +}; + +export const ProviderVerificationListCell: React.FunctionComponent = ({ verification }) => { + if (!verification) { + return Not evaluated; + } + + const { summary } = verification; + const tier = summary.effectiveTier; + const capabilities = summary.capabilities; + + return ( +
    +
    + + {tier ?? "Not evaluated"} + + + +
    + +
    + {capabilities === null ? ( + Capabilities not evaluated + ) : capabilities.length === 0 ? ( + No attested capabilities + ) : ( + <> + {capabilities.slice(0, 2).map(capability => ( + + {CAPABILITY_LABELS[capability]} + + ))} + {capabilities.length > 2 && ( + CAPABILITY_LABELS[value]) + .join(", ")} + > + +{capabilities.length - 2} + + )} + + )} +
    + +
    + + + +
    +
    + ); +}; + +function CompactState({ icon: Icon, label, value }: { icon: typeof Database; label: string; value: string }) { + return ( + + + ); +} + +function formatAuditorCount(value: number | null): string { + if (value === null) return "Not evaluated"; + return `${value} auditor${value === 1 ? "" : "s"}`; +} + +function reviewLabel(value: ProviderVerificationListView["summary"]["reviewState"]): string { + if (value === "under_review") return "Under review"; + if (value === "grace") return "Grace active"; + return stateLabel(value); +} + +function stateLabel(value: string): string { + if (value === "unknown") return "Not evaluated"; + if (value === "not_posted") return "Not posted"; + return value.charAt(0).toUpperCase() + value.slice(1).replaceAll("_", " "); +} diff --git a/apps/deploy-web/src/components/providers/providerListFilters.spec.ts b/apps/deploy-web/src/components/providers/providerListFilters.spec.ts new file mode 100644 index 0000000000..54820bd47e --- /dev/null +++ b/apps/deploy-web/src/components/providers/providerListFilters.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import type { ClientProviderList } from "@src/types/provider"; +import { hasAuditOrAttestation } from "./providerListFilters"; + +describe(hasAuditOrAttestation.name, () => { + it.each([ + { legacyAudited: true, verificationEnabled: false, validAuditorCount: null, expected: true }, + { legacyAudited: false, verificationEnabled: true, validAuditorCount: 1, expected: true }, + { legacyAudited: false, verificationEnabled: true, validAuditorCount: 0, expected: false }, + { legacyAudited: false, verificationEnabled: true, validAuditorCount: null, expected: false }, + { legacyAudited: false, verificationEnabled: false, validAuditorCount: 1, expected: false } + ])( + "returns $expected for legacy=$legacyAudited enabled=$verificationEnabled auditors=$validAuditorCount", + ({ legacyAudited, verificationEnabled, validAuditorCount, expected }) => { + const provider = { + isAudited: legacyAudited, + verification: + validAuditorCount === null + ? null + : { + summary: { validAuditorCount } + } + } as ClientProviderList; + + expect(hasAuditOrAttestation(provider, verificationEnabled)).toBe(expected); + } + ); +}); diff --git a/apps/deploy-web/src/components/providers/providerListFilters.ts b/apps/deploy-web/src/components/providers/providerListFilters.ts new file mode 100644 index 0000000000..169cb02241 --- /dev/null +++ b/apps/deploy-web/src/components/providers/providerListFilters.ts @@ -0,0 +1,7 @@ +import type { ClientProviderList } from "@src/types/provider"; + +type AuditedProvider = Pick; + +export function hasAuditOrAttestation(provider: AuditedProvider, isProviderVerificationEnabled: boolean): boolean { + return provider.isAudited || (isProviderVerificationEnabled && (provider.verification?.summary.validAuditorCount ?? 0) > 0); +} diff --git a/apps/deploy-web/src/components/sdl/PlacementFormModal.tsx b/apps/deploy-web/src/components/sdl/PlacementFormModal.tsx index b0b187be89..80605b00e0 100644 --- a/apps/deploy-web/src/components/sdl/PlacementFormModal.tsx +++ b/apps/deploy-web/src/components/sdl/PlacementFormModal.tsx @@ -9,6 +9,7 @@ import { InfoCircle } from "iconoir-react"; import { UAKT_DENOM } from "@src/config/denom.config"; import { useSupportedDenoms } from "@src/hooks/useDenom"; +import { useFlag } from "@src/hooks/useFlag"; import type { PlacementType, SdlBuilderFormValuesType, ServiceType } from "@src/types"; import { udenomToDenom } from "@src/utils/mathHelpers"; import { getAvgCostPerMonth, toReadableDenom, uaktToAKT } from "@src/utils/priceUtils"; @@ -17,6 +18,8 @@ import { USDLabel } from "../shared/UsdLabel"; import type { AttributesRefType } from "./AttributesFormControl"; import { AttributesFormControl } from "./AttributesFormControl"; import { FormPaper } from "./FormPaper"; +import type { PlacementVerificationRefType } from "./PlacementVerificationFormControl"; +import { PlacementVerificationFormControl } from "./PlacementVerificationFormControl"; import type { SignedByRefType } from "./SignedByFormControl"; import { SignedByFormControl } from "./SignedByFormControl"; @@ -32,6 +35,8 @@ type Props = { export const PlacementFormModal: React.FunctionComponent = ({ control, services, serviceIndex, onClose, placement: _placement }) => { const signedByRef = useRef(null); const attritubesRef = useRef(null); + const verificationRef = useRef(null); + const isProviderVerificationEnabled = useFlag("provider_verification"); const supportedSdlDenoms = useSupportedDenoms(); const currentService = services[serviceIndex]; const placementIndex = usePlacementIndexForService(control, serviceIndex); @@ -41,6 +46,7 @@ export const PlacementFormModal: React.FunctionComponent = ({ control, se const attributesToRemove: number[] = []; const signedByAnyToRemove: number[] = []; const signedByAllToRemove: number[] = []; + const verificationAuditorsToRemove: number[] = []; _placement.attributes?.forEach((e, i) => { if (!e.key.trim() || !e.value?.trim()) { @@ -60,9 +66,16 @@ export const PlacementFormModal: React.FunctionComponent = ({ control, se } }); + _placement.verification?.auditors?.forEach((auditor, index) => { + if (!auditor.value.trim()) { + verificationAuditorsToRemove.push(index); + } + }); + attritubesRef.current?._removeAttribute(attributesToRemove); signedByRef.current?._removeSignedByAnyOf(signedByAnyToRemove); signedByRef.current?._removeSignedByAllOf(signedByAllToRemove); + verificationRef.current?.removeAuditors(verificationAuditorsToRemove); onClose(); }; @@ -176,6 +189,8 @@ export const PlacementFormModal: React.FunctionComponent = ({ control, se
    + + {isProviderVerificationEnabled && } diff --git a/apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.spec.tsx b/apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.spec.tsx new file mode 100644 index 0000000000..615bb5f92f --- /dev/null +++ b/apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.spec.tsx @@ -0,0 +1,196 @@ +import type { RefObject } from "react"; +import { createRef } from "react"; +import type { UseFormReturn } from "react-hook-form"; +import { FormProvider, useForm } from "react-hook-form"; +import { TooltipProvider } from "@akashnetwork/ui/components"; +import { describe, expect, it } from "vitest"; + +import type { PlacementVerificationType, SdlBuilderFormValuesType } from "@src/types"; +import { defaultServiceWithPlacement } from "@src/utils/sdl/data"; +import type { PlacementVerificationRefType } from "./PlacementVerificationFormControl"; +import { PlacementVerificationFormControl } from "./PlacementVerificationFormControl"; + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +describe(PlacementVerificationFormControl.name, () => { + it("adds and removes the optional verification requirement", async () => { + const user = userEvent.setup(); + const { form } = setup(); + const toggle = screen.getByRole("switch", { name: "Require provider verification" }); + + expect(toggle).not.toBeChecked(); + expect(screen.queryByRole("combobox", { name: "Minimum verification tier" })).not.toBeInTheDocument(); + expect(screen.getByText("Any provider may bid")).toBeInTheDocument(); + expect(screen.getByText("L1 - Identified")).toBeInTheDocument(); + expect(screen.getByText("L4 - Trusted")).toBeInTheDocument(); + + await user.click(toggle); + + expect(form().getValues("placements.0.verification")).toEqual({ + minTier: 1, + capabilities: [], + auditors: [] + }); + expect(screen.getByRole("combobox", { name: "Minimum verification tier" })).toHaveTextContent("L1 - Identified"); + expect(screen.getByText("Operator identity verified")).toBeInTheDocument(); + + await user.click(toggle); + + expect(form().getValues("placements.0.verification")).toBeUndefined(); + expect(screen.queryByRole("combobox", { name: "Minimum verification tier" })).not.toBeInTheDocument(); + expect(screen.getByText("Any provider may bid")).toBeInTheDocument(); + }); + + it("updates the tier and its concise meaning", () => { + const { form } = setup({ verification: buildVerification() }); + + fireEvent.click(screen.getByRole("combobox", { name: "Minimum verification tier" })); + fireEvent.click(screen.getByRole("option", { name: "L3 - Established" })); + + expect(form().getValues("placements.0.verification.minTier")).toBe(3); + expect(screen.getByText("Sustained reliability checked")).toBeInTheDocument(); + }); + + it("writes canonical capabilities without duplicates", async () => { + const user = userEvent.setup(); + const { form } = setup({ verification: buildVerification() }); + const persistentStorage = screen.getByRole("checkbox", { name: "Persistent storage" }); + const bareMetal = screen.getByRole("checkbox", { name: "Bare metal" }); + + await user.click(persistentStorage); + await user.click(bareMetal); + + expect(form().getValues("placements.0.verification.capabilities")).toEqual(["persistent_storage", "bare_metal"]); + + await user.click(persistentStorage); + + expect(form().getValues("placements.0.verification.capabilities")).toEqual(["bare_metal"]); + }); + + it("updates the minimum auditor count", async () => { + const user = userEvent.setup(); + const { form } = setup({ verification: buildVerification() }); + const input = screen.getByRole("spinbutton", { name: "Minimum auditors" }); + + await user.clear(input); + await user.type(input, "2"); + + expect(form().getValues("placements.0.verification.minAuditorCount")).toBe(2); + }); + + it("manages named auditors and their any/all policy", async () => { + const user = userEvent.setup(); + const { form } = setup({ verification: buildVerification() }); + + await user.click(screen.getByRole("button", { name: "Add auditor" })); + await user.type(screen.getByRole("textbox", { name: "Auditor 1" }), "akash1auditor"); + fireEvent.click(screen.getByRole("combobox", { name: "Named auditor policy" })); + fireEvent.click(screen.getByRole("option", { name: "All listed auditors" })); + + expect(form().getValues("placements.0.verification.auditors.0.value")).toBe("akash1auditor"); + expect(form().getValues("placements.0.verification.auditorMode")).toBe("all"); + + await user.click(screen.getByRole("button", { name: "Remove auditor 1" })); + + expect(form().getValues("placements.0.verification.auditors")).toEqual([]); + expect(form().getValues("placements.0.verification.auditorMode")).toBeUndefined(); + expect(screen.queryByRole("combobox", { name: "Named auditor policy" })).not.toBeInTheDocument(); + }); + + it("renders imported requirements without changing them on mount", () => { + const verification = buildVerification({ + minTier: 4, + capabilities: ["tee_hardware_attestation", "confidential_computing"], + auditors: [{ id: "auditor-1", value: "akash1trusted" }], + auditorMode: "all", + minAuditorCount: 2 + }); + const { form } = setup({ verification }); + + expect(screen.getByRole("switch", { name: "Require provider verification" })).toBeChecked(); + expect(screen.getByRole("combobox", { name: "Minimum verification tier" })).toHaveTextContent("L4 - Trusted"); + expect(screen.getByRole("checkbox", { name: "TEE hardware attestation" })).toBeChecked(); + expect(screen.getByRole("textbox", { name: "Auditor 1" })).toHaveValue("akash1trusted"); + expect(form().getValues("placements.0.verification")).toEqual(verification); + }); + + it("leaves legacy signedBy untouched when verification is disabled", async () => { + const user = userEvent.setup(); + const { form } = setup({ verification: buildVerification(), signedBy: "akash1legacy" }); + + await user.click(screen.getByRole("switch", { name: "Require provider verification" })); + + expect(form().getValues("placements.0.verification")).toBeUndefined(); + expect(form().getValues("placements.0.signedBy")).toEqual({ + anyOf: [{ id: "legacy", value: "akash1legacy" }], + allOf: [] + }); + }); + + it("exposes the same blank-row pruning operation as the placement editor's other arrays", () => { + const ref = createRef(); + const { form } = setup({ + verification: buildVerification({ + auditors: [ + { id: "blank", value: "" }, + { id: "kept", value: "akash1kept" } + ] + }), + verificationRef: ref + }); + + act(() => ref.current?.removeAuditors([0])); + + expect(form().getValues("placements.0.verification.auditors")).toEqual([{ id: "kept", value: "akash1kept" }]); + }); +}); + +function buildVerification(overrides: Partial = {}): PlacementVerificationType { + return { + minTier: 1, + capabilities: [], + auditors: [], + auditorMode: "any", + minAuditorCount: 0, + ...overrides + }; +} + +function setup({ + verification, + signedBy, + verificationRef +}: { + verification?: PlacementVerificationType; + signedBy?: string; + verificationRef?: RefObject; +} = {}) { + const values = defaultServiceWithPlacement(); + values.placements[0].verification = verification; + if (signedBy) { + values.placements[0].signedBy = { anyOf: [{ id: "legacy", value: signedBy }], allOf: [] }; + } + + let form: UseFormReturn | undefined; + const Wrapper = () => { + const methods = useForm({ defaultValues: values }); + form = methods; + return ( + + + + + + ); + }; + + render(); + + return { + form: () => { + if (!form) throw new Error("Form did not initialize"); + return form; + } + }; +} diff --git a/apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.tsx b/apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.tsx new file mode 100644 index 0000000000..8d07f55ec2 --- /dev/null +++ b/apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.tsx @@ -0,0 +1,269 @@ +"use client"; +import { forwardRef, useCallback, useImperativeHandle } from "react"; +import type { Control } from "react-hook-form"; +import { useFieldArray, useFormContext, useWatch } from "react-hook-form"; +import { + Button, + CheckboxWithLabel, + CustomTooltip, + FormField, + FormInput, + FormItem, + FormLabel, + FormMessage, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch +} from "@akashnetwork/ui/components"; +import { Bin, InfoCircle, Plus } from "iconoir-react"; +import { nanoid } from "nanoid"; + +import type { PlacementVerificationType, SdlBuilderFormValuesType } from "@src/types"; + +type Props = { + placementIndex: number; + control: Control; + showTopDivider?: boolean; +}; + +export type PlacementVerificationRefType = { + removeAuditors: (index: number | number[]) => void; +}; + +type Capability = NonNullable[number]; + +const defaultVerification = (): PlacementVerificationType => ({ + minTier: 1, + capabilities: [], + auditors: [] +}); + +const TIER_OPTIONS = [ + { value: 1, label: "L1 - Identified", description: "Operator identity verified" }, + { value: 2, label: "L2 - Verified", description: "Resources and location checked" }, + { value: 3, label: "L3 - Established", description: "Sustained reliability checked" }, + { value: 4, label: "L4 - Trusted", description: "Physical audit and SLA" } +] as const; + +const CAPABILITY_OPTIONS = [ + { value: "tee_hardware_attestation", label: "TEE hardware attestation" }, + { value: "confidential_computing", label: "Confidential computing" }, + { value: "persistent_storage", label: "Persistent storage" }, + { value: "bare_metal", label: "Bare metal" } +] satisfies ReadonlyArray<{ value: Capability; label: string }>; + +export const PlacementVerificationFormControl = forwardRef(({ control, placementIndex, showTopDivider = true }, ref) => { + const { setValue } = useFormContext(); + const verification = useWatch({ control, name: `placements.${placementIndex}.verification` }); + const { + fields: auditors, + append: appendAuditor, + remove: removeAuditor + } = useFieldArray({ + control, + name: `placements.${placementIndex}.verification.auditors`, + keyName: "fieldId" + }); + const selectedTier = TIER_OPTIONS.find(option => option.value === verification?.minTier) ?? TIER_OPTIONS[0]; + + const removeAuditors = useCallback( + (index: number | number[]) => { + const indexes = new Set(Array.isArray(index) ? index : [index]); + const removesEveryAuditor = auditors.length > 0 && auditors.every((_, auditorIndex) => indexes.has(auditorIndex)); + + removeAuditor(index); + if (removesEveryAuditor) { + setValue(`placements.${placementIndex}.verification.auditorMode`, undefined, { shouldDirty: true }); + } + }, + [auditors, placementIndex, removeAuditor, setValue] + ); + + useImperativeHandle(ref, () => ({ removeAuditors }), [removeAuditors]); + + return ( +
    +
    +
    +
    + + Require verified providers + + + + +
    +

    {verification ? "Only providers meeting every requirement may bid" : "Any provider may bid"}

    +
    + + ( + field.onChange(checked ? defaultVerification() : undefined)} + /> + )} + /> +
    + + {verification ? ( +
    +
    + ( + + Minimum tier + +

    {selectedTier.description}

    + +
    + )} + /> + + ( + field.onChange(event.target.value === "" ? undefined : event.target.valueAsNumber)} + /> + )} + /> +
    + + ( + + Required capabilities +
    + {CAPABILITY_OPTIONS.map(option => ( + { + const current = field.value ?? []; + field.onChange(checked === true ? [...current, option.value] : current.filter(value => value !== option.value)); + }} + /> + ))} +
    + +
    + )} + /> + +
    +
    +
    + Named auditors +

    Optional auditor address requirements

    +
    + +
    + + {auditors.length > 0 ? ( +
    + {auditors.map((auditor, auditorIndex) => ( +
    +
    + ( + field.onChange(event.target.value)} + /> + )} + /> +
    + +
    + ))} + + ( + + Named auditor policy + + + + )} + /> +
    + ) : ( +

    None

    + )} +
    +
    + ) : ( +
    +

    Verification tiers

    +
    + {TIER_OPTIONS.map(option => ( +
    + {option.label} + {option.description} +
    + ))} +
    +
    + )} +
    + ); +}); + +PlacementVerificationFormControl.displayName = "PlacementVerificationFormControl"; diff --git a/apps/deploy-web/src/config/browser-env.config.ts b/apps/deploy-web/src/config/browser-env.config.ts index b81e3d4837..79aef5896c 100644 --- a/apps/deploy-web/src/config/browser-env.config.ts +++ b/apps/deploy-web/src/config/browser-env.config.ts @@ -6,6 +6,10 @@ export const browserEnvConfig = validateStaticEnvVars({ NEXT_PUBLIC_STATS_APP_URL: process.env.NEXT_PUBLIC_STATS_APP_URL, NEXT_PUBLIC_PROVIDER_PROXY_URL: process.env.NEXT_PUBLIC_PROVIDER_PROXY_URL, NEXT_PUBLIC_DEFAULT_NETWORK_ID: process.env.NEXT_PUBLIC_DEFAULT_NETWORK_ID, + NEXT_PUBLIC_AKASH_SANDBOX_CHAIN_ID: process.env.NEXT_PUBLIC_AKASH_SANDBOX_CHAIN_ID, + NEXT_PUBLIC_AKASH_SANDBOX_RPC_URL: process.env.NEXT_PUBLIC_AKASH_SANDBOX_RPC_URL, + NEXT_PUBLIC_AKASH_SANDBOX_REST_API_URL: process.env.NEXT_PUBLIC_AKASH_SANDBOX_REST_API_URL, + NEXT_PUBLIC_AKASH_SANDBOX_GENESIS_URL: process.env.NEXT_PUBLIC_AKASH_SANDBOX_GENESIS_URL, NEXT_PUBLIC_MASTER_WALLET_ADDRESS: process.env.NEXT_PUBLIC_MASTER_WALLET_ADDRESS, NEXT_PUBLIC_UAKT_TOP_UP_MASTER_WALLET_ADDRESS: process.env.NEXT_PUBLIC_UAKT_TOP_UP_MASTER_WALLET_ADDRESS, NEXT_PUBLIC_USDC_TOP_UP_MASTER_WALLET_ADDRESS: process.env.NEXT_PUBLIC_USDC_TOP_UP_MASTER_WALLET_ADDRESS, diff --git a/apps/deploy-web/src/config/env-config.schema.ts b/apps/deploy-web/src/config/env-config.schema.ts index c66a765b65..bbed0990cb 100644 --- a/apps/deploy-web/src/config/env-config.schema.ts +++ b/apps/deploy-web/src/config/env-config.schema.ts @@ -9,6 +9,10 @@ export const browserEnvSchema = z.object({ NEXT_PUBLIC_USDC_TOP_UP_MASTER_WALLET_ADDRESS: z.string(), NEXT_PUBLIC_MANAGED_WALLET_NETWORK_ID: networkId.optional().default("mainnet"), NEXT_PUBLIC_DEFAULT_NETWORK_ID: networkId.optional().default("mainnet"), + NEXT_PUBLIC_AKASH_SANDBOX_CHAIN_ID: z.string().trim().min(1).optional(), + NEXT_PUBLIC_AKASH_SANDBOX_RPC_URL: z.string().url().optional(), + NEXT_PUBLIC_AKASH_SANDBOX_REST_API_URL: z.string().url().optional(), + NEXT_PUBLIC_AKASH_SANDBOX_GENESIS_URL: z.string().url().optional(), NEXT_PUBLIC_API_BASE_URL: z.string(), NEXT_PUBLIC_STATS_APP_URL: z.string().url(), NEXT_PUBLIC_PROVIDER_PROXY_URL: z.string(), diff --git a/apps/deploy-web/src/context/ServicesProvider/ServicesProvider.spec.tsx b/apps/deploy-web/src/context/ServicesProvider/ServicesProvider.spec.tsx index e816e80699..708537869c 100644 --- a/apps/deploy-web/src/context/ServicesProvider/ServicesProvider.spec.tsx +++ b/apps/deploy-web/src/context/ServicesProvider/ServicesProvider.spec.tsx @@ -1,8 +1,9 @@ +import { MAINNET_ID, SANDBOX_ID } from "@akashnetwork/chain-sdk/web"; import { netConfig } from "@akashnetwork/net"; import { describe, expect, it } from "vitest"; import networkStore from "@src/store/networkStore"; -import { ServicesProvider, useServices } from "./ServicesProvider"; +import { resolveChainApiBaseUrl, ServicesProvider, useServices } from "./ServicesProvider"; import { render, screen } from "@testing-library/react"; @@ -13,6 +14,34 @@ describe(ServicesProvider.name, () => { expect(screen.getByTestId("chain-api-base-url")).toHaveTextContent(netConfig.getBaseAPIUrl(networkStore.selectedNetworkId)); }); + it("uses the private REST endpoint for an overridden sandbox", () => { + const baseUrl = resolveChainApiBaseUrl({ + networkId: SANDBOX_ID, + akashSandboxOverride: { + chainId: "aep-86", + rpcUrl: "https://rpc.aep86.example.com", + restApiUrl: "https://rest.aep86.example.com", + genesisUrl: "https://aep86.example.com/genesis.json" + } + }); + + expect(baseUrl).toBe("https://rest.aep86.example.com"); + }); + + it("does not apply the sandbox override to mainnet", () => { + const baseUrl = resolveChainApiBaseUrl({ + networkId: MAINNET_ID, + akashSandboxOverride: { + chainId: "aep-86", + rpcUrl: "https://rpc.aep86.example.com", + restApiUrl: "https://rest.aep86.example.com", + genesisUrl: "https://aep86.example.com/genesis.json" + } + }); + + expect(baseUrl).toBe(netConfig.getBaseAPIUrl(MAINNET_ID)); + }); + function setup() { render( diff --git a/apps/deploy-web/src/context/ServicesProvider/ServicesProvider.tsx b/apps/deploy-web/src/context/ServicesProvider/ServicesProvider.tsx index 12eed2cd48..5b4d41d657 100644 --- a/apps/deploy-web/src/context/ServicesProvider/ServicesProvider.tsx +++ b/apps/deploy-web/src/context/ServicesProvider/ServicesProvider.tsx @@ -1,7 +1,8 @@ import React, { useContext, useMemo } from "react"; -import type { NetworkId } from "@akashnetwork/chain-sdk/web"; +import { type NetworkId, SANDBOX_ID } from "@akashnetwork/chain-sdk/web"; import { AuthzHttpService, BmeHttpService, LeaseHttpService } from "@akashnetwork/http-sdk"; import { netConfig } from "@akashnetwork/net"; +import { type AkashSandboxNetworkOverride, getAkashSandboxNetworkOverrideFromEnv } from "@akashnetwork/network-store"; import { UACT_DENOM, UAKT_DENOM, USDC_IBC_DENOMS } from "@src/config/denom.config"; import { services as rootContainer } from "@src/services/app-di-container/browser-di-container"; @@ -50,7 +51,7 @@ function createAppContainer(blockchainStatus: BlockchainSta let isBlockchainDown = blockchainStatus.isBlockchainDown; const chainApiHttpClient: FallbackableHttpClient = rootContainer.applyAxiosInterceptors( createFallbackableHttpClient(rootContainer.createAxios, rootContainer.fallbackChainApiHttpClient, { - baseURL: netConfig.getBaseAPIUrl(rootContainer.networkStore.selectedNetworkId), + baseURL: resolveChainApiBaseUrl({ networkId: rootContainer.networkStore.selectedNetworkId }), shouldFallback: () => isBlockchainDown || blockchainStatus.isBlockchainDown, onUnavailableError: (error): Promise | void => { if (isBlockchainDown) return; @@ -93,3 +94,14 @@ function createAppContainer(blockchainStatus: BlockchainSta return di; } + +export function resolveChainApiBaseUrl({ + networkId, + akashSandboxOverride = getAkashSandboxNetworkOverrideFromEnv() +}: { + networkId: NetworkId; + akashSandboxOverride?: AkashSandboxNetworkOverride; +}): string { + if (networkId === SANDBOX_ID && akashSandboxOverride) return akashSandboxOverride.restApiUrl; + return netConfig.getBaseAPIUrl(networkId); +} diff --git a/apps/deploy-web/src/queries/usePlacementOffers.spec.ts b/apps/deploy-web/src/queries/usePlacementOffers.spec.ts index b67e452d04..efbf4ec4d8 100644 --- a/apps/deploy-web/src/queries/usePlacementOffers.spec.ts +++ b/apps/deploy-web/src/queries/usePlacementOffers.spec.ts @@ -1,7 +1,8 @@ +import { VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; -import type { ScreenedProvider } from "@src/queries/useScreenedProviders"; +import type { ProviderVerificationExclusion, ScreenedProvider } from "@src/queries/useScreenedProviders"; import type { ApiProviderList } from "@src/types/provider"; import type { DEPENDENCIES } from "./usePlacementOffers"; import { usePlacementOffers } from "./usePlacementOffers"; @@ -156,6 +157,107 @@ describe(usePlacementOffers.name, () => { ]); }); + it("keeps an accepted chain bid when verification preflight did not include the provider", () => { + const eligible = verifiedProvider({ owner: "akash1eligible", tier: VerificationTier.verification_tier_verified }); + const { result } = setup({ + phase: "quoting", + dseq: "100", + verificationEnabled: true, + screened: [eligible], + bids: [ + { bid: { state: "open", price: { amount: "1900", denom: "uakt" }, id: { provider: "akash1eligible", dseq: "100", gseq: 1, oseq: 1 } } }, + { bid: { state: "open", price: { amount: "100", denom: "uakt" }, id: { provider: "akash1excluded", dseq: "100", gseq: 1, oseq: 1 } } } + ] + }); + + expect(result.current.offers.map(offer => offer.owner)).toEqual(["akash1eligible", "akash1excluded"]); + }); + + it("suppresses a stale preflight exclusion after the provider has an accepted chain bid", () => { + const exclusion = mock({ owner: "akash1bidder" }); + const { result } = setup({ + phase: "quoting", + dseq: "100", + verificationEnabled: true, + screened: [], + screenedExclusions: [exclusion], + bids: [{ bid: { state: "open", price: { amount: "100", denom: "uakt" }, id: { provider: "akash1bidder", dseq: "100", gseq: 1, oseq: 1 } } }] + }); + + expect(result.current.offers.map(offer => offer.owner)).toEqual(["akash1bidder"]); + expect(result.current.exclusions).toEqual([]); + }); + + it("keeps incomplete verification facts eligible and marks them not evaluated", () => { + const provider = verifiedProvider({ owner: "akash1unknown", tier: VerificationTier.verification_tier_unspecified }); + provider.verification = { + outcome: "not_evaluated", + incompleteFacts: ["snapshot"], + summary: provider.verification!.summary + }; + const { result } = setup({ phase: "configuring", verificationEnabled: true, screened: [provider] }); + + expect(result.current.offers).toEqual([ + expect.objectContaining({ owner: "akash1unknown", verification: expect.objectContaining({ outcome: "not_evaluated" }) }) + ]); + }); + + it("ranks verification-eligible providers by tier before price", () => { + const { result } = setup({ + phase: "quoting", + dseq: "100", + verificationEnabled: true, + screened: [ + verifiedProvider({ owner: "akash1l2", tier: VerificationTier.verification_tier_verified }), + verifiedProvider({ owner: "akash1l3", tier: VerificationTier.verification_tier_established }) + ], + bids: [ + { bid: { state: "open", price: { amount: "100", denom: "uakt" }, id: { provider: "akash1l2", dseq: "100", gseq: 1, oseq: 1 } } }, + { bid: { state: "open", price: { amount: "5000", denom: "uakt" }, id: { provider: "akash1l3", dseq: "100", gseq: 1, oseq: 1 } } } + ] + }); + + expect(result.current.offers.map(offer => offer.owner)).toEqual(["akash1l3", "akash1l2"]); + }); + + it("uses auditor count, uptime, price, then name to break verification ranking ties", () => { + const recentDay = new Intl.DateTimeFormat("en-CA", { + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit" + }).format(Date.now()); + const base = { tier: VerificationTier.verification_tier_verified, auditors: ["akash1a"] }; + const screened = [ + verifiedProvider({ owner: "akash1pricey", organization: "Zulu", ...base }), + verifiedProvider({ owner: "akash1cheap", organization: "Beta", ...base }), + verifiedProvider({ owner: "akash1aardvark", organization: "Aardvark", ...base }), + verifiedProvider({ + owner: "akash1down", + organization: "Alpha", + ...base, + incidents: [{ date: recentDay, hasOpenIncident: false, incidentCount: 1, downtimeSeconds: 3600 }] + }), + verifiedProvider({ owner: "akash1auditors", organization: "Omega", tier: base.tier, auditors: ["akash1a", "akash1b"] }) + ]; + const bids = screened.map(provider => ({ + bid: { + state: "open", + price: { amount: provider.owner === "akash1pricey" ? "500" : "100", denom: "uakt" }, + id: { provider: provider.owner, dseq: "100", gseq: 1, oseq: 1 } + } + })); + const { result } = setup({ phase: "quoting", dseq: "100", verificationEnabled: true, screened, bids }); + + expect(result.current.offers.map(offer => offer.owner)).toEqual(["akash1auditors", "akash1aardvark", "akash1cheap", "akash1pricey", "akash1down"]); + }); + + it("preserves server order when the placement has no verification result", () => { + const { result } = setup({ phase: "configuring", verificationEnabled: true, screened: [polaris(), mock({ owner: "akash1first" })] }); + + expect(result.current.offers.map(offer => offer.owner)).toEqual(["akash1aaa", "akash1first"]); + }); + it("reuses screened metadata for a bidder instead of the provider list", () => { const { result } = setup({ phase: "quoting", @@ -238,18 +340,53 @@ describe(usePlacementOffers.name, () => { return mock({ owner: "akash1aaa", organization: "Polaris", location: "us-east" }); } + function verifiedProvider(input: { + owner: string; + tier: VerificationTier; + auditors?: string[]; + organization?: string; + incidents?: ScreenedProvider["incidents"]; + }): ScreenedProvider { + return mock({ + owner: input.owner, + organization: input.organization ?? input.owner, + location: "us-east", + incidents: input.incidents ?? [], + verification: { + outcome: "pass", + summary: { + bestStatusValidTier: input.tier, + tierGateTier: input.tier, + capabilities: [], + validAttestationCount: input.auditors?.length ?? 1, + validAuditors: input.auditors ?? ["akash1auditor"], + snapshotState: "current", + observedHeight: "123" + } + } + }); + } + function setup(input: { phase: "configuring" | "quoting"; dseq?: string; screened: ScreenedProvider[]; screenedInvalid?: boolean; + screenedExclusions?: ProviderVerificationExclusion[]; + verificationEnabled?: boolean; placementGseq?: number; providerList?: Array & { owner: string }>; bidsLoading?: boolean; bidsError?: boolean; bids?: Array<{ bid: { state: string; price: { amount: string; denom: string }; id: { provider: string; dseq: string; gseq: number; oseq: number } } }>; }) { - const useScreenedProviders = vi.fn(() => ({ providers: input.screened, isLoading: false, isError: false, isInvalid: input.screenedInvalid ?? false })); + const useScreenedProviders = vi.fn(() => ({ + providers: input.screened, + exclusions: input.screenedExclusions ?? [], + isLoading: false, + isError: false, + isInvalid: input.screenedInvalid ?? false + })); const useProviderList = vi.fn(() => ({ data: input.providerList ?? [], isLoading: false, isError: false })); const dependencies: typeof DEPENDENCIES = { useScreenedProviders: useScreenedProviders as never, @@ -258,7 +395,17 @@ describe(usePlacementOffers.name, () => { getPlacementGseq: (() => input.placementGseq) as never }; const view = renderHook(() => - usePlacementOffers({ phase: input.phase, dseq: input.dseq, sdl: "sdl", placementName: "placement-1", region: "us-east" }, dependencies) + usePlacementOffers( + { + phase: input.phase, + dseq: input.dseq, + sdl: "sdl", + placementName: "placement-1", + region: "us-east", + verificationEnabled: input.verificationEnabled + }, + dependencies + ) ); return { ...view, useScreenedProviders, useProviderList }; } diff --git a/apps/deploy-web/src/queries/usePlacementOffers.ts b/apps/deploy-web/src/queries/usePlacementOffers.ts index 9697e96ad9..c064691881 100644 --- a/apps/deploy-web/src/queries/usePlacementOffers.ts +++ b/apps/deploy-web/src/queries/usePlacementOffers.ts @@ -1,11 +1,13 @@ import { useMemo } from "react"; +import { deriveProviderUptime } from "@src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplaceProvidersTable/ProviderUptimeCell/deriveProviderUptime"; import { BID_POLL_INTERVAL, useListBids } from "@src/queries/useListBids"; import { useProviderList } from "@src/queries/useProvidersQuery"; -import type { ScreenedProvider } from "@src/queries/useScreenedProviders"; +import type { ProviderVerificationExclusion, ScreenedProvider } from "@src/queries/useScreenedProviders"; import { useScreenedProviders } from "@src/queries/useScreenedProviders"; import type { ApiProviderList } from "@src/types/provider"; import { formatBidId } from "@src/utils/bids/bidId"; +import { providerDisplayName } from "@src/utils/providerUtils"; import { getPlacementGseq } from "@src/utils/sdl/placementGseq"; export type OfferState = "searching" | "submitted" | "closed" | "unavailable"; @@ -26,10 +28,12 @@ interface UsePlacementOffersInput { sdl: string; placementName: string; region?: string; + verificationEnabled?: boolean; } interface UsePlacementOffersResult { offers: PlacementOffer[]; + exclusions: ProviderVerificationExclusion[]; isLoading: boolean; isError: boolean; /** True while still configuring a spec that can't be screened — the marketplace shows a message instead of a list. */ @@ -58,7 +62,7 @@ export const DEPENDENCIES = { useScreenedProviders, useListBids, useProviderList * sequence (`gseq`). */ export function usePlacementOffers( - { phase, dseq, sdl, placementName, region }: UsePlacementOffersInput, + { phase, dseq, sdl, placementName, region, verificationEnabled = false }: UsePlacementOffersInput, dependencies: typeof DEPENDENCIES = DEPENDENCIES ): UsePlacementOffersResult { const isLocked = phase === "creating" || phase === "quoting" || phase === "closing" || phase === "deploying"; @@ -69,29 +73,34 @@ export function usePlacementOffers( const gseq = useMemo(() => dependencies.getPlacementGseq(sdl, placementName), [dependencies, sdl, placementName]); const providersByOwner = useMemo(() => new Map((providerListQuery.data ?? []).map(provider => [provider.owner, provider])), [providerListQuery.data]); const screenedByOwner = useMemo(() => new Map(screened.providers.map(provider => [provider.owner, provider])), [screened.providers]); + const exclusions = screened.exclusions ?? []; + const hasVerificationScreening = verificationEnabled && (exclusions.length > 0 || screened.providers.some(hasVerificationResult)); + const placementBids = useMemo(() => (bidsQuery.data?.data ?? []).filter(entry => gseq === undefined || entry.bid.id.gseq === gseq), [bidsQuery.data, gseq]); + const biddingOwners = useMemo(() => new Set(placementBids.map(entry => entry.bid.id.provider)), [placementBids]); const offers = useMemo( function buildOffers(): PlacementOffer[] { - if (isScreening) return screened.providers.map(toSearchingOffer); + if (isScreening) return rankOffers(screened.providers.map(toSearchingOffer), hasVerificationScreening); - const placementBids = (bidsQuery.data?.data ?? []).filter(entry => gseq === undefined || entry.bid.id.gseq === gseq); - if (placementBids.length === 0) return screened.providers.map(toSearchingOffer); + if (placementBids.length === 0) return rankOffers(screened.providers.map(toSearchingOffer), hasVerificationScreening); const bidByOwner = pickBestBidPerOwner(placementBids); - return mergedOwners(screened.providers, bidByOwner).map(function toMergedOffer(owner): PlacementOffer { + const merged = mergedOwners(screened.providers, bidByOwner).map(function toMergedOffer(owner): PlacementOffer { const meta = screenedByOwner.get(owner) ?? providerListToOffer(owner, providersByOwner.get(owner)); const entry = bidByOwner.get(owner); if (entry?.bid.state === "open") return { ...meta, offerState: "submitted", bidId: formatBidId(entry.bid.id), price: entry.bid.price }; if (entry) return { ...meta, offerState: "closed", bidId: undefined, price: entry.bid.price }; return { ...meta, offerState: "unavailable", bidId: undefined, price: undefined }; }); + return rankOffers(merged, hasVerificationScreening); }, - [isScreening, screened.providers, screenedByOwner, bidsQuery.data, gseq, providersByOwner] + [isScreening, screened.providers, screenedByOwner, placementBids, providersByOwner, hasVerificationScreening] ); const isQuoting = phase === "quoting"; return { offers, + exclusions: verificationEnabled ? exclusions.filter(exclusion => !biddingOwners.has(exclusion.owner)) : [], isLoading: screened.isLoading || (isQuoting && offers.length === 0 && bidsQuery.isLoading), isError: screened.isError || (isQuoting && bidsQuery.isError), isInvalid: !isLocked && screened.isInvalid @@ -132,6 +141,47 @@ function mergedOwners(screened: ScreenedProvider[], bidByOwner: Map [offer.owner, deriveProviderUptime(offer.incidents ?? [], now, timeZone).percent])); + + return [...offers].sort((left, right) => { + const leftSummary = left.verification?.summary; + const rightSummary = right.verification?.summary; + const byTier = (rightSummary?.tierGateTier ?? -1) - (leftSummary?.tierGateTier ?? -1); + if (byTier !== 0) return byTier; + + const byAuditors = (rightSummary?.validAuditors.length ?? -1) - (leftSummary?.validAuditors.length ?? -1); + if (byAuditors !== 0) return byAuditors; + + const byUptime = (uptimeByOwner.get(right.owner) ?? 0) - (uptimeByOwner.get(left.owner) ?? 0); + if (byUptime !== 0) return byUptime; + + const byPrice = comparePrice(left.price, right.price); + if (byPrice !== 0) return byPrice; + + return providerDisplayName(left).localeCompare(providerDisplayName(right)); + }); +} + +function hasVerificationResult(provider: ScreenedProvider): boolean { + return provider.verification?.outcome === "pass" || provider.verification?.outcome === "not_evaluated"; +} + +function comparePrice(left: PlacementOffer["price"], right: PlacementOffer["price"]): number { + if (!left && !right) return 0; + if (!left) return 1; + if (!right) return -1; + if (left.denom !== right.denom) return left.denom.localeCompare(right.denom); + + const leftAmount = BigInt(left.amount); + const rightAmount = BigInt(right.amount); + return leftAmount < rightAmount ? -1 : leftAmount > rightAmount ? 1 : 0; +} + /** * A screened-provider-shaped record for a bidder that was never screened, so the table renders it identically. * The provider list (when loaded) supplies the name (organization, else host), region and audited flag; uptime diff --git a/apps/deploy-web/src/queries/useScreenedProviders.spec.tsx b/apps/deploy-web/src/queries/useScreenedProviders.spec.tsx index 33803655b5..25804e3102 100644 --- a/apps/deploy-web/src/queries/useScreenedProviders.spec.tsx +++ b/apps/deploy-web/src/queries/useScreenedProviders.spec.tsx @@ -1,3 +1,4 @@ +import { AuditorSelectionMode, CapabilityFlag, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; import { createProxy } from "@akashnetwork/react-query-proxy"; import { keepPreviousData, type UseQueryResult } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; @@ -44,13 +45,29 @@ deployment: count: 1 `; +const VERIFICATION_SDL = HELLO_WORLD_SDL.replace( + " pricing:", + ` signedBy: + anyOf: + - akash1legacy + verification: + min_tier: 2 + capabilities: + - persistent_storage + auditors: + - akash1auditor + auditor_mode: all + min_auditor_count: 1 + pricing:` +); + describe("useScreenedProviders", () => { - it("screens the given placement's group spec, audited-only", () => { + it("screens the given placement without adding an auditor policy", () => { const { useQuery } = setup({ placementName: "dcloud" }); expect(useQuery).toHaveBeenCalledWith( expect.objectContaining({ - requirements: { signedBy: { allOf: [AUDITOR] }, attributes: [] }, + requirements: { signedBy: { allOf: [], anyOf: [] }, attributes: [] }, resources: expect.arrayContaining([expect.objectContaining({ count: 1 })]) }), expect.anything() @@ -78,6 +95,28 @@ describe("useScreenedProviders", () => { expect(result.current.providers).toEqual(providers); }); + it("returns structured verification exclusions from the query result", () => { + const exclusion = { + owner: "akash1excluded", + firstFailure: { code: "snapshot_stale" as const }, + failures: [{ code: "snapshot_stale" as const }], + summary: { + bestStatusValidTier: VerificationTier.verification_tier_verified, + tierGateTier: VerificationTier.verification_tier_verified, + capabilities: [], + validAttestationCount: 1, + validAuditors: ["akash1auditor"], + snapshotState: "stale" as const, + observedHeight: "123" + } + }; + const { result } = setup({ placementName: "dcloud", exclusions: [exclusion] }); + + expect(result.current.exclusions).toHaveLength(1); + expect(result.current.exclusions[0].owner).toBe("akash1excluded"); + expect(result.current.exclusions[0].firstFailure.code).toBe("snapshot_stale"); + }); + it("requests the previous data as a placeholder so the list refines in place instead of blanking", () => { const { useQuery } = setup({ placementName: "dcloud" }); @@ -120,10 +159,17 @@ describe("useScreenedProviders", () => { } }); - function setup(input: { placementName: string; sdl?: string; region?: string; providers?: ScreenedProvider[]; enabled?: boolean }) { + function setup(input: { + placementName: string; + sdl?: string; + region?: string; + providers?: ScreenedProvider[]; + exclusions?: ScreenedProvidersResponse["exclusions"]; + enabled?: boolean; + }) { const useQuery = vi.fn().mockReturnValue( mock>({ - data: { providers: input.providers ?? [] }, + data: { providers: input.providers ?? [], exclusions: input.exclusions }, isLoading: false, isError: false }) @@ -179,13 +225,29 @@ deployment: `; describe("buildPlacementScreeningRequest", () => { - it("builds an audited request from the matching placement group spec", () => { + it("builds a request from the matching placement without adding signedBy", () => { const request = buildPlacementScreeningRequest(HELLO_WORLD_SDL, "dcloud"); - expect(request).toMatchObject({ requirements: { signedBy: { allOf: [AUDITOR] } } }); + expect(request).toMatchObject({ requirements: { signedBy: { allOf: [], anyOf: [] } } }); expect(request?.resources[0].resource.cpu.units.val).toBeTruthy(); }); + it("preserves signedBy and verification as independent placement policies", () => { + const request = buildPlacementScreeningRequest(VERIFICATION_SDL, "dcloud"); + + expect(request?.requirements).toEqual({ + signedBy: { allOf: [], anyOf: ["akash1legacy"] }, + attributes: [], + verification: { + minTier: VerificationTier.verification_tier_verified, + requiredCapabilities: [CapabilityFlag.capability_persistent_storage], + requiredAuditors: ["akash1auditor"], + auditorMode: AuditorSelectionMode.auditor_selection_mode_all, + minAuditorCount: 1 + } + }); + }); + it("substitutes a placeholder image so an image-less spec still screens its own resources", () => { const request = buildPlacementScreeningRequest(SMALL_PRESET_SDL_NO_IMAGE, "dcloud"); diff --git a/apps/deploy-web/src/queries/useScreenedProviders.ts b/apps/deploy-web/src/queries/useScreenedProviders.ts index dbb2387601..cce549ad45 100644 --- a/apps/deploy-web/src/queries/useScreenedProviders.ts +++ b/apps/deploy-web/src/queries/useScreenedProviders.ts @@ -1,4 +1,6 @@ import { useMemo } from "react"; +import type { VerificationRequirement } from "@akashnetwork/chain-sdk/private-types/akash.v1"; +import { AuditorSelectionMode, CapabilityFlag, VerificationTier } from "@akashnetwork/chain-sdk/private-types/akash.v1"; import { GroupSpec } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; import { generateManifest, type SDLInput, yaml } from "@akashnetwork/chain-sdk/web"; import type { paths } from "@akashnetwork/console-api-types"; @@ -8,14 +10,52 @@ import { useServices } from "@src/context/ServicesProvider"; import { usePacedValue } from "@src/hooks/usePacedValue/usePacedValue"; import { AUDITOR } from "@src/utils/deploymentData/v1beta3"; -type ScreeningRequest = NonNullable["content"]["application/json"]; +export type ScreeningRequest = NonNullable["content"]["application/json"]; +type VerificationRequirementRequest = NonNullable["verification"]>; /** The screening request minus `timezone`, which the hook attaches from the client's resolved locale. */ -type ScreeningRequestBody = Omit; +export type ScreeningRequestBody = Omit & { + requirements: ScreeningRequest["requirements"] & { verification?: VerificationRequirementRequest }; +}; -export type ScreenedProvidersResponse = paths["/v1/bid-screening"]["post"]["responses"][200]["content"]["application/json"]; +type GeneratedScreenedProvidersResponse = paths["/v1/bid-screening"]["post"]["responses"][200]["content"]["application/json"]; +type GeneratedScreenedProvider = GeneratedScreenedProvidersResponse["providers"][number]; -export type ScreenedProvider = ScreenedProvidersResponse["providers"][number]; +export interface ProviderVerificationSummary { + bestStatusValidTier: number; + tierGateTier: number; + capabilities: number[]; + validAttestationCount: number; + validAuditors: string[]; + snapshotState: "unknown" | "not_posted" | "current" | "stale" | "suspended"; + observedHeight: string; +} + +export type ProviderVerificationFailure = + | { code: "snapshot_not_posted" } + | { code: "snapshot_suspended" } + | { code: "snapshot_stale" } + | { code: "insufficient_tier"; actual: number; required: number } + | { code: "missing_capability"; capability: number } + | { code: "insufficient_auditor_count"; actual: number; required: number } + | { code: "required_auditor_not_found"; mode: number; missing: string[] }; + +export interface ProviderVerificationExclusion { + owner: string; + firstFailure: ProviderVerificationFailure; + failures: ProviderVerificationFailure[]; + summary: ProviderVerificationSummary; +} + +export type ScreenedProvider = GeneratedScreenedProvider & { + verification?: + { outcome: "pass"; summary: ProviderVerificationSummary } | { outcome: "not_evaluated"; incompleteFacts: string[]; summary: ProviderVerificationSummary }; +}; + +export type ScreenedProvidersResponse = Omit & { + providers: ScreenedProvider[]; + exclusions?: ProviderVerificationExclusion[]; +}; interface UseScreenedProvidersInput { sdl: string; @@ -36,6 +76,7 @@ interface UseScreenedProvidersInput { interface UseScreenedProvidersResult { providers: ScreenedProvider[]; + exclusions: ProviderVerificationExclusion[]; isLoading: boolean; isError: boolean; /** @@ -66,7 +107,8 @@ const SKIPPED_SCREENING_REQUEST: ScreeningRequest = { ...buildCatalogScreeningRe * the current SDL to group specs and queries the one matching `placementName`. When the SDL can't be turned * into a screening request (invalid or incomplete spec) it does NOT fall back to the full catalog — no * provider would bid on an unusable spec — instead it reports `isInvalid` so the marketplace shows a message. - * A selected region travels in the SDL, so a valid spec already screens by region. Audited-only via signedBy. + * A selected region travels in the SDL, so a valid spec already screens by region. The placement's legacy + * signedBy and AEP-86 verification policies are forwarded independently. */ export function useScreenedProviders({ sdl, placementName, enabled = true }: UseScreenedProvidersInput): UseScreenedProvidersResult { const { api } = useServices(); @@ -83,7 +125,8 @@ export function useScreenedProviders({ sdl, placementName, enabled = true }: Use }); return { - providers: isInvalid ? [] : query.data?.providers ?? [], + providers: isInvalid ? [] : (query.data?.providers ?? []), + exclusions: isInvalid ? [] : ((query.data as ScreenedProvidersResponse | undefined)?.exclusions ?? []), isLoading: !isInvalid && query.isLoading, isError: !isInvalid && query.isError, isInvalid @@ -92,10 +135,9 @@ export function useScreenedProviders({ sdl, placementName, enabled = true }: Use /** * Converts the current SDL into a screening request for a single placement's group spec. Returns null - * when the SDL is incomplete/invalid (e.g. mid-edit) or the placement isn't in it yet, so the caller can - * fall back to the full catalog. `signedBy` forces audited-only screening; `attributes` are passed through - * from the placement and carry the `location-region` filter (and any other declared attribute). The proto - * JSON encodes resource values as decimal integer strings, which the screening endpoint accepts. + * when the SDL is incomplete/invalid (e.g. mid-edit) or the placement isn't in it yet. `signedBy`, + * verification, and attributes are copied from the generated group spec without combining their semantics. + * The proto JSON encodes resource values as decimal integer strings, which the screening endpoint accepts. */ export function buildPlacementScreeningRequest(rawSdl: string, placementName: string): ScreeningRequestBody | null { if (!rawSdl) return null; @@ -112,15 +154,19 @@ export function buildPlacementScreeningRequest(rawSdl: string, placementName: st const group = manifest.groupSpecs.find(candidate => candidate.name === placementName); if (!group) return null; - const groupJson = GroupSpec.toJSON(group) as { - resources: ScreeningRequest["resources"]; - requirements?: { attributes?: Array<{ key: string; value: string }> }; - }; + const groupJson = GroupSpec.toJSON(group) as { resources: ScreeningRequest["resources"] }; + const requirements = group.requirements; + const verification = requirements?.verification ? toVerificationRequirementRequest(requirements.verification) : null; + if (requirements?.verification && !verification) return null; return { requirements: { - signedBy: { allOf: [AUDITOR] }, - attributes: groupJson.requirements?.attributes ?? [] + signedBy: { + allOf: requirements?.signedBy?.allOf ?? [], + anyOf: requirements?.signedBy?.anyOf ?? [] + }, + attributes: requirements?.attributes ?? [], + ...(verification ? { verification } : {}) }, resources: groupJson.resources, reclamationWindow: manifest.reclamation?.minWindow?.seconds ? Number(manifest.reclamation?.minWindow?.seconds) : undefined @@ -130,6 +176,68 @@ export function buildPlacementScreeningRequest(rawSdl: string, placementName: st } } +export function hasPlacementVerificationRequirement(rawSdl: string, placementName: string): boolean { + return buildPlacementScreeningRequest(rawSdl, placementName)?.requirements.verification !== undefined; +} + +function toVerificationRequirementRequest(requirement: VerificationRequirement): VerificationRequirementRequest | null { + const minTier = toVerificationTierRequest(requirement.minTier); + const auditorMode = toAuditorSelectionModeRequest(requirement.auditorMode); + if (minTier === null || auditorMode === null) return null; + + const requiredCapabilities: NonNullable = []; + for (const capability of requirement.requiredCapabilities) { + const mappedCapability = toCapabilityFlagRequest(capability); + if (mappedCapability === null) return null; + requiredCapabilities.push(mappedCapability); + } + + return { + minTier, + requiredCapabilities, + requiredAuditors: requirement.requiredAuditors, + auditorMode, + minAuditorCount: requirement.minAuditorCount + }; +} + +function toVerificationTierRequest(tier: VerificationTier): VerificationRequirementRequest["minTier"] | null { + switch (tier) { + case VerificationTier.verification_tier_unspecified: + case VerificationTier.verification_tier_identified: + case VerificationTier.verification_tier_verified: + case VerificationTier.verification_tier_established: + case VerificationTier.verification_tier_trusted: + return tier; + case VerificationTier.UNRECOGNIZED: + return null; + } +} + +function toCapabilityFlagRequest(capability: CapabilityFlag): NonNullable[number] | null { + switch (capability) { + case CapabilityFlag.capability_tee_hardware_attestation: + case CapabilityFlag.capability_confidential_computing: + case CapabilityFlag.capability_persistent_storage: + case CapabilityFlag.capability_bare_metal: + return capability; + case CapabilityFlag.capability_unspecified: + case CapabilityFlag.UNRECOGNIZED: + return null; + } +} + +function toAuditorSelectionModeRequest(mode: AuditorSelectionMode): VerificationRequirementRequest["auditorMode"] | null { + switch (mode) { + case AuditorSelectionMode.auditor_selection_mode_unspecified: + case AuditorSelectionMode.auditor_selection_mode_any: + case AuditorSelectionMode.auditor_selection_mode_all: + return mode; + case AuditorSelectionMode.UNRECOGNIZED: + return null; + } +} + /** * Builds the full audited catalog request (empty resource spec) used before a deployment is configured (no * SDL yet, mid-edit/invalid SDL, or no placement selected). Because the region is chosen independently of the diff --git a/apps/deploy-web/src/types/deployment.ts b/apps/deploy-web/src/types/deployment.ts index 4ab4fd3bb0..8e3365560b 100644 --- a/apps/deploy-web/src/types/deployment.ts +++ b/apps/deploy-web/src/types/deployment.ts @@ -66,6 +66,28 @@ export interface RpcDeployment { export type DeploymentGroup = DeploymentGroup_v2 | DeploymentGroup_v3; +export type RpcVerificationTier = + | "verification_tier_identified" + | "verification_tier_verified" + | "verification_tier_established" + | "verification_tier_trusted" + | "verification_tier_unspecified"; + +export type RpcVerificationCapability = + | "capability_unspecified" + | "capability_tee_hardware_attestation" + | "capability_confidential_computing" + | "capability_persistent_storage" + | "capability_bare_metal"; + +export interface RpcVerificationRequirement { + min_tier: RpcVerificationTier; + required_capabilities: RpcVerificationCapability[]; + required_auditors: string[]; + auditor_mode: "auditor_selection_mode_unspecified" | "auditor_selection_mode_any" | "auditor_selection_mode_all"; + min_auditor_count: number; +} + export type DeploymentResource_V2 = DeploymentResource; export type DeploymentResource_V3 = DeploymentResource; @@ -83,6 +105,7 @@ interface DeploymentGroup_v2 { all_of: string[]; any_of: string[]; }; + verification?: RpcVerificationRequirement; attributes: Array<{ key: string; value: string; @@ -157,6 +180,7 @@ interface DeploymentGroup_v3 { all_of: string[]; any_of: string[]; }; + verification?: RpcVerificationRequirement; attributes: Array<{ key: string; value: string; diff --git a/apps/deploy-web/src/types/feature-flags.ts b/apps/deploy-web/src/types/feature-flags.ts index a9ed7e5c00..4fc0b581ea 100644 --- a/apps/deploy-web/src/types/feature-flags.ts +++ b/apps/deploy-web/src/types/feature-flags.ts @@ -9,4 +9,5 @@ export type FeatureFlag = | "ui_build_and_deploy" | "ui_agent_mode_deploy" | "hackathons" - | "deployment_runtime_limit"; + | "deployment_runtime_limit" + | "provider_verification"; diff --git a/apps/deploy-web/src/types/provider.ts b/apps/deploy-web/src/types/provider.ts index b509a54d73..6bc293774d 100644 --- a/apps/deploy-web/src/types/provider.ts +++ b/apps/deploy-web/src/types/provider.ts @@ -164,6 +164,184 @@ export interface ProviderStatusDto { }; } +export type ProviderVerificationTier = "L0" | "L1" | "L2" | "L3" | "L4" | "unknown"; + +export type ProviderVerificationCapability = + "unspecified" | "tee_hardware_attestation" | "confidential_computing" | "persistent_storage" | "bare_metal" | "unknown"; + +export interface ProviderVerificationCoin { + denom: string; + amount: string; +} + +export interface ProviderVerificationListView { + provider: string; + moduleActive: boolean | null; + summary: { + effectiveTier: ProviderVerificationTier | null; + validAuditorCount: number | null; + capabilities: ProviderVerificationCapability[] | null; + snapshotState: "unknown" | "not_posted" | "current" | "stale" | "suspended"; + maintenanceState: "unknown" | "none" | "scheduled" | "active"; + reviewState: "unknown" | "none" | "under_review" | "grace"; + }; + observedAt: string; + observedHeight: string; +} + +export interface ProviderVerificationView { + provider: string; + providerDeclaredTier: string | null; + moduleActive: boolean | null; + provenance: { + providerTier: "provider self-declared"; + inventory: "provider-signed inventory"; + attestations: "auditor-attested"; + }; + summary: { + bestAttestedTier: ProviderVerificationTier | null; + effectiveTier: ProviderVerificationTier | null; + capabilities: ProviderVerificationCapability[] | null; + validAttestationCount: number | null; + validAuditorCount: number | null; + validAuditors: string[] | null; + snapshotState: "unknown" | "not_posted" | "current" | "stale" | "suspended"; + maintenanceState: "unknown" | "none" | "scheduled" | "active"; + reviewState: "unknown" | "none" | "under_review" | "grace"; + }; + attestations: Array<{ + provider: string; + auditor: string; + tier: ProviderVerificationTier; + capabilities: ProviderVerificationCapability[]; + evidenceHash: string | null; + fee: ProviderVerificationCoin | null; + feeStatus: "unspecified" | "escrowed" | "released_to_auditor" | "returned_to_provider" | "unknown"; + createdAt: string | null; + expiresAt: string | null; + status: "unspecified" | "valid" | "voided" | "expired" | "revoked" | "removed" | "unknown"; + voidedReason: "unspecified" | "discrepancy" | "governance" | "bond_withdrawn" | "bond_slashed" | "unknown"; + deposit: ProviderVerificationCoin | null; + depositStatus: "unspecified" | "escrowed" | "pending_discrepancy" | "returned_to_auditor" | "slashed" | "unknown"; + auditEscrowId: string; + faultAttribution: "unspecified" | "provider_fault" | "auditor_fault" | "shared_fault" | "no_fault" | "inconclusive" | "unknown"; + }>; + bond: { + provider: string; + bondedAmount: ProviderVerificationCoin | null; + requiredForCurrentTier: ProviderVerificationCoin; + unbondingEntries: Array<{ + amount: ProviderVerificationCoin | null; + completionTime: string | null; + }>; + slashed: boolean; + lastSlashTime: string | null; + } | null; + snapshot: { + provider: string; + snapshotHash: string | null; + resourceSummary: { + totalGpus: number; + totalVcpus: number; + totalMemoryMb: string; + totalStorageMb: string; + activeLeases: number; + softwareVersion: string; + softwareSignature: string | null; + softwareIdentity: { + version: string; + artifactRef: string; + digestAlgorithm: string; + digest: string | null; + signatureType: string; + signature: string | null; + signatureRef: string; + publicKeyRef: string; + } | null; + } | null; + postedAt: string | null; + snapshotTimestamp: string | null; + complianceDeadline: string | null; + suspended: boolean; + } | null; + grace: { + id: string; + provider: string; + preservedTier: ProviderVerificationTier; + sourceDiscrepancyIds: string[]; + startedAt: string | null; + expiresAt: string | null; + status: "unspecified" | "active" | "expired" | "terminated" | "unknown"; + } | null; + auditEscrows: Array<{ + id: string; + provider: string; + consumedByAuditor: string | null; + requestedTier: ProviderVerificationTier; + requestedCapabilities: ProviderVerificationCapability[]; + fee: ProviderVerificationCoin | null; + feeStatus: "unspecified" | "escrowed" | "released_to_auditor" | "returned_to_provider" | "unknown"; + providerDeposit: ProviderVerificationCoin | null; + providerDepositStatus: "unspecified" | "escrowed" | "returned_to_provider" | "slashed" | "unknown"; + status: "unspecified" | "open" | "consumed" | "cancelled" | "expired" | "settled" | "unknown"; + openedAt: string | null; + consumedAt: string | null; + expiresAt: string | null; + metadataHash: string | null; + settlementReason: "unspecified" | "cancelled_unconsumed" | "expired_unconsumed" | "provider_fault" | "no_fault" | "unknown"; + faultAttribution: "unspecified" | "provider_fault" | "auditor_fault" | "shared_fault" | "no_fault" | "inconclusive" | "unknown"; + }>; + maintenance: Array<{ + record: { + id: string; + provider: string; + maintenanceType: "unspecified" | "planned" | "emergency" | "security" | "network" | "capacity" | "unknown"; + startsAt: string | null; + expectedEndsAt: string | null; + openedAt: string | null; + closedAt: string | null; + metadataHash: string | null; + } | null; + status: "unspecified" | "scheduled" | "active" | "elapsed" | "closed" | "unknown"; + }>; + discrepancies: Array<{ + id: string; + provider: string; + auditorA: string; + auditorATier: ProviderVerificationTier; + auditorB: string; + auditorBTier: ProviderVerificationTier; + timestamp: string | null; + resolutionStatus: "unspecified" | "pending" | "resolved" | "timed_out" | "unknown"; + resolutionProposalId: string; + graceRecordId: string; + resolutionReason: + | "unspecified" + | "auditor_a_correct" + | "auditor_b_correct" + | "both_auditors_wrong" + | "provider_fault" + | "shared_fault" + | "evidence_inconclusive" + | "governance_timeout_review" + | "unknown"; + faultAttribution: "unspecified" | "provider_fault" | "auditor_fault" | "shared_fault" | "no_fault" | "inconclusive" | "unknown"; + resolutionEvidenceHash: string | null; + }>; + observedAt: string; + observedHeight: string; + completeness: { + params: boolean; + attestations: boolean; + graces: boolean; + snapshot: boolean; + bond: boolean; + auditEscrows: boolean; + maintenance: boolean; + discrepancies: boolean; + }; +} + export interface ApiProviderList { owner: string; name: string | null; @@ -232,6 +410,7 @@ export interface ApiProviderList { workloadSupportChia: boolean; workloadSupportChiaCapabilities: string[]; featEndpointIp: boolean; + verification: ProviderVerificationListView | null; } export interface ClientProviderList extends ApiProviderList { @@ -239,7 +418,8 @@ export interface ClientProviderList extends ApiProviderList { userActiveLeases?: number; } -export interface ApiProviderDetail extends ApiProviderList { +export interface ApiProviderDetail extends Omit { + verification: ProviderVerificationView | null; uptime: Array<{ id: string; isOnline: boolean; diff --git a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts index 3bc192afdc..a5a492db16 100644 --- a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts +++ b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; -import { CredentialsSchema, EndpointSchema, EnvironmentVariableSchema, SdlBuilderFormValuesSchema, ServiceSchema, ServiceStorageSchema } from "./sdlBuilder"; +import { + CredentialsSchema, + EndpointSchema, + EnvironmentVariableSchema, + PlacementSchema, + SdlBuilderFormValuesSchema, + ServiceSchema, + ServiceStorageSchema +} from "./sdlBuilder"; describe("ServiceStorageSchema", () => { it("surfaces a friendly required message instead of the raw type error when size is cleared", () => { @@ -57,6 +65,67 @@ describe("EnvironmentVariableSchema", () => { }); }); +describe("PlacementSchema", () => { + it("accepts verification requirements independently of legacy signedBy", () => { + const result = PlacementSchema.safeParse({ + id: "p-1", + name: "dcloud", + signedBy: { anyOf: [{ value: "akash1legacy" }], allOf: [] }, + verification: { + minTier: 3, + capabilities: ["persistent_storage", "bare_metal"], + auditors: [{ value: "akash1auditor" }], + auditorMode: "all", + minAuditorCount: 2 + } + }); + + expect(result.success).toBe(true); + }); + + it.each([0, 5, 1.5])("rejects verification tier %s at the form schema boundary", minTier => { + const result = PlacementSchema.safeParse({ id: "p-1", name: "dcloud", verification: { minTier } }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toContainEqual(expect.objectContaining({ path: ["verification", "minTier"] })); + }); + + it("rejects unknown verification capabilities at the form schema boundary", () => { + const result = PlacementSchema.safeParse({ + id: "p-1", + name: "dcloud", + verification: { minTier: 1, capabilities: ["unknown_capability"] } + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toContainEqual(expect.objectContaining({ path: ["verification", "capabilities", 0] })); + }); + + it.each([4_294_967_296, -1, 1.5])("rejects verification auditor count %s outside protobuf uint32", minAuditorCount => { + const result = PlacementSchema.safeParse({ id: "p-1", name: "dcloud", verification: { minTier: 1, minAuditorCount } }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toContainEqual(expect.objectContaining({ path: ["verification", "minAuditorCount"] })); + }); + + it("accepts the maximum protobuf uint32 auditor count", () => { + const result = PlacementSchema.safeParse({ id: "p-1", name: "dcloud", verification: { minTier: 1, minAuditorCount: 4_294_967_295 } }); + + expect(result.success).toBe(true); + }); + + it.each([" auditor", "auditor ", "cosmos1auditor"])('rejects verification auditor address "%s" like the Go SDL parser', value => { + const result = PlacementSchema.safeParse({ id: "p-1", name: "dcloud", verification: { minTier: 1, auditors: [{ value }] } }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toContainEqual(expect.objectContaining({ path: ["verification", "auditors", 0, "value"] })); + }); +}); + describe("SdlBuilderFormValuesSchema", () => { it("rejects a service whose placementId does not exist in placements[]", () => { const result = SdlBuilderFormValuesSchema.safeParse({ diff --git a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts index b667bbf899..8739c7777b 100644 --- a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts +++ b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts @@ -125,6 +125,25 @@ export const SignedBySchema = z.object({ value: z.string().min(1, { message: "Value is required." }) }); +export const VerificationCapabilitySchema = z.enum(["tee_hardware_attestation", "confidential_computing", "persistent_storage", "bare_metal"]); + +const VerificationAuditorSchema = z.object({ + id: z.string().optional(), + value: z + .string() + .min(1, { message: "Auditor address is required." }) + .refine(value => value === value.trim(), { message: "Auditor address cannot contain surrounding whitespace." }) + .refine(value => value.startsWith("akash1"), { message: "Auditor address must start with akash1." }) +}); + +export const PlacementVerificationSchema = z.object({ + minTier: z.number().int().min(1).max(4), + capabilities: z.array(VerificationCapabilitySchema).optional(), + auditors: z.array(VerificationAuditorSchema).optional(), + auditorMode: z.enum(["any", "all"]).optional(), + minAuditorCount: z.number().int().min(0).max(4_294_967_295).optional() +}); + export const CUSTOM_HOST_ID = "__CUSTOM__"; export const CredentialsSchema = z .object({ @@ -269,7 +288,8 @@ export const PlacementSchema = z.object({ allOf: z.array(SignedBySchema), anyOf: z.array(SignedBySchema) }) - .optional() + .optional(), + verification: PlacementVerificationSchema.optional() }); export const EndpointSchema = z.object({ @@ -610,6 +630,7 @@ export type EnvironmentVariableType = z.infer; export type AcceptType = z.infer; export type PlacementAttributeType = z.infer; export type SignedByType = z.infer; +export type PlacementVerificationType = z.infer; export type ExposeType = z.infer; export type PlacementType = z.infer; export type EndpointType = z.infer; diff --git a/apps/deploy-web/src/utils/sdl/sdlGenerator.spec.ts b/apps/deploy-web/src/utils/sdl/sdlGenerator.spec.ts index 94cb9b3863..7dcad192a1 100644 --- a/apps/deploy-web/src/utils/sdl/sdlGenerator.spec.ts +++ b/apps/deploy-web/src/utils/sdl/sdlGenerator.spec.ts @@ -94,6 +94,51 @@ describe("sdlGenerator", () => { expect(parsedAny.profiles.placement["dcloud"].attributes?.["location-region"]).toBeUndefined(); }); + it("emits verification requirements alongside legacy signedBy", () => { + const formValues = buildFormValues(buildLogCollectorService({ title: "web", image: "nginx:latest" })); + formValues.placements[0].signedBy = { anyOf: [{ value: "akash1legacy" }], allOf: [] }; + formValues.placements[0].verification = { + minTier: 3, + capabilities: ["persistent_storage", "bare_metal"], + auditors: [{ value: "akash1auditor1" }, { value: "akash1auditor2" }], + auditorMode: "all", + minAuditorCount: 2 + }; + + const parsed = yaml.load(generateSdl(formValues)) as { + profiles: { placement: Record }; + }; + + expect(parsed.profiles.placement.dcloud).toMatchObject({ + signedBy: { anyOf: ["akash1legacy"] }, + verification: { + min_tier: 3, + capabilities: ["persistent_storage", "bare_metal"], + auditors: ["akash1auditor1", "akash1auditor2"], + auditor_mode: "all", + min_auditor_count: 2 + } + }); + }); + + it("omits verification when the placement has no requirement", () => { + const parsed = yaml.load(generateSdl(buildFormValues(buildLogCollectorService({ title: "web", image: "nginx:latest" })))) as { + profiles: { placement: Record }; + }; + + expect(parsed.profiles.placement.dcloud.verification).toBeUndefined(); + }); + + it("omits empty optional verification collections", () => { + const formValues = buildFormValues(buildLogCollectorService({ title: "web", image: "nginx:latest" })); + formValues.placements[0].verification = { minTier: 1, capabilities: [], auditors: [] }; + const parsed = yaml.load(generateSdl(formValues)) as { + profiles: { placement: Record }> }; + }; + + expect(parsed.profiles.placement.dcloud.verification).toEqual({ min_tier: 1 }); + }); + it("throws when a service references a placementId that does not exist", () => { const formValues = { placements: [{ id: "p-1", name: "dcloud" }], diff --git a/apps/deploy-web/src/utils/sdl/sdlGenerator.ts b/apps/deploy-web/src/utils/sdl/sdlGenerator.ts index 4604f5d9d7..1bfa6f0b1f 100644 --- a/apps/deploy-web/src/utils/sdl/sdlGenerator.ts +++ b/apps/deploy-web/src/utils/sdl/sdlGenerator.ts @@ -29,6 +29,34 @@ const buildGpuAttributes = (interconnect: { group?: string } | undefined): Recor return attributes; }; +type PlacementVerification = NonNullable; +type SdlVerificationRequirement = { + min_tier: PlacementVerification["minTier"]; + capabilities?: PlacementVerification["capabilities"]; + auditors?: string[]; + auditor_mode?: PlacementVerification["auditorMode"]; + min_auditor_count?: PlacementVerification["minAuditorCount"]; +}; + +const buildVerificationRequirement = (verification: PlacementVerification): SdlVerificationRequirement => { + const requirement: SdlVerificationRequirement = { min_tier: verification.minTier }; + + if (verification.capabilities?.length) { + requirement.capabilities = verification.capabilities; + } + if (verification.auditors?.length) { + requirement.auditors = verification.auditors.map(auditor => auditor.value); + } + if (verification.auditorMode) { + requirement.auditor_mode = verification.auditorMode; + } + if (verification.minAuditorCount !== undefined) { + requirement.min_auditor_count = verification.minAuditorCount; + } + + return requirement; +}; + export const generateSdl = (formValues: SdlBuilderFormValuesType) => { const sdl: Record = { version: "2.0", services: {}, profiles: { compute: {}, placement: {} }, deployment: {} }; @@ -54,6 +82,10 @@ export const generateSdl = (formValues: SdlBuilderFormValuesType) => { sdl.profiles.placement[placement.name].signedBy.allOf = placement.signedBy?.allOf.map(x => x.value); } + if (placement.verification) { + sdl.profiles.placement[placement.name].verification = buildVerificationRequirement(placement.verification); + } + if ((placement.attributes?.length || 0) > 0) { sdl.profiles.placement[placement.name].attributes = placement.attributes?.reduce>( (acc, curr) => ((acc[curr.key] = curr.value), acc), diff --git a/apps/deploy-web/src/utils/sdl/sdlImport.spec.ts b/apps/deploy-web/src/utils/sdl/sdlImport.spec.ts index d42b390b0f..93dc1aaea2 100644 --- a/apps/deploy-web/src/utils/sdl/sdlImport.spec.ts +++ b/apps/deploy-web/src/utils/sdl/sdlImport.spec.ts @@ -561,3 +561,131 @@ describe("importSimpleSdl reclamation", () => { expect(parsed.reclamation).toEqual({ min_window: minWindow }); }); }); + +describe("importSimpleSdl verification", () => { + it("imports and regenerates the complete verification requirement independently of signedBy", () => { + const yml = placementRequirementSdl([ + " signedBy:", + " anyOf:", + " - akash1legacy", + " verification:", + " min_tier: 3", + " capabilities:", + " - persistent_storage", + " - bare_metal", + " auditors:", + " - akash1auditor1", + " - akash1auditor2", + " auditor_mode: all", + " min_auditor_count: 2" + ]); + + const imported = importSimpleSdl(yml); + expect(SdlBuilderFormValuesSchema.safeParse(imported).success).toBe(true); + expect(imported.placements[0]).toMatchObject({ + signedBy: { anyOf: [{ value: "akash1legacy" }], allOf: [] }, + verification: { + minTier: 3, + capabilities: ["persistent_storage", "bare_metal"], + auditors: [{ value: "akash1auditor1" }, { value: "akash1auditor2" }], + auditorMode: "all", + minAuditorCount: 2 + } + }); + + const parsed = yaml.load(generateSdl(imported)) as { + profiles: { placement: Record }; + }; + expect(parsed.profiles.placement.dcloud).toMatchObject({ + signedBy: { anyOf: ["akash1legacy"] }, + verification: { + min_tier: 3, + capabilities: ["persistent_storage", "bare_metal"], + auditors: ["akash1auditor1", "akash1auditor2"], + auditor_mode: "all", + min_auditor_count: 2 + } + }); + }); + + it("leaves verification absent when the SDL has no requirement", () => { + const imported = importSimpleSdl(placementRequirementSdl([])); + + expect(imported.placements[0].verification).toBeUndefined(); + const parsed = yaml.load(generateSdl(imported)) as { + profiles: { placement: Record }; + }; + expect(parsed.profiles.placement.dcloud.verification).toBeUndefined(); + }); + + it("leaves invalid verification values to the form schema validation boundary", () => { + const imported = importSimpleSdl( + placementRequirementSdl([" verification:", " min_tier: 5", " capabilities:", " - unknown_capability"]) + ); + + const result = SdlBuilderFormValuesSchema.safeParse(imported); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: ["placements", 0, "verification", "minTier"] }), + expect.objectContaining({ path: ["placements", 0, "verification", "capabilities", 0] }) + ]) + ); + }); + + it.each(["any", "all"])("collapses a vacuous tier-zero verification block with auditor mode %s", auditorMode => { + const imported = importSimpleSdl( + placementRequirementSdl([ + " verification:", + " min_tier: 0", + " capabilities: []", + " auditors: []", + ` auditor_mode: ${auditorMode}`, + " min_auditor_count: 0" + ]) + ); + + expect(imported.placements[0].verification).toBeUndefined(); + const parsed = yaml.load(generateSdl(imported)) as { + profiles: { placement: Record }; + }; + expect(parsed.profiles.placement.dcloud.verification).toBeUndefined(); + }); +}); + +const placementRequirementSdl = (placementLines: string[]) => + [ + "version: '2.0'", + "services:", + " web:", + " image: nginx:latest", + " expose:", + " - port: 80", + " as: 80", + " to:", + " - global: true", + "profiles:", + " compute:", + " web:", + " resources:", + " cpu:", + " units: 0.5", + " memory:", + " size: 512Mi", + " storage:", + " - size: 512Mi", + " placement:", + " dcloud:", + ...placementLines, + " pricing:", + " web:", + " denom: uakt", + " amount: 1000", + "deployment:", + " web:", + " dcloud:", + " profile: web", + " count: 1" + ].join("\n"); diff --git a/apps/deploy-web/src/utils/sdl/sdlImport.ts b/apps/deploy-web/src/utils/sdl/sdlImport.ts index 3526d4a996..017f65b80a 100644 --- a/apps/deploy-web/src/utils/sdl/sdlImport.ts +++ b/apps/deploy-web/src/utils/sdl/sdlImport.ts @@ -7,6 +7,15 @@ import { CustomValidationError } from "../deploymentData"; import { capitalizeFirstLetter } from "../stringUtils"; import { defaultHttpOptions } from "./data"; +type PlacementVerification = NonNullable; +type SdlVerificationRequirement = { + min_tier: number; + capabilities?: PlacementVerification["capabilities"]; + auditors?: string[]; + auditor_mode?: PlacementVerification["auditorMode"]; + min_auditor_count?: number; +}; + /** YAML parses unquoted scalars like `0` or `false` into native types, so tokens are stringified instead of filtered as falsy. */ export const parseSvcCommand = (command?: string | (string | number | boolean)[]): string => { if (!command) { @@ -271,6 +280,23 @@ function hydratePlacement(id: string, name: string, profile: any): PlacementType signedBy: { anyOf: profile.signedBy?.anyOf ? profile.signedBy.anyOf.map((x: string) => ({ id: nanoid(), value: x })) : [], allOf: profile.signedBy?.allOf ? profile.signedBy.allOf.map((x: string) => ({ id: nanoid(), value: x })) : [] - } + }, + verification: hydrateVerification(profile.verification as SdlVerificationRequirement | undefined) + }; +} + +function hydrateVerification(verification: SdlVerificationRequirement | undefined): PlacementType["verification"] { + if (!verification || isVacuousVerification(verification)) return undefined; + + return { + minTier: verification.min_tier, + capabilities: verification.capabilities, + auditors: verification.auditors?.map(value => ({ id: nanoid(), value })), + auditorMode: verification.auditor_mode, + minAuditorCount: verification.min_auditor_count }; } + +function isVacuousVerification(verification: SdlVerificationRequirement): boolean { + return verification.min_tier === 0 && !verification.capabilities?.length && !verification.auditors?.length && (verification.min_auditor_count ?? 0) === 0; +} diff --git a/packages/network-store/package.json b/packages/network-store/package.json index d327cb4813..e7e5e58801 100644 --- a/packages/network-store/package.json +++ b/packages/network-store/package.json @@ -15,6 +15,8 @@ "scripts": { "format": "prettier --write ./*.{ts,json} **/*.{ts,json}", "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest", "validate:types": "tsc --noEmit && echo" }, "dependencies": { @@ -23,6 +25,7 @@ "jotai": "^2.9.2" }, "devDependencies": { - "@akashnetwork/dev-config": "*" + "@akashnetwork/dev-config": "*", + "vitest": "^4.1.5" } } diff --git a/packages/network-store/src/network.config.spec.ts b/packages/network-store/src/network.config.spec.ts new file mode 100644 index 0000000000..365ac8c34f --- /dev/null +++ b/packages/network-store/src/network.config.spec.ts @@ -0,0 +1,52 @@ +import { netConfig } from "@akashnetwork/net"; +import { describe, expect, it } from "vitest"; + +import { getInitialNetworksConfig, resolveAkashSandboxNetworkOverride } from "./network.config"; + +describe(getInitialNetworksConfig.name, () => { + it("preserves the standard network configuration by default", () => { + const networks = getInitialNetworksConfig({ apiBaseUrl: "/api" }); + + expect(networks.map(({ chainId, rpcEndpoint }) => ({ chainId, rpcEndpoint }))).toEqual([ + { chainId: "akashnet-2", rpcEndpoint: netConfig.getBaseRpcUrl("mainnet") }, + { chainId: "sandbox-2", rpcEndpoint: netConfig.getBaseRpcUrl("sandbox") }, + { chainId: "testnet-8", rpcEndpoint: "" } + ]); + }); + + it("overrides only the Akash sandbox network", () => { + const akashSandboxOverride = resolveAkashSandboxNetworkOverride({ + chainId: "aep-86", + rpcUrl: "https://rpc.aep86.example.com", + restApiUrl: "https://rest.aep86.example.com", + genesisUrl: "https://aep86.example.com/genesis.json" + }); + const networks = getInitialNetworksConfig({ apiBaseUrl: "/api", akashSandboxOverride }); + + expect(networks[0]).toMatchObject({ chainId: "akashnet-2", rpcEndpoint: netConfig.getBaseRpcUrl("mainnet") }); + expect(networks[1]).toMatchObject({ chainId: "aep-86", rpcEndpoint: "https://rpc.aep86.example.com" }); + expect(networks[2]).toMatchObject({ chainId: "testnet-8", rpcEndpoint: "" }); + }); +}); + +describe(resolveAkashSandboxNetworkOverride.name, () => { + it("rejects a partial override", () => { + expect(() => + resolveAkashSandboxNetworkOverride({ + chainId: "aep-86", + rpcUrl: "https://rpc.aep86.example.com" + }) + ).toThrow("must be set together"); + }); + + it("rejects non-HTTP endpoints", () => { + expect(() => + resolveAkashSandboxNetworkOverride({ + chainId: "aep-86", + rpcUrl: "tcp://rpc.aep86.example.com", + restApiUrl: "https://rest.aep86.example.com", + genesisUrl: "https://aep86.example.com/genesis.json" + }) + ).toThrow("NEXT_PUBLIC_AKASH_SANDBOX_RPC_URL must use http or https"); + }); +}); diff --git a/packages/network-store/src/network.config.ts b/packages/network-store/src/network.config.ts index 9fac1357b2..3c4c30ae02 100644 --- a/packages/network-store/src/network.config.ts +++ b/packages/network-store/src/network.config.ts @@ -3,7 +3,58 @@ import { netConfig } from "@akashnetwork/net"; import type { Network } from "./network.type"; -export const getInitialNetworksConfig = ({ apiBaseUrl }: { apiBaseUrl: string }): Network[] => [ +export interface AkashSandboxNetworkOverride { + chainId: string; + genesisUrl: string; + restApiUrl: string; + rpcUrl: string; +} + +interface AkashSandboxNetworkOverrideInput { + 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 resolveAkashSandboxNetworkOverride(input: AkashSandboxNetworkOverrideInput): AkashSandboxNetworkOverride | 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]) + }; +} + +export function getAkashSandboxNetworkOverrideFromEnv(): AkashSandboxNetworkOverride | undefined { + return resolveAkashSandboxNetworkOverride({ + 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 getInitialNetworksConfig = ({ + apiBaseUrl, + akashSandboxOverride = getAkashSandboxNetworkOverrideFromEnv() +}: { + apiBaseUrl: string; + akashSandboxOverride?: AkashSandboxNetworkOverride; +}): Network[] => [ { id: MAINNET_ID, title: "Mainnet", @@ -25,9 +76,9 @@ export const getInitialNetworksConfig = ({ apiBaseUrl }: { apiBaseUrl: string }) title: "Sandbox", description: "Sandbox of the mainnet version.", nodesUrl: `${apiBaseUrl}/blockchain-config?network=sandbox`, - chainId: "sandbox-2", + chainId: akashSandboxOverride?.chainId ?? "sandbox-2", chainRegistryName: "akash-sandbox", - rpcEndpoint: netConfig.getBaseRpcUrl(SANDBOX_ID), + rpcEndpoint: akashSandboxOverride?.rpcUrl ?? netConfig.getBaseRpcUrl(SANDBOX_ID), version: netConfig.getVersion(SANDBOX_ID), enabled: true, deploymentVersion: "v1beta4", @@ -53,3 +104,16 @@ export const getInitialNetworksConfig = ({ apiBaseUrl }: { apiBaseUrl: string }) version: null } ]; + +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; +} From 5c7808ae73e4944aa06829a4c50b71e5ccece031 Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 20:36:47 -0700 Subject: [PATCH 06/12] feat(notifications): alert on provider verification changes Notify affected leases about maintenance windows and tier demotions while deduplicating chain events and preserving leases through policy drift. Signed-off-by: Joseph Chalabi --- .../drizzle/0006_romantic_sasquatch.sql | 33 + .../drizzle/meta/0006_snapshot.json | 725 ++++++++++++++++++ apps/notifications/drizzle/meta/_journal.json | 7 + .../config/event-key-registry.config.ts | 1 + .../broker/services/broker/event-map.ts | 2 + .../chain-events/chain-events.handler.spec.ts | 20 +- .../chain-events/chain-events.handler.ts | 11 + .../src/modules/alert/alert.module.ts | 11 + .../modules/alert/config/env.config.spec.ts | 35 + .../src/modules/alert/config/env.config.ts | 34 +- .../event-provider-maintenance-opened.dto.ts | 15 + .../src/modules/alert/model-schemas/index.ts | 1 + .../provider-tier-demotion.schema.ts | 58 ++ .../alert/alert-json-fields.schema.ts | 15 +- .../repositories/alert/alert.repository.ts | 99 +++ .../provider-tier-demotion.repository.ts | 191 +++++ .../provider-active-leases.service.spec.ts | 106 +++ .../provider-active-leases.service.ts | 35 + ...provider-maintenance-alert.service.spec.ts | 128 ++++ .../provider-maintenance-alert.service.ts | 63 ++ ...ovider-tier-demotion-alert.service.spec.ts | 207 +++++ .../provider-tier-demotion-alert.service.ts | 171 +++++ ...rovider-tier-demotion-feed.service.spec.ts | 72 ++ .../provider-tier-demotion-feed.service.ts | 30 + .../alert/types/provider-lease.type.ts | 8 + .../types/provider-tier-demotion.type.ts | 32 + .../chain/providers/registry.provider.spec.ts | 13 + .../chain/providers/registry.provider.ts | 20 +- .../chain-events-poller.service.spec.ts | 22 +- .../chain-events-poller.service.ts | 3 +- .../tx-events.service.spec.ts | 81 +- .../tx-events-service/tx-events.service.ts | 11 +- .../provider-maintenance-alert.spec.ts | 220 ++++++ .../provider-tier-demotion-alert.spec.ts | 89 +++ 34 files changed, 2543 insertions(+), 26 deletions(-) create mode 100644 apps/notifications/drizzle/0006_romantic_sasquatch.sql create mode 100644 apps/notifications/drizzle/meta/0006_snapshot.json create mode 100644 apps/notifications/src/modules/alert/config/env.config.spec.ts create mode 100644 apps/notifications/src/modules/alert/dto/event-provider-maintenance-opened.dto.ts create mode 100644 apps/notifications/src/modules/alert/model-schemas/provider-tier-demotion.schema.ts create mode 100644 apps/notifications/src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository.ts create mode 100644 apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.spec.ts create mode 100644 apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.ts create mode 100644 apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.spec.ts create mode 100644 apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.ts create mode 100644 apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.spec.ts create mode 100644 apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.ts create mode 100644 apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.spec.ts create mode 100644 apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.ts create mode 100644 apps/notifications/src/modules/alert/types/provider-lease.type.ts create mode 100644 apps/notifications/src/modules/alert/types/provider-tier-demotion.type.ts create mode 100644 apps/notifications/src/modules/chain/providers/registry.provider.spec.ts create mode 100644 apps/notifications/test/functional/provider-maintenance-alert.spec.ts create mode 100644 apps/notifications/test/functional/provider-tier-demotion-alert.spec.ts diff --git a/apps/notifications/drizzle/0006_romantic_sasquatch.sql b/apps/notifications/drizzle/0006_romantic_sasquatch.sql new file mode 100644 index 0000000000..17722516b1 --- /dev/null +++ b/apps/notifications/drizzle/0006_romantic_sasquatch.sql @@ -0,0 +1,33 @@ +CREATE TYPE "public"."provider_tier_demotion_notification_status" AS ENUM('PENDING', 'SENT');--> statement-breakpoint +CREATE TABLE "provider_tier_demotion_notifications" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL, + "stream_id" uuid NOT NULL, + "cursor" bigint NOT NULL, + "alert_id" uuid NOT NULL, + "provider" text NOT NULL, + "owner" text NOT NULL, + "dseq" text NOT NULL, + "gseq" integer NOT NULL, + "oseq" integer NOT NULL, + "bseq" integer NOT NULL, + "status" "provider_tier_demotion_notification_status" DEFAULT 'PENDING' NOT NULL, + "claim_id" uuid NOT NULL, + "claimed_at" timestamp with time zone DEFAULT now() NOT NULL, + "sent_at" timestamp with time zone, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "provider_tier_demotion_state" ( + "id" integer PRIMARY KEY DEFAULT 1 NOT NULL, + "stream_id" uuid, + "cursor" bigint DEFAULT 0 NOT NULL, + "claim_id" uuid, + "claim_expires_at" timestamp with time zone, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "provider_tier_demotion_notifications" ADD CONSTRAINT "provider_tier_demotion_notifications_alert_id_alerts_id_fk" FOREIGN KEY ("alert_id") REFERENCES "public"."alerts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "provider_tier_demotion_notifications_delivery_uidx" ON "provider_tier_demotion_notifications" USING btree ("stream_id","cursor","alert_id","owner","dseq","gseq","oseq","bseq","provider");--> statement-breakpoint +CREATE INDEX "provider_tier_demotion_notifications_status_idx" ON "provider_tier_demotion_notifications" USING btree ("status","claimed_at"); \ No newline at end of file diff --git a/apps/notifications/drizzle/meta/0006_snapshot.json b/apps/notifications/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000000..2e5a23b95a --- /dev/null +++ b/apps/notifications/drizzle/meta/0006_snapshot.json @@ -0,0 +1,725 @@ +{ + "id": "5311eb6f-84e6-4f63-86f9-e1fa9d5923d5", + "prevId": "9360dd65-3d4f-40bc-9be1-92015414ec1a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.alerts": { + "name": "alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "type": { + "name": "type", + "type": "alert_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "alert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OK'" + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "min_block_height": { + "name": "min_block_height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_alerts_user_id": { + "name": "idx_alerts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_notification_channel_id": { + "name": "idx_alerts_notification_channel_id", + "columns": [ + { + "expression": "notification_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_status": { + "name": "idx_alerts_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_min_block_height_id": { + "name": "idx_alerts_min_block_height_id", + "columns": [ + { + "expression": "min_block_height", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_created_at_id": { + "name": "idx_alerts_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_alerts_params": { + "name": "idx_alerts_params", + "columns": [ + { + "expression": "\"params\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_alerts_enabled_type_status": { + "name": "idx_alerts_enabled_type_status", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alerts_notification_channel_id_notification_channels_id_fk": { + "name": "alerts_notification_channel_id_notification_channels_id_fk", + "tableFrom": "alerts", + "tableTo": "notification_channels", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_tier_demotion_notifications": { + "name": "provider_tier_demotion_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "stream_id": { + "name": "stream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dseq": { + "name": "dseq", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gseq": { + "name": "gseq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "oseq": { + "name": "oseq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bseq": { + "name": "bseq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "provider_tier_demotion_notification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "claim_id": { + "name": "claim_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_tier_demotion_notifications_delivery_uidx": { + "name": "provider_tier_demotion_notifications_delivery_uidx", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cursor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "alert_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dseq", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gseq", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "oseq", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bseq", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_tier_demotion_notifications_status_idx": { + "name": "provider_tier_demotion_notifications_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_tier_demotion_notifications_alert_id_alerts_id_fk": { + "name": "provider_tier_demotion_notifications_alert_id_alerts_id_fk", + "tableFrom": "provider_tier_demotion_notifications", + "tableTo": "alerts", + "columnsFrom": [ + "alert_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_tier_demotion_state": { + "name": "provider_tier_demotion_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "stream_id": { + "name": "stream_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cursor": { + "name": "cursor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "claim_id": { + "name": "claim_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.block_cursor": { + "name": "block_cursor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'latest'" + }, + "last_processed_block": { + "name": "last_processed_block", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channels": { + "name": "notification_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_channel_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_notification_channels_user_id": { + "name": "idx_notification_channels_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_channels_user_id_is_default": { + "name": "idx_notification_channels_user_id_is_default", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true and deleted_at is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.alert_status": { + "name": "alert_status", + "schema": "public", + "values": [ + "OK", + "TRIGGERED" + ] + }, + "public.alert_type": { + "name": "alert_type", + "schema": "public", + "values": [ + "CHAIN_MESSAGE", + "DEPLOYMENT_BALANCE", + "CHAIN_EVENT", + "WALLET_BALANCE" + ] + }, + "public.provider_tier_demotion_notification_status": { + "name": "provider_tier_demotion_notification_status", + "schema": "public", + "values": [ + "PENDING", + "SENT" + ] + }, + "public.notification_channel_type": { + "name": "notification_channel_type", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/notifications/drizzle/meta/_journal.json b/apps/notifications/drizzle/meta/_journal.json index 78ab5e1355..3bd011cb22 100644 --- a/apps/notifications/drizzle/meta/_journal.json +++ b/apps/notifications/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1762440232870, "tag": "0005_left_magus", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1787637958458, + "tag": "0006_romantic_sasquatch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/notifications/src/common/config/event-key-registry.config.ts b/apps/notifications/src/common/config/event-key-registry.config.ts index e5009dc311..53f8102257 100644 --- a/apps/notifications/src/common/config/event-key-registry.config.ts +++ b/apps/notifications/src/common/config/event-key-registry.config.ts @@ -2,5 +2,6 @@ export const eventKeyRegistry = { blockCreated: "blockchain.v1.block.created", eventCloseDeployment: "akash.v1.deployment.deployment-closed", eventLeaseReclaimStarted: "akash.v1.market.lease-reclaim-started", + eventProviderMaintenanceOpened: "akash.v1beta4.provider.provider-maintenance-opened", createNotification: "notifications.v1.notification.create" } as const; diff --git a/apps/notifications/src/infrastructure/broker/services/broker/event-map.ts b/apps/notifications/src/infrastructure/broker/services/broker/event-map.ts index 9154045cef..95fb3136c7 100644 --- a/apps/notifications/src/infrastructure/broker/services/broker/event-map.ts +++ b/apps/notifications/src/infrastructure/broker/services/broker/event-map.ts @@ -2,6 +2,7 @@ import type { eventKeyRegistry } from "@src/common/config/event-key-registry.con import type { ChainBlockCreatedDto } from "@src/modules/alert/dto/chain-block-created.dto"; import type { EventClosedDeploymentDto } from "@src/modules/alert/dto/event-closed-deployment.dto"; import type { EventLeaseReclaimStartedDto } from "@src/modules/alert/dto/event-lease-reclaim-started.dto"; +import type { EventProviderMaintenanceOpenedDto } from "@src/modules/alert/dto/event-provider-maintenance-opened.dto"; import type { AlertMessage } from "@src/modules/alert/types/message-callback.type"; export type EventToPayload = { @@ -9,4 +10,5 @@ export type EventToPayload = { [eventKeyRegistry.blockCreated]: ChainBlockCreatedDto; [eventKeyRegistry.eventCloseDeployment]: EventClosedDeploymentDto; [eventKeyRegistry.eventLeaseReclaimStarted]: EventLeaseReclaimStartedDto; + [eventKeyRegistry.eventProviderMaintenanceOpened]: EventProviderMaintenanceOpenedDto; }; diff --git a/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.spec.ts b/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.spec.ts index 11cd72e021..1a296fd9f3 100644 --- a/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.spec.ts +++ b/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.spec.ts @@ -9,8 +9,10 @@ import { BrokerService } from "@src/infrastructure/broker"; import { ChainBlockCreatedDto } from "@src/modules/alert/dto/chain-block-created.dto"; import { EventClosedDeploymentDto } from "@src/modules/alert/dto/event-closed-deployment.dto"; import { EventLeaseReclaimStartedDto } from "@src/modules/alert/dto/event-lease-reclaim-started.dto"; +import { EventProviderMaintenanceOpenedDto } from "@src/modules/alert/dto/event-provider-maintenance-opened.dto"; import { ChainAlertService } from "@src/modules/alert/services/chain-alert/chain-alert.service"; import { DeploymentBalanceAlertsService } from "@src/modules/alert/services/deployment-balance-alerts/deployment-balance-alerts.service"; +import { ProviderMaintenanceAlertService } from "@src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service"; import { ReclaimAlertService } from "@src/modules/alert/services/reclaim-alert/reclaim-alert.service"; import { WalletBalanceAlertsService } from "@src/modules/alert/services/wallet-balance-alerts/wallet-balance-alerts.service"; import { ChainEventsHandler } from "./chain-events.handler"; @@ -65,6 +67,20 @@ describe(ChainEventsHandler.name, () => { }); }); + describe("processProviderMaintenanceOpened", () => { + it("routes provider maintenance through the notification broker", async () => { + const { controller, providerMaintenanceAlertService, brokerService } = await setup(); + const event = generateMock(EventProviderMaintenanceOpenedDto.schema); + const alertMessage = generateAlertMessage({}); + providerMaintenanceAlertService.alertFor.mockImplementation((_, callback) => callback(alertMessage)); + + await controller.processProviderMaintenanceOpened(event); + + expect(providerMaintenanceAlertService.alertFor).toHaveBeenCalledWith(event, expect.any(Function)); + expect(brokerService.publish).toHaveBeenCalledWith(eventKeyRegistry.createNotification, alertMessage); + }); + }); + async function setup() { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -73,7 +89,8 @@ describe(ChainEventsHandler.name, () => { MockProvider(ChainAlertService), MockProvider(DeploymentBalanceAlertsService), MockProvider(WalletBalanceAlertsService), - MockProvider(ReclaimAlertService) + MockProvider(ReclaimAlertService), + MockProvider(ProviderMaintenanceAlertService) ] }).compile(); @@ -83,6 +100,7 @@ describe(ChainEventsHandler.name, () => { deploymentBalanceAlertsService: module.get>(DeploymentBalanceAlertsService), walletBalanceAlertsService: module.get>(WalletBalanceAlertsService), reclaimAlertService: module.get>(ReclaimAlertService), + providerMaintenanceAlertService: module.get>(ProviderMaintenanceAlertService), brokerService: module.get>(BrokerService) }; } diff --git a/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.ts b/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.ts index af81b3061f..41655aae8c 100644 --- a/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.ts +++ b/apps/notifications/src/interfaces/alert-events/handlers/chain-events/chain-events.handler.ts @@ -5,7 +5,9 @@ import { BrokerService, Handler } from "@src/infrastructure/broker"; import { ChainBlockCreatedDto } from "@src/modules/alert/dto/chain-block-created.dto"; import { EventClosedDeploymentDto } from "@src/modules/alert/dto/event-closed-deployment.dto"; import { EventLeaseReclaimStartedDto } from "@src/modules/alert/dto/event-lease-reclaim-started.dto"; +import { EventProviderMaintenanceOpenedDto } from "@src/modules/alert/dto/event-provider-maintenance-opened.dto"; import { ChainAlertService } from "@src/modules/alert/services/chain-alert/chain-alert.service"; +import { ProviderMaintenanceAlertService } from "@src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service"; import { ReclaimAlertService } from "@src/modules/alert/services/reclaim-alert/reclaim-alert.service"; import { WalletBalanceAlertsService } from "@src/modules/alert/services/wallet-balance-alerts/wallet-balance-alerts.service"; @@ -15,6 +17,7 @@ export class ChainEventsHandler { private readonly chainMessageAlertService: ChainAlertService, private readonly walletBalanceAlertsService: WalletBalanceAlertsService, private readonly reclaimAlertService: ReclaimAlertService, + private readonly providerMaintenanceAlertService: ProviderMaintenanceAlertService, private readonly brokerService: BrokerService ) {} @@ -43,4 +46,12 @@ export class ChainEventsHandler { async processLeaseReclaimStarted(payload: EventLeaseReclaimStartedDto): Promise { await this.reclaimAlertService.alertFor(payload, message => this.brokerService.publish(eventKeyRegistry.createNotification, message)); } + + @Handler({ + key: eventKeyRegistry.eventProviderMaintenanceOpened, + dto: EventProviderMaintenanceOpenedDto + }) + async processProviderMaintenanceOpened(payload: EventProviderMaintenanceOpenedDto): Promise { + await this.providerMaintenanceAlertService.alertFor(payload, message => this.brokerService.publish(eventKeyRegistry.createNotification, message)); + } } diff --git a/apps/notifications/src/modules/alert/alert.module.ts b/apps/notifications/src/modules/alert/alert.module.ts index d5c7139b69..852fce9ebe 100644 --- a/apps/notifications/src/modules/alert/alert.module.ts +++ b/apps/notifications/src/modules/alert/alert.module.ts @@ -10,8 +10,13 @@ import { register } from "@src/infrastructure/db/db.module"; import type { FullSchema } from "@src/infrastructure/db/full-schema"; import { DbHealthzService } from "@src/infrastructure/db/services/db-healthz/db-healthz.service"; import { AlertRepository } from "@src/modules/alert/repositories/alert/alert.repository"; +import { ProviderTierDemotionRepository } from "@src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository"; import { ChainAlertService } from "@src/modules/alert/services/chain-alert/chain-alert.service"; import { DeploymentAlertService } from "@src/modules/alert/services/deployment-alert/deployment-alert.service"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import { ProviderMaintenanceAlertService } from "@src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service"; +import { ProviderTierDemotionAlertService } from "@src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service"; +import { ProviderTierDemotionFeedService } from "@src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service"; import { ReclaimAlertService } from "@src/modules/alert/services/reclaim-alert/reclaim-alert.service"; import { WalletBalanceAlertsService } from "@src/modules/alert/services/wallet-balance-alerts/wallet-balance-alerts.service"; import { HTTP_SDK_PROVIDERS } from "./providers/http-sdk.provider"; @@ -36,6 +41,11 @@ import * as schema from "./model-schemas"; TemplateService, DeploymentAlertService, ReclaimAlertService, + ProviderActiveLeasesService, + ProviderMaintenanceAlertService, + ProviderTierDemotionRepository, + ProviderTierDemotionFeedService, + ProviderTierDemotionAlertService, DbHealthzService, ...HTTP_SDK_PROVIDERS ], @@ -46,6 +56,7 @@ import * as schema from "./model-schemas"; AlertRepository, DeploymentAlertService, ReclaimAlertService, + ProviderMaintenanceAlertService, DbHealthzService ] }) diff --git a/apps/notifications/src/modules/alert/config/env.config.spec.ts b/apps/notifications/src/modules/alert/config/env.config.spec.ts new file mode 100644 index 0000000000..4c8e017a8a --- /dev/null +++ b/apps/notifications/src/modules/alert/config/env.config.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { schema } from "@src/modules/alert/config/env.config"; + +const baseEnv = { + API_NODE_ENDPOINT: "https://rpc.akt.dev/rest", + CONSOLE_WEB_URL: "console.akash.network" +}; + +describe("alert environment config", () => { + it("disables maintenance alerts by default", () => { + expect(schema.parse(baseEnv).PROVIDER_MAINTENANCE_ALERTS_ENABLED).toBe(false); + }); + + it("enables maintenance alerts without a Console API dependency", () => { + const result = schema.parse({ ...baseEnv, PROVIDER_MAINTENANCE_ALERTS_ENABLED: "true" }); + + expect(result.PROVIDER_MAINTENANCE_ALERTS_ENABLED).toBe(true); + }); + + it("disables provider tier-demotion alerts by default", () => { + expect(schema.parse(baseEnv).PROVIDER_TIER_DEMOTION_ALERTS_ENABLED).toBe(false); + }); + + it("requires the Console API only when provider tier-demotion alerts are enabled", () => { + expect(() => schema.parse({ ...baseEnv, PROVIDER_TIER_DEMOTION_ALERTS_ENABLED: "true" })).toThrow("CONSOLE_API_ENDPOINT is required"); + + const result = schema.parse({ + ...baseEnv, + CONSOLE_API_ENDPOINT: "https://api.akash.network", + PROVIDER_TIER_DEMOTION_ALERTS_ENABLED: "true" + }); + expect(result.PROVIDER_TIER_DEMOTION_ALERTS_ENABLED).toBe(true); + }); +}); diff --git a/apps/notifications/src/modules/alert/config/env.config.ts b/apps/notifications/src/modules/alert/config/env.config.ts index 11234d17b4..b150ed2bb3 100644 --- a/apps/notifications/src/modules/alert/config/env.config.ts +++ b/apps/notifications/src/modules/alert/config/env.config.ts @@ -1,9 +1,33 @@ import { z } from "zod"; -export const schema = z.object({ - API_NODE_ENDPOINT: z.string(), - CONSOLE_WEB_URL: z.string(), - DEPLOYMENT_BALANCE_BLOCKS_THROTTLE: z.number({ coerce: true }).optional().default(10) -}); +export const schema = z + .object({ + API_NODE_ENDPOINT: z.string(), + CONSOLE_API_ENDPOINT: z.string().url().optional(), + CONSOLE_API_SECRET_TOKEN: z.string().optional(), + CONSOLE_WEB_URL: z.string(), + DEPLOYMENT_BALANCE_BLOCKS_THROTTLE: z.number({ coerce: true }).optional().default(10), + PROVIDER_MAINTENANCE_ALERTS_ENABLED: z + .enum(["true", "false"]) + .optional() + .default("false") + .transform(value => value === "true"), + PROVIDER_TIER_DEMOTION_ALERTS_ENABLED: z + .enum(["true", "false"]) + .optional() + .default("false") + .transform(value => value === "true"), + PROVIDER_TIER_DEMOTION_POLL_INTERVAL_MS: z.number({ coerce: true }).int().positive().optional().default(15000), + PROVIDER_TIER_DEMOTION_PAGE_SIZE: z.number({ coerce: true }).int().min(1).max(100).optional().default(100) + }) + .superRefine((env, context) => { + if (env.PROVIDER_TIER_DEMOTION_ALERTS_ENABLED && !env.CONSOLE_API_ENDPOINT) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["CONSOLE_API_ENDPOINT"], + message: "CONSOLE_API_ENDPOINT is required when provider tier-demotion alerts are enabled" + }); + } + }); export type AlertEnvConfig = z.infer; diff --git a/apps/notifications/src/modules/alert/dto/event-provider-maintenance-opened.dto.ts b/apps/notifications/src/modules/alert/dto/event-provider-maintenance-opened.dto.ts new file mode 100644 index 0000000000..ecfe64c2a9 --- /dev/null +++ b/apps/notifications/src/modules/alert/dto/event-provider-maintenance-opened.dto.ts @@ -0,0 +1,15 @@ +import { createZodDto } from "nestjs-zod"; +import { z } from "zod"; + +const EventProviderMaintenanceOpenedSchema = z.object({ + module: z.literal("provider"), + action: z.literal("provider-maintenance-opened"), + maintenance_id: z.union([z.string(), z.number()]).transform(String), + provider: z.string(), + maintenance_type: z.union([z.string(), z.number()]).transform(String), + starts_at: z.string().datetime(), + expected_ends_at: z.string().datetime(), + metadata_hash: z.string().optional() +}); + +export class EventProviderMaintenanceOpenedDto extends createZodDto(EventProviderMaintenanceOpenedSchema) {} diff --git a/apps/notifications/src/modules/alert/model-schemas/index.ts b/apps/notifications/src/modules/alert/model-schemas/index.ts index 5ad17470b6..dc85341b9b 100644 --- a/apps/notifications/src/modules/alert/model-schemas/index.ts +++ b/apps/notifications/src/modules/alert/model-schemas/index.ts @@ -1 +1,2 @@ export * from "./alert.schema"; +export * from "./provider-tier-demotion.schema"; diff --git a/apps/notifications/src/modules/alert/model-schemas/provider-tier-demotion.schema.ts b/apps/notifications/src/modules/alert/model-schemas/provider-tier-demotion.schema.ts new file mode 100644 index 0000000000..7658a3d05d --- /dev/null +++ b/apps/notifications/src/modules/alert/model-schemas/provider-tier-demotion.schema.ts @@ -0,0 +1,58 @@ +import { sql } from "drizzle-orm"; +import { bigint, index, integer, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; + +import { timestamps } from "@src/lib/db/timestamps"; +import { Alert } from "./alert.schema"; + +export const ProviderTierDemotionNotificationStatus = pgEnum("provider_tier_demotion_notification_status", ["PENDING", "SENT"]); + +export const ProviderTierDemotionState = pgTable("provider_tier_demotion_state", { + id: integer("id").primaryKey().notNull().default(1), + streamId: uuid("stream_id"), + cursor: bigint("cursor", { mode: "bigint" }) + .notNull() + .default(sql`0`), + claimId: uuid("claim_id"), + claimExpiresAt: timestamp("claim_expires_at", { withTimezone: true }), + ...timestamps +}); + +export const ProviderTierDemotionNotification = pgTable( + "provider_tier_demotion_notifications", + { + id: uuid("id") + .primaryKey() + .notNull() + .default(sql`uuid_generate_v4()`), + streamId: uuid("stream_id").notNull(), + cursor: bigint("cursor", { mode: "bigint" }).notNull(), + alertId: uuid("alert_id") + .notNull() + .references(() => Alert.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + owner: text("owner").notNull(), + dseq: text("dseq").notNull(), + gseq: integer("gseq").notNull(), + oseq: integer("oseq").notNull(), + bseq: integer("bseq").notNull(), + status: ProviderTierDemotionNotificationStatus("status").notNull().default("PENDING"), + claimId: uuid("claim_id").notNull(), + claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(), + sentAt: timestamp("sent_at", { withTimezone: true }), + ...timestamps + }, + table => [ + uniqueIndex("provider_tier_demotion_notifications_delivery_uidx").on( + table.streamId, + table.cursor, + table.alertId, + table.owner, + table.dseq, + table.gseq, + table.oseq, + table.bseq, + table.provider + ), + index("provider_tier_demotion_notifications_status_idx").on(table.status, table.claimedAt) + ] +); diff --git a/apps/notifications/src/modules/alert/repositories/alert/alert-json-fields.schema.ts b/apps/notifications/src/modules/alert/repositories/alert/alert-json-fields.schema.ts index 8c89d53bdb..316d077128 100644 --- a/apps/notifications/src/modules/alert/repositories/alert/alert-json-fields.schema.ts +++ b/apps/notifications/src/modules/alert/repositories/alert/alert-json-fields.schema.ts @@ -46,11 +46,24 @@ export const walletBalanceParamsSchema = z.object({ suppressedBySystem: z.boolean().optional() }); +const providerMaintenanceNotificationSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("pending"), + claimId: z.string().uuid(), + claimedAt: z.string() + }), + z.object({ + status: z.literal("sent"), + sentAt: z.string() + }) +]); + export const generalParamsSchema = z.object({ dseq: dseqSchema, type: z.string(), suppressedBySystem: z.boolean().optional(), - reclaimNotifiedAt: z.string().optional() + reclaimNotifiedAt: z.string().optional(), + providerMaintenanceNotifications: z.record(providerMaintenanceNotificationSchema).optional() }); export const chainMessageTypeSchema = z.literal("CHAIN_MESSAGE"); diff --git a/apps/notifications/src/modules/alert/repositories/alert/alert.repository.ts b/apps/notifications/src/modules/alert/repositories/alert/alert.repository.ts index 3d563cf895..9c3ff3caa3 100644 --- a/apps/notifications/src/modules/alert/repositories/alert/alert.repository.ts +++ b/apps/notifications/src/modules/alert/repositories/alert/alert.repository.ts @@ -6,9 +6,11 @@ import { and, count, eq, gt, lte, ne, or, sql } from "drizzle-orm"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import type { SQL } from "drizzle-orm/sql/sql"; import difference from "lodash/difference"; +import { randomUUID } from "node:crypto"; import { DRIZZLE_PROVIDER_TOKEN } from "@src/infrastructure/db/config/db.config"; import { DrizzleAbility } from "@src/lib/drizzle-ability/drizzle-ability"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; import { NotificationChannel } from "@src/modules/notifications/model-schemas"; import * as schema from "../../model-schemas"; import type { DeploymentBalanceJsonFields, GeneralJsonFields, WalletBalanceJsonFields } from "./alert-json-fields.schema"; @@ -81,6 +83,11 @@ export interface FindAllDeploymentAlertsConditions { includeSuppressed?: boolean; } +export interface ProviderMaintenanceNotificationClaim { + alert: AlertOutput; + claimId: string; +} + /** * The per-deployment escrow-balance alert is retired: the block worker no longer evaluates it. * Rows created before the retirement still exist, so they must stay out of anything a user can @@ -88,6 +95,7 @@ export interface FindAllDeploymentAlertsConditions { * while being invisible in the UI. */ const RETIRED_ALERT_TYPE: AlertType = "DEPLOYMENT_BALANCE"; +const PROVIDER_MAINTENANCE_CLAIM_TIMEOUT_MS = 5 * 60 * 1000; @Injectable() export class AlertRepository { @@ -220,6 +228,97 @@ export class AlertRepository { }); } + async claimProviderMaintenanceNotification( + id: string, + provider: string, + maintenanceId: string, + lease: ProviderLeaseId + ): Promise { + const notificationKey = this.toProviderMaintenanceNotificationKey(provider, maintenanceId, lease); + const claimId = randomUUID(); + + return this.db.transaction(async transaction => { + const [alert] = await transaction + .update(schema.Alert) + .set({ + params: sql`jsonb_set( + COALESCE(${schema.Alert.params}, '{}'::jsonb), + '{providerMaintenanceNotifications}', + COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb) + || jsonb_build_object( + ${notificationKey}::text, + jsonb_build_object('status', 'pending', 'claimId', ${claimId}::text, 'claimedAt', NOW()) + ) + )`, + updatedAt: sql`NOW()` + }) + .where( + and( + eq(schema.Alert.id, id), + sql`NOT (COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb) ? ${notificationKey}::text) + OR ( + COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'status' = 'pending' + AND ( + COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'claimedAt' + )::timestamptz <= NOW() - (${PROVIDER_MAINTENANCE_CLAIM_TIMEOUT_MS} * INTERVAL '1 millisecond') + )` + ) + ) + .returning(); + + return alert && { alert: this.toOutput(alert), claimId }; + }); + } + + async completeProviderMaintenanceNotification(id: string, provider: string, maintenanceId: string, lease: ProviderLeaseId, claimId: string): Promise { + const notificationKey = this.toProviderMaintenanceNotificationKey(provider, maintenanceId, lease); + + await this.db + .update(schema.Alert) + .set({ + params: sql`jsonb_set( + COALESCE(${schema.Alert.params}, '{}'::jsonb), + '{providerMaintenanceNotifications}', + COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb) + || jsonb_build_object(${notificationKey}::text, jsonb_build_object('status', 'sent', 'sentAt', NOW())) + )`, + updatedAt: sql`NOW()` + }) + .where( + and( + eq(schema.Alert.id, id), + sql`COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'status' = 'pending'`, + sql`COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'claimId' = ${claimId}::text` + ) + ); + } + + async releaseProviderMaintenanceNotification(id: string, provider: string, maintenanceId: string, lease: ProviderLeaseId, claimId: string): Promise { + const notificationKey = this.toProviderMaintenanceNotificationKey(provider, maintenanceId, lease); + + await this.db + .update(schema.Alert) + .set({ + params: sql`jsonb_set( + COALESCE(${schema.Alert.params}, '{}'::jsonb), + '{providerMaintenanceNotifications}', + COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb) - ${notificationKey}::text + )`, + updatedAt: sql`NOW()` + }) + .where( + and( + eq(schema.Alert.id, id), + sql`COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'status' = 'pending'`, + sql`COALESCE(${schema.Alert.params}->'providerMaintenanceNotifications', '{}'::jsonb)->${notificationKey}::text->>'claimId' = ${claimId}::text` + ) + ); + } + + private toProviderMaintenanceNotificationKey(provider: string, maintenanceId: string, lease: ProviderLeaseId): string { + return [provider, maintenanceId, lease.owner, lease.dseq, lease.gseq, lease.oseq, lease.bseq, lease.provider].join("/"); + } + async deleteOneById(id: string): Promise { return this.db.transaction(async transaction => { const [alert] = await transaction diff --git a/apps/notifications/src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository.ts b/apps/notifications/src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository.ts new file mode 100644 index 0000000000..69753843ee --- /dev/null +++ b/apps/notifications/src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository.ts @@ -0,0 +1,191 @@ +import { InjectDrizzle } from "@knaadh/nestjs-drizzle-pg"; +import { Injectable } from "@nestjs/common"; +import { and, eq, isNull, lt, or } from "drizzle-orm"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { randomUUID } from "node:crypto"; + +import { DRIZZLE_PROVIDER_TOKEN } from "@src/infrastructure/db/config/db.config"; +import * as schema from "@src/modules/alert/model-schemas"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; + +const FEED_CLAIM_TTL_MS = 15 * 60 * 1000; +const DELIVERY_CLAIM_TTL_MS = FEED_CLAIM_TTL_MS; + +export interface ProviderTierDemotionFeedClaim { + claimId: string; + streamId: string | null; + cursor: string; +} + +export interface ProviderTierDemotionDelivery { + streamId: string; + cursor: string; + alertId: string; + provider: string; + lease: ProviderLeaseId; +} + +export type ProviderTierDemotionDeliveryClaim = { status: "claimed"; claimId: string } | { status: "sent" } | { status: "busy" }; + +@Injectable() +export class ProviderTierDemotionRepository { + constructor( + @InjectDrizzle(DRIZZLE_PROVIDER_TOKEN) + private readonly db: NodePgDatabase + ) {} + + async claimFeed(): Promise { + await this.db.insert(schema.ProviderTierDemotionState).values({ id: 1 }).onConflictDoNothing(); + + const claimId = randomUUID(); + const now = new Date(); + const [state] = await this.db + .update(schema.ProviderTierDemotionState) + .set({ + claimId, + claimExpiresAt: new Date(now.getTime() + FEED_CLAIM_TTL_MS), + updatedAt: now + }) + .where( + and( + eq(schema.ProviderTierDemotionState.id, 1), + or(isNull(schema.ProviderTierDemotionState.claimExpiresAt), lt(schema.ProviderTierDemotionState.claimExpiresAt, now)) + ) + ) + .returning(); + + return ( + state && { + claimId, + streamId: state.streamId, + cursor: state.cursor.toString() + } + ); + } + + async setFeedPosition(claimId: string, streamId: string, cursor: string): Promise { + const rows = await this.db + .update(schema.ProviderTierDemotionState) + .set({ streamId, cursor: BigInt(cursor), updatedAt: new Date() }) + .where(and(eq(schema.ProviderTierDemotionState.id, 1), eq(schema.ProviderTierDemotionState.claimId, claimId))) + .returning({ id: schema.ProviderTierDemotionState.id }); + + if (rows.length !== 1) throw new Error("Provider tier-demotion feed claim was lost"); + } + + async advanceFeed(claimId: string, streamId: string, cursor: string): Promise { + const rows = await this.db + .update(schema.ProviderTierDemotionState) + .set({ cursor: BigInt(cursor), updatedAt: new Date() }) + .where( + and( + eq(schema.ProviderTierDemotionState.id, 1), + eq(schema.ProviderTierDemotionState.claimId, claimId), + eq(schema.ProviderTierDemotionState.streamId, streamId), + lt(schema.ProviderTierDemotionState.cursor, BigInt(cursor)) + ) + ) + .returning({ id: schema.ProviderTierDemotionState.id }); + + if (rows.length !== 1) throw new Error("Provider tier-demotion feed claim was lost"); + } + + async releaseFeed(claimId: string): Promise { + await this.db + .update(schema.ProviderTierDemotionState) + .set({ claimId: null, claimExpiresAt: null, updatedAt: new Date() }) + .where(and(eq(schema.ProviderTierDemotionState.id, 1), eq(schema.ProviderTierDemotionState.claimId, claimId))); + } + + async claimDelivery(delivery: ProviderTierDemotionDelivery): Promise { + const claimId = randomUUID(); + const values = this.deliveryValues(delivery, claimId); + const inserted = await this.db + .insert(schema.ProviderTierDemotionNotification) + .values(values) + .onConflictDoNothing() + .returning({ claimId: schema.ProviderTierDemotionNotification.claimId }); + + if (inserted[0]) return { status: "claimed", claimId: inserted[0].claimId }; + + const staleBefore = new Date(Date.now() - DELIVERY_CLAIM_TTL_MS); + const [reclaimed] = await this.db + .update(schema.ProviderTierDemotionNotification) + .set({ claimId, claimedAt: new Date(), updatedAt: new Date() }) + .where( + and( + this.deliveryWhere(delivery), + eq(schema.ProviderTierDemotionNotification.status, "PENDING"), + lt(schema.ProviderTierDemotionNotification.claimedAt, staleBefore) + ) + ) + .returning({ claimId: schema.ProviderTierDemotionNotification.claimId }); + + if (reclaimed) return { status: "claimed", claimId: reclaimed.claimId }; + + const existing = await this.db.query.ProviderTierDemotionNotification.findFirst({ + columns: { status: true }, + where: this.deliveryWhere(delivery) + }); + if (!existing) throw new Error("Provider tier-demotion delivery disappeared while being claimed"); + + return existing.status === "SENT" ? { status: "sent" } : { status: "busy" }; + } + + async completeDelivery(delivery: ProviderTierDemotionDelivery, claimId: string): Promise { + const rows = await this.db + .update(schema.ProviderTierDemotionNotification) + .set({ status: "SENT", sentAt: new Date(), updatedAt: new Date() }) + .where( + and( + this.deliveryWhere(delivery), + eq(schema.ProviderTierDemotionNotification.status, "PENDING"), + eq(schema.ProviderTierDemotionNotification.claimId, claimId) + ) + ) + .returning({ id: schema.ProviderTierDemotionNotification.id }); + + if (rows.length !== 1) throw new Error("Provider tier-demotion delivery claim was lost"); + } + + async releaseDelivery(delivery: ProviderTierDemotionDelivery, claimId: string): Promise { + await this.db + .delete(schema.ProviderTierDemotionNotification) + .where( + and( + this.deliveryWhere(delivery), + eq(schema.ProviderTierDemotionNotification.status, "PENDING"), + eq(schema.ProviderTierDemotionNotification.claimId, claimId) + ) + ); + } + + private deliveryValues(delivery: ProviderTierDemotionDelivery, claimId: string): typeof schema.ProviderTierDemotionNotification.$inferInsert { + return { + streamId: delivery.streamId, + cursor: BigInt(delivery.cursor), + alertId: delivery.alertId, + provider: delivery.provider, + owner: delivery.lease.owner, + dseq: delivery.lease.dseq, + gseq: delivery.lease.gseq, + oseq: delivery.lease.oseq, + bseq: delivery.lease.bseq, + claimId + }; + } + + private deliveryWhere(delivery: ProviderTierDemotionDelivery) { + return and( + eq(schema.ProviderTierDemotionNotification.streamId, delivery.streamId), + eq(schema.ProviderTierDemotionNotification.cursor, BigInt(delivery.cursor)), + eq(schema.ProviderTierDemotionNotification.alertId, delivery.alertId), + eq(schema.ProviderTierDemotionNotification.provider, delivery.provider), + eq(schema.ProviderTierDemotionNotification.owner, delivery.lease.owner), + eq(schema.ProviderTierDemotionNotification.dseq, delivery.lease.dseq), + eq(schema.ProviderTierDemotionNotification.gseq, delivery.lease.gseq), + eq(schema.ProviderTierDemotionNotification.oseq, delivery.lease.oseq), + eq(schema.ProviderTierDemotionNotification.bseq, delivery.lease.bseq) + ); + } +} diff --git a/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.spec.ts b/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.spec.ts new file mode 100644 index 0000000000..3d62a117cb --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.spec.ts @@ -0,0 +1,106 @@ +import type { HttpClient, RestAkashLeaseListResponse, RpcLease } from "@akashnetwork/http-sdk"; +import { Test } from "@nestjs/testing"; +import { describe, expect, it, vi } from "vitest"; +import type { MockProxy } from "vitest-mock-extended"; + +import { CHAIN_API_HTTP_CLIENT_TOKEN } from "@src/modules/alert/providers/http-sdk.provider"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; + +describe(ProviderActiveLeasesService.name, () => { + it("paginates provider-filtered chain leases and keeps active and reclaiming workloads", async () => { + const { service, chainApi } = await setup(); + chainApi.get + .mockResolvedValueOnce({ + data: chainLeases( + [ + lease({ owner: "akash1owner1", dseq: "100", gseq: 1, oseq: 1, bseq: 3 }, "active"), + lease({ owner: "akash1owner2", dseq: "200", gseq: 2, oseq: 1, bseq: 4 }, "closed") + ], + "next-page" + ) + }) + .mockResolvedValueOnce({ + data: chainLeases([ + lease({ owner: "akash1owner3", dseq: "300", gseq: 3, oseq: 1, bseq: 7 }, "reclaiming"), + lease({ owner: "akash1owner4", dseq: "400", gseq: 4, oseq: 1, bseq: 8 }, "insufficient_funds") + ]) + }); + + const result = await service.list(PROVIDER, 2); + + expect(chainApi.get).toHaveBeenNthCalledWith(1, "/akash/market/v1beta5/leases/list", { + params: { + "filters.provider": PROVIDER, + "pagination.limit": 2, + "pagination.key": undefined + }, + timeout: 30000 + }); + expect(chainApi.get).toHaveBeenNthCalledWith(2, "/akash/market/v1beta5/leases/list", { + params: { + "filters.provider": PROVIDER, + "pagination.limit": 2, + "pagination.key": "next-page" + }, + timeout: 30000 + }); + expect(result).toEqual([ + { owner: "akash1owner1", dseq: "100", gseq: 1, oseq: 1, bseq: 3, provider: PROVIDER }, + { owner: "akash1owner3", dseq: "300", gseq: 3, oseq: 1, bseq: 7, provider: PROVIDER } + ]); + }); + + it("stops after an empty page even when a malformed response includes a continuation key", async () => { + const { service, chainApi } = await setup(); + chainApi.get.mockResolvedValue({ data: chainLeases([], "unexpected-next-page") }); + + await expect(service.list(PROVIDER)).resolves.toEqual([]); + expect(chainApi.get).toHaveBeenCalledTimes(1); + }); + + async function setup() { + const module = await Test.createTestingModule({ + providers: [ProviderActiveLeasesService, { provide: CHAIN_API_HTTP_CLIENT_TOKEN, useValue: { get: vi.fn() } }] + }).compile(); + + return { + service: module.get(ProviderActiveLeasesService), + chainApi: module.get>(CHAIN_API_HTTP_CLIENT_TOKEN) + }; + } +}); + +const PROVIDER = "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx"; + +function chainLeases(leases: RpcLease[], nextKey: string | null = null): RestAkashLeaseListResponse { + return { + leases, + pagination: { next_key: nextKey, total: String(leases.length) } + }; +} + +function lease( + input: { owner: string; dseq: string; gseq: number; oseq: number; bseq: number }, + state: "active" | "closed" | "insufficient_funds" | "reclaiming" +): RpcLease { + return { + lease: { + id: { ...input, provider: PROVIDER }, + state, + price: { denom: "uakt", amount: "1" }, + created_at: "1", + closed_on: "0" + }, + escrow_payment: { + id: { aid: { scope: "deployment", xid: input.dseq }, xid: "1" }, + state: { + owner: input.owner, + state: "open", + rate: { denom: "uakt", amount: "1" }, + balance: { denom: "uakt", amount: "1" }, + unsettled: { denom: "uakt", amount: "0" }, + withdrawn: { denom: "uakt", amount: "0" } + } + } + }; +} diff --git a/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.ts b/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.ts new file mode 100644 index 0000000000..ae9f66e3ce --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-active-leases/provider-active-leases.service.ts @@ -0,0 +1,35 @@ +import type { HttpClient, RestAkashLeaseListResponse, RpcLease } from "@akashnetwork/http-sdk"; +import { extractData, isLeaseLive } from "@akashnetwork/http-sdk"; +import { Inject, Injectable } from "@nestjs/common"; + +import { CHAIN_API_HTTP_CLIENT_TOKEN } from "@src/modules/alert/providers/http-sdk.provider"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; + +@Injectable() +export class ProviderActiveLeasesService { + constructor(@Inject(CHAIN_API_HTTP_CLIENT_TOKEN) private readonly chainApi: HttpClient) {} + + async list(provider: string, pageSize = 100): Promise { + const leases: RpcLease[] = []; + let key: string | undefined; + + do { + const page = extractData( + await this.chainApi.get("/akash/market/v1beta5/leases/list", { + params: { + "filters.provider": provider, + "pagination.limit": pageSize, + "pagination.key": key + }, + timeout: 30000 + }) + ); + leases.push(...page.leases); + + if (page.leases.length === 0) break; + key = page.pagination.next_key ?? undefined; + } while (key); + + return leases.filter(({ lease }) => isLeaseLive(lease)).map(({ lease }) => ({ ...lease.id })); + } +} diff --git a/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.spec.ts b/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.spec.ts new file mode 100644 index 0000000000..f00d15fb5d --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.spec.ts @@ -0,0 +1,128 @@ +import { ConfigService } from "@nestjs/config"; +import { Test } from "@nestjs/testing"; +import { describe, expect, it, vi } from "vitest"; +import type { MockProxy } from "vitest-mock-extended"; + +import { LoggerService } from "@src/common/services/logger/logger.service"; +import type { AlertConfig } from "@src/modules/alert/config"; +import { AlertRepository } from "@src/modules/alert/repositories/alert/alert.repository"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import { ProviderMaintenanceAlertService } from "@src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service"; + +import { MockProvider } from "@test/mocks/provider.mock"; +import { generateGeneralAlert } from "@test/seeders/general-alert.seeder"; + +describe(ProviderMaintenanceAlertService.name, () => { + it("does nothing when maintenance notifications are disabled", async () => { + const { service, activeLeases, onMessage } = await setup({ enabled: false }); + + await service.alertFor(EVENT, onMessage); + + expect(activeLeases.list).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("notifies an active lease through its enabled deployment alert channel", async () => { + const { service, activeLeases, alertRepository, onMessage } = await setup(); + const alert = generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true }); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(alert); + alertRepository.claimProviderMaintenanceNotification.mockResolvedValue({ alert, claimId: CLAIM_ID }); + + await service.alertFor(EVENT, onMessage); + + expect(alertRepository.claimProviderMaintenanceNotification).toHaveBeenCalledWith(alert.id, EVENT.provider, EVENT.maintenance_id, LEASE); + expect(onMessage).toHaveBeenCalledWith({ + notificationChannelId: alert.notificationChannelId, + payload: { + summary: "Provider maintenance scheduled for deployment 100", + description: expect.stringContaining("The lease remains open") + } + }); + expect(alertRepository.completeProviderMaintenanceNotification).toHaveBeenCalledWith(alert.id, EVENT.provider, EVENT.maintenance_id, LEASE, CLAIM_ID); + }); + + it("skips leases without an enabled deployment notification", async () => { + const { service, activeLeases, alertRepository, onMessage } = await setup(); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(generateGeneralAlert({ type: "CHAIN_EVENT", enabled: false })); + + await service.alertFor(EVENT, onMessage); + + expect(alertRepository.claimProviderMaintenanceNotification).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("skips a replay when the maintenance and lease tuple is already claimed", async () => { + const { service, activeLeases, alertRepository, onMessage } = await setup(); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true })); + alertRepository.claimProviderMaintenanceNotification.mockResolvedValue(undefined); + + await service.alertFor(EVENT, onMessage); + + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("releases the claim when publishing fails so the broker can retry", async () => { + const { service, activeLeases, alertRepository, onMessage } = await setup(); + const alert = generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true }); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(alert); + alertRepository.claimProviderMaintenanceNotification.mockResolvedValue({ alert, claimId: CLAIM_ID }); + onMessage.mockRejectedValue(new Error("publish failed")); + + await expect(service.alertFor(EVENT, onMessage)).rejects.toThrow("publish failed"); + + expect(alertRepository.releaseProviderMaintenanceNotification).toHaveBeenCalledWith(alert.id, EVENT.provider, EVENT.maintenance_id, LEASE, CLAIM_ID); + expect(alertRepository.completeProviderMaintenanceNotification).not.toHaveBeenCalled(); + }); + + async function setup({ enabled = true }: { enabled?: boolean } = {}) { + const configService = { + getOrThrow: vi.fn((key: keyof AlertConfig) => { + if (key === "alert.PROVIDER_MAINTENANCE_ALERTS_ENABLED") return enabled; + if (key === "alert.CONSOLE_WEB_URL") return "console.akash.network"; + throw new Error(`Unexpected config key: ${key}`); + }) + }; + const module = await Test.createTestingModule({ + providers: [ + ProviderMaintenanceAlertService, + ProviderActiveLeasesService, + MockProvider(AlertRepository), + MockProvider(ProviderActiveLeasesService), + MockProvider(LoggerService), + { provide: ConfigService, useValue: configService } + ] + }).compile(); + + return { + service: module.get(ProviderMaintenanceAlertService), + activeLeases: module.get>(ProviderActiveLeasesService), + alertRepository: module.get>(AlertRepository), + onMessage: vi.fn() + }; + } +}); + +const EVENT = { + module: "provider" as const, + action: "provider-maintenance-opened" as const, + maintenance_id: "17", + provider: "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx", + maintenance_type: "provider_maintenance_type_planned", + starts_at: "2026-08-25T12:00:00Z", + expected_ends_at: "2026-08-25T14:00:00Z" +}; + +const LEASE = { + owner: "akash1owner1", + dseq: "100", + gseq: 1, + oseq: 1, + bseq: 3, + provider: EVENT.provider +}; + +const CLAIM_ID = "b88f6777-7885-41b9-81ca-a601ea4d72f8"; diff --git a/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.ts b/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.ts new file mode 100644 index 0000000000..045ec547f2 --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-maintenance-alert/provider-maintenance-alert.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import { LoggerService } from "@src/common/services/logger/logger.service"; +import type { AlertConfig } from "@src/modules/alert/config"; +import type { EventProviderMaintenanceOpenedDto } from "@src/modules/alert/dto/event-provider-maintenance-opened.dto"; +import { AlertRepository } from "@src/modules/alert/repositories/alert/alert.repository"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import type { MessageCallback } from "@src/modules/alert/types/message-callback.type"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; + +@Injectable() +export class ProviderMaintenanceAlertService { + constructor( + private readonly activeLeases: ProviderActiveLeasesService, + private readonly alertRepository: AlertRepository, + private readonly configService: ConfigService, + private readonly loggerService: LoggerService + ) { + this.loggerService.setContext(ProviderMaintenanceAlertService.name); + } + + async alertFor(event: EventProviderMaintenanceOpenedDto, onMessage: MessageCallback): Promise { + if (!this.configService.getOrThrow("alert.PROVIDER_MAINTENANCE_ALERTS_ENABLED")) return; + + const leases = await this.activeLeases.list(event.provider); + await Promise.all(leases.map(lease => this.alertForLease(event, lease, onMessage))); + } + + private async alertForLease(event: EventProviderMaintenanceOpenedDto, lease: ProviderLeaseId, onMessage: MessageCallback): Promise { + const alert = await this.alertRepository.findDeploymentClosedAlertByOwnerAndDseq(lease.owner, lease.dseq); + if (!alert?.enabled) return; + + const claim = await this.alertRepository.claimProviderMaintenanceNotification(alert.id, event.provider, event.maintenance_id, lease); + if (!claim) return; + + try { + await onMessage({ + notificationChannelId: claim.alert.notificationChannelId, + payload: { + summary: `Provider maintenance scheduled for deployment ${lease.dseq}`, + description: this.description(event, lease) + } + }); + await this.alertRepository.completeProviderMaintenanceNotification(alert.id, event.provider, event.maintenance_id, lease, claim.claimId); + } catch (error) { + await this.alertRepository.releaseProviderMaintenanceNotification(alert.id, event.provider, event.maintenance_id, lease, claim.claimId); + throw error; + } + } + + private description(event: EventProviderMaintenanceOpenedDto, lease: ProviderLeaseId): string { + const type = event.maintenance_type.replace(/^provider_maintenance_type_/, "").replaceAll("_", " "); + const baseUrl = this.configService.getOrThrow("alert.CONSOLE_WEB_URL"); + const link = `${baseUrl}`; + + return ( + `Provider ${event.provider} announced ${type} maintenance for lease group ${lease.gseq}/${lease.oseq}. ` + + `The window starts at ${event.starts_at} and is expected to end at ${event.expected_ends_at}. ` + + `The lease remains open. Please visit ${link} to review the deployment.` + ); + } +} diff --git a/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.spec.ts b/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.spec.ts new file mode 100644 index 0000000000..c1b1b681f5 --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.spec.ts @@ -0,0 +1,207 @@ +import { ConfigService } from "@nestjs/config"; +import { Test } from "@nestjs/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MockProxy } from "vitest-mock-extended"; + +import { LoggerService } from "@src/common/services/logger/logger.service"; +import { BrokerService } from "@src/infrastructure/broker"; +import type { AlertConfig } from "@src/modules/alert/config"; +import { AlertRepository } from "@src/modules/alert/repositories/alert/alert.repository"; +import { ProviderTierDemotionRepository } from "@src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import { ProviderTierDemotionAlertService } from "@src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service"; +import { ProviderTierDemotionFeedService } from "@src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service"; +import type { ProviderTierDemotionFeed } from "@src/modules/alert/types/provider-tier-demotion.type"; + +import { MockProvider } from "@test/mocks/provider.mock"; +import { generateGeneralAlert } from "@test/seeders/general-alert.seeder"; + +describe(ProviderTierDemotionAlertService.name, () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("does not poll when the feature is disabled", async () => { + const { service, repository, feedService } = await setup({ enabled: false }); + + await service.processNextPage(); + + expect(repository.claimFeed).not.toHaveBeenCalled(); + expect(feedService.get).not.toHaveBeenCalled(); + }); + + it.each([ + ["first observation", null], + ["stream reset", OLD_STREAM_ID] + ])("moves to the feed head without alerting on %s", async (_, streamId) => { + const { service, repository, feedService, activeLeases, brokerService } = await setup(); + repository.claimFeed.mockResolvedValue({ claimId: FEED_CLAIM_ID, streamId, cursor: "0" }); + feedService.get.mockResolvedValue(FEED); + + await service.processNextPage(); + + expect(repository.setFeedPosition).toHaveBeenCalledWith(FEED_CLAIM_ID, STREAM_ID, "12"); + expect(activeLeases.list).not.toHaveBeenCalled(); + expect(brokerService.publish).not.toHaveBeenCalled(); + expect(repository.releaseFeed).toHaveBeenCalledWith(FEED_CLAIM_ID); + }); + + it("silently advances to the head while the verification module is inactive", async () => { + const { service, repository, feedService, activeLeases, brokerService } = await setup(); + repository.claimFeed.mockResolvedValue(FEED_CLAIM); + feedService.get.mockResolvedValue({ ...FEED, moduleActive: false }); + + await service.processNextPage(); + + expect(repository.setFeedPosition).toHaveBeenCalledWith(FEED_CLAIM_ID, STREAM_ID, "12"); + expect(activeLeases.list).not.toHaveBeenCalled(); + expect(brokerService.publish).not.toHaveBeenCalled(); + }); + + it("publishes once for an active lease and advances only after delivery completes", async () => { + const { service, repository, feedService, activeLeases, alertRepository, brokerService } = await setup(); + const alert = generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true }); + repository.claimFeed.mockResolvedValue(FEED_CLAIM); + repository.claimDelivery.mockResolvedValue({ status: "claimed", claimId: DELIVERY_CLAIM_ID }); + feedService.get.mockResolvedValue(FEED); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(alert); + + await service.processNextPage(); + + const delivery = { + streamId: STREAM_ID, + cursor: "11", + alertId: alert.id, + provider: PROVIDER, + lease: LEASE + }; + expect(repository.claimDelivery).toHaveBeenCalledWith(delivery); + expect(brokerService.publish).toHaveBeenCalledWith( + "notifications.v1.notification.create", + { + notificationChannelId: alert.notificationChannelId, + payload: { + summary: "Provider verification changed for deployment 100", + description: expect.stringContaining("The existing lease remains open") + } + }, + { id: expect.stringMatching(/^[0-9a-f-]{36}$/) } + ); + expect(repository.completeDelivery).toHaveBeenCalledWith(delivery, DELIVERY_CLAIM_ID); + expect(repository.advanceFeed).toHaveBeenCalledWith(FEED_CLAIM_ID, STREAM_ID, "11"); + expect(repository.completeDelivery.mock.invocationCallOrder[0]).toBeLessThan(repository.advanceFeed.mock.invocationCallOrder[0]); + }); + + it("skips a delivery already marked sent and still advances the cursor", async () => { + const { service, repository, feedService, activeLeases, alertRepository, brokerService } = await setup(); + repository.claimFeed.mockResolvedValue(FEED_CLAIM); + repository.claimDelivery.mockResolvedValue({ status: "sent" }); + feedService.get.mockResolvedValue(FEED); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true })); + + await service.processNextPage(); + + expect(brokerService.publish).not.toHaveBeenCalled(); + expect(repository.advanceFeed).toHaveBeenCalledWith(FEED_CLAIM_ID, STREAM_ID, "11"); + }); + + it("does not advance when another worker still owns a delivery", async () => { + const { service, repository, feedService, activeLeases, alertRepository } = await setup(); + repository.claimFeed.mockResolvedValue(FEED_CLAIM); + repository.claimDelivery.mockResolvedValue({ status: "busy" }); + feedService.get.mockResolvedValue(FEED); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true })); + + await expect(service.processNextPage()).rejects.toThrow("already being processed"); + + expect(repository.advanceFeed).not.toHaveBeenCalled(); + expect(repository.releaseFeed).toHaveBeenCalledWith(FEED_CLAIM_ID); + }); + + it("releases a failed delivery, keeps the cursor, and reuses the broker job id on retry", async () => { + const { service, repository, feedService, activeLeases, alertRepository, brokerService } = await setup(); + const alert = generateGeneralAlert({ type: "CHAIN_EVENT", enabled: true }); + repository.claimFeed.mockResolvedValue(FEED_CLAIM); + repository.claimDelivery.mockResolvedValueOnce({ status: "claimed", claimId: DELIVERY_CLAIM_ID }).mockResolvedValueOnce({ + status: "claimed", + claimId: SECOND_DELIVERY_CLAIM_ID + }); + feedService.get.mockResolvedValue(FEED); + activeLeases.list.mockResolvedValue([LEASE]); + alertRepository.findDeploymentClosedAlertByOwnerAndDseq.mockResolvedValue(alert); + brokerService.publish.mockRejectedValueOnce(new Error("publish failed")).mockResolvedValueOnce(); + + await expect(service.processNextPage()).rejects.toThrow("publish failed"); + expect(repository.advanceFeed).not.toHaveBeenCalled(); + expect(repository.releaseDelivery).toHaveBeenCalledWith(expect.anything(), DELIVERY_CLAIM_ID); + + await service.processNextPage(); + + expect(brokerService.publish).toHaveBeenCalledTimes(2); + expect(brokerService.publish.mock.calls[0][2]?.id).toBe(brokerService.publish.mock.calls[1][2]?.id); + expect(repository.advanceFeed).toHaveBeenCalledWith(FEED_CLAIM_ID, STREAM_ID, "11"); + }); + + async function setup({ enabled = true }: { enabled?: boolean } = {}) { + const configService = { + get: vi.fn(), + getOrThrow: vi.fn((key: keyof AlertConfig) => { + if (key === "alert.PROVIDER_TIER_DEMOTION_ALERTS_ENABLED") return enabled; + if (key === "alert.CONSOLE_WEB_URL") return "console.akash.network"; + if (key === "alert.PROVIDER_TIER_DEMOTION_POLL_INTERVAL_MS") return 15000; + throw new Error(`Unexpected config key: ${key}`); + }) + }; + const module = await Test.createTestingModule({ + providers: [ + ProviderTierDemotionAlertService, + MockProvider(ProviderTierDemotionFeedService), + MockProvider(ProviderTierDemotionRepository), + MockProvider(ProviderActiveLeasesService), + MockProvider(AlertRepository), + MockProvider(BrokerService), + MockProvider(LoggerService), + { provide: ConfigService, useValue: configService } + ] + }).compile(); + + return { + service: module.get(ProviderTierDemotionAlertService), + feedService: module.get>(ProviderTierDemotionFeedService), + repository: module.get>(ProviderTierDemotionRepository), + activeLeases: module.get>(ProviderActiveLeasesService), + alertRepository: module.get>(AlertRepository), + brokerService: module.get>(BrokerService) + }; + } +}); + +const STREAM_ID = "5be32550-fbc2-4f02-9ac2-7d58f0362451"; +const OLD_STREAM_ID = "28d32c3f-38a0-43e5-a07a-d8c1d6b99203"; +const FEED_CLAIM_ID = "0d29d7ce-41fc-4c4a-bd33-a1bcbf296e4d"; +const DELIVERY_CLAIM_ID = "e6e6d36c-86c6-4da2-8b5a-86ca83b780ee"; +const SECOND_DELIVERY_CLAIM_ID = "9e4c6ef0-3577-4b56-98a7-ae6068f0e98e"; +const PROVIDER = "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx"; + +const FEED_CLAIM = { claimId: FEED_CLAIM_ID, streamId: STREAM_ID, cursor: "10" }; +const LEASE = { owner: "akash1owner1", dseq: "100", gseq: 1, oseq: 1, bseq: 3, provider: PROVIDER }; +const FEED: ProviderTierDemotionFeed = { + streamId: STREAM_ID, + headCursor: "12", + nextCursor: "11", + moduleActive: true, + items: [ + { + cursor: "11", + provider: PROVIDER, + previous: { effectiveTier: "L3", maxPlacementTier: "L3", snapshotState: "current" }, + current: { effectiveTier: "L1", maxPlacementTier: "L1", snapshotState: "stale" }, + changes: ["tier_gate", "snapshot_eligibility"], + observedHeight: "12345", + observedAt: "2026-08-25T00:00:00.000Z" + } + ] +}; diff --git a/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.ts b/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.ts new file mode 100644 index 0000000000..90d7d6480d --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-tier-demotion-alert/provider-tier-demotion-alert.service.ts @@ -0,0 +1,171 @@ +import { Injectable, OnApplicationBootstrap, OnModuleDestroy } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { createHash } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; + +import { eventKeyRegistry } from "@src/common/config/event-key-registry.config"; +import { LoggerService } from "@src/common/services/logger/logger.service"; +import { BrokerService } from "@src/infrastructure/broker"; +import type { AlertConfig } from "@src/modules/alert/config"; +import { AlertRepository } from "@src/modules/alert/repositories/alert/alert.repository"; +import { + type ProviderTierDemotionDelivery, + ProviderTierDemotionRepository +} from "@src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import { ProviderTierDemotionFeedService } from "@src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; +import type { ProviderTierDemotion } from "@src/modules/alert/types/provider-tier-demotion.type"; + +@Injectable() +export class ProviderTierDemotionAlertService implements OnApplicationBootstrap, OnModuleDestroy { + private readonly abortController = new AbortController(); + private polling?: Promise; + + constructor( + private readonly feedService: ProviderTierDemotionFeedService, + private readonly repository: ProviderTierDemotionRepository, + private readonly activeLeases: ProviderActiveLeasesService, + private readonly alertRepository: AlertRepository, + private readonly brokerService: BrokerService, + private readonly configService: ConfigService, + private readonly loggerService: LoggerService + ) { + this.loggerService.setContext(ProviderTierDemotionAlertService.name); + } + + onApplicationBootstrap(): void { + if (!this.configService.getOrThrow("alert.PROVIDER_TIER_DEMOTION_ALERTS_ENABLED")) return; + + this.polling = this.pollLoop(); + } + + async processNextPage(signal: AbortSignal = this.abortController.signal): Promise { + if (!this.configService.getOrThrow("alert.PROVIDER_TIER_DEMOTION_ALERTS_ENABLED")) return; + + const claim = await this.repository.claimFeed(); + if (!claim) return; + + try { + const feed = await this.feedService.get(claim.cursor, signal); + + if (!claim.streamId || claim.streamId !== feed.streamId || !feed.moduleActive) { + await this.repository.setFeedPosition(claim.claimId, feed.streamId, feed.headCursor); + return; + } + + let cursor = BigInt(claim.cursor); + for (const demotion of feed.items) { + const nextCursor = BigInt(demotion.cursor); + if (nextCursor <= cursor) throw new Error("Provider tier-demotion feed cursors must increase monotonically"); + + await this.processDemotion(feed.streamId, demotion); + await this.repository.advanceFeed(claim.claimId, feed.streamId, demotion.cursor); + cursor = nextCursor; + } + } finally { + await this.repository.releaseFeed(claim.claimId); + } + } + + async onModuleDestroy(): Promise { + this.abortController.abort(); + await this.polling; + } + + private async pollLoop(): Promise { + while (!this.abortController.signal.aborted) { + try { + await this.processNextPage(); + } catch (error) { + if (!this.abortController.signal.aborted) { + this.loggerService.error({ event: "PROVIDER_TIER_DEMOTION_POLL_FAILED", error }); + } + } + + await delay(this.configService.getOrThrow("alert.PROVIDER_TIER_DEMOTION_POLL_INTERVAL_MS"), undefined, { + signal: this.abortController.signal + }).catch(error => (error?.name === "AbortError" ? undefined : Promise.reject(error))); + } + } + + private async processDemotion(streamId: string, demotion: ProviderTierDemotion): Promise { + const leases = await this.activeLeases.list(demotion.provider); + await Promise.all(leases.map(lease => this.processLease(streamId, demotion, lease))); + } + + private async processLease(streamId: string, demotion: ProviderTierDemotion, lease: ProviderLeaseId): Promise { + const alert = await this.alertRepository.findDeploymentClosedAlertByOwnerAndDseq(lease.owner, lease.dseq); + if (!alert?.enabled) return; + + const delivery: ProviderTierDemotionDelivery = { + streamId, + cursor: demotion.cursor, + alertId: alert.id, + provider: demotion.provider, + lease + }; + const claim = await this.repository.claimDelivery(delivery); + if (claim.status === "sent") return; + if (claim.status === "busy") throw new Error("Provider tier-demotion delivery is already being processed"); + + try { + await this.brokerService.publish( + eventKeyRegistry.createNotification, + { + notificationChannelId: alert.notificationChannelId, + payload: { + summary: `Provider verification changed for deployment ${lease.dseq}`, + description: this.description(demotion, lease) + } + }, + { id: this.deliveryId(delivery) } + ); + await this.repository.completeDelivery(delivery, claim.claimId); + } catch (error) { + await this.repository.releaseDelivery(delivery, claim.claimId); + throw error; + } + } + + private description(demotion: ProviderTierDemotion, lease: ProviderLeaseId): string { + const baseUrl = this.configService.getOrThrow("alert.CONSOLE_WEB_URL"); + const link = `${baseUrl}`; + const tierChange = + demotion.previous.effectiveTier === demotion.current.effectiveTier + ? `Provider ${demotion.provider} verification eligibility changed.` + : `Provider ${demotion.provider} verification tier changed from ${demotion.previous.effectiveTier} to ${demotion.current.effectiveTier}.`; + const changed = demotion.changes.includes("snapshot_eligibility") ? ` Snapshot eligibility is now ${demotion.current.snapshotState}.` : ""; + + return ( + `${tierChange} ` + + `New placements qualify up to ${demotion.current.maxPlacementTier}.${changed} ` + + `The existing lease remains open. Please visit ${link} to review the deployment.` + ); + } + + private deliveryId(delivery: ProviderTierDemotionDelivery): string { + const digest = createHash("sha256") + .update( + [ + "provider-tier-demotion", + delivery.streamId, + delivery.cursor, + delivery.alertId, + delivery.provider, + delivery.lease.owner, + delivery.lease.dseq, + delivery.lease.gseq, + delivery.lease.oseq, + delivery.lease.bseq + ].join("/") + ) + .digest() + .subarray(0, 16); + digest[6] = (digest[6] & 0x0f) | 0x50; + digest[8] = (digest[8] & 0x3f) | 0x80; + const hex = digest.toString("hex"); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } +} diff --git a/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.spec.ts b/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.spec.ts new file mode 100644 index 0000000000..8191436718 --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.spec.ts @@ -0,0 +1,72 @@ +import { ConfigService } from "@nestjs/config"; +import { Test } from "@nestjs/testing"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AlertConfig } from "@src/modules/alert/config"; +import { ProviderTierDemotionFeedService } from "@src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service"; + +describe(ProviderTierDemotionFeedService.name, () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("requests and validates the private cursor feed", async () => { + const service = await setup(); + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(FEED), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(service.get("10")).resolves.toEqual(FEED); + + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe("https://api.akash.network/internal/v1/provider-verification/tier-demotions?after=10&limit=50&token=private-token"); + expect(init).toMatchObject({ headers: { accept: "application/json" } }); + }); + + it("does not accept an unavailable feed as progress", async () => { + const service = await setup(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response('{"error":"provider_verification_not_ready"}', { status: 503 }))); + + await expect(service.get("10")).rejects.toThrow("HTTP 503"); + }); + + it("rejects malformed feed data at the HTTP boundary", async () => { + const service = await setup(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...FEED, nextCursor: "invalid" }), { status: 200 }))); + + await expect(service.get("10")).rejects.toThrow(); + }); + + async function setup() { + const configService = { + get: vi.fn((key: keyof AlertConfig) => (key === "alert.CONSOLE_API_SECRET_TOKEN" ? "private-token" : undefined)), + getOrThrow: vi.fn((key: keyof AlertConfig) => { + if (key === "alert.CONSOLE_API_ENDPOINT") return "https://api.akash.network"; + if (key === "alert.PROVIDER_TIER_DEMOTION_PAGE_SIZE") return 50; + throw new Error(`Unexpected config key: ${key}`); + }) + }; + const module = await Test.createTestingModule({ + providers: [ProviderTierDemotionFeedService, { provide: ConfigService, useValue: configService }] + }).compile(); + + return module.get(ProviderTierDemotionFeedService); + } +}); + +const FEED = { + streamId: "5be32550-fbc2-4f02-9ac2-7d58f0362451", + headCursor: "12", + nextCursor: "11", + moduleActive: true, + items: [ + { + cursor: "11", + provider: "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx", + previous: { effectiveTier: "L3", maxPlacementTier: "L3", snapshotState: "current" }, + current: { effectiveTier: "L1", maxPlacementTier: "L1", snapshotState: "stale" }, + changes: ["tier_gate", "snapshot_eligibility"], + observedHeight: "12345", + observedAt: "2026-08-25T00:00:00.000Z" + } + ] +}; diff --git a/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.ts b/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.ts new file mode 100644 index 0000000000..85d9f890e7 --- /dev/null +++ b/apps/notifications/src/modules/alert/services/provider-tier-demotion-feed/provider-tier-demotion-feed.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +import type { AlertConfig } from "@src/modules/alert/config"; +import { type ProviderTierDemotionFeed, ProviderTierDemotionFeedSchema } from "@src/modules/alert/types/provider-tier-demotion.type"; + +@Injectable() +export class ProviderTierDemotionFeedService { + constructor(private readonly configService: ConfigService) {} + + async get(after: string, signal?: AbortSignal): Promise { + const endpoint = new URL("/internal/v1/provider-verification/tier-demotions", this.configService.getOrThrow("alert.CONSOLE_API_ENDPOINT")); + endpoint.searchParams.set("after", after); + endpoint.searchParams.set("limit", String(this.configService.getOrThrow("alert.PROVIDER_TIER_DEMOTION_PAGE_SIZE"))); + + const token = this.configService.get("alert.CONSOLE_API_SECRET_TOKEN"); + if (token) endpoint.searchParams.set("token", token); + + const response = await fetch(endpoint, { + headers: { accept: "application/json" }, + signal + }); + + if (!response.ok) { + throw new Error(`Provider tier-demotion feed returned HTTP ${response.status}`); + } + + return ProviderTierDemotionFeedSchema.parse(await response.json()); + } +} diff --git a/apps/notifications/src/modules/alert/types/provider-lease.type.ts b/apps/notifications/src/modules/alert/types/provider-lease.type.ts new file mode 100644 index 0000000000..0361a1f8a9 --- /dev/null +++ b/apps/notifications/src/modules/alert/types/provider-lease.type.ts @@ -0,0 +1,8 @@ +export interface ProviderLeaseId { + owner: string; + dseq: string; + gseq: number; + oseq: number; + bseq: number; + provider: string; +} diff --git a/apps/notifications/src/modules/alert/types/provider-tier-demotion.type.ts b/apps/notifications/src/modules/alert/types/provider-tier-demotion.type.ts new file mode 100644 index 0000000000..1a5b8a7a8a --- /dev/null +++ b/apps/notifications/src/modules/alert/types/provider-tier-demotion.type.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +const UIntStringSchema = z.string().regex(/^\d+$/); +const TierSchema = z.enum(["L0", "L1", "L2", "L3", "L4", "unknown"]); +const SnapshotStateSchema = z.enum(["unknown", "not_posted", "current", "stale", "suspended"]); + +const TierStateSchema = z.object({ + effectiveTier: TierSchema, + maxPlacementTier: TierSchema, + snapshotState: SnapshotStateSchema +}); + +export const ProviderTierDemotionFeedSchema = z.object({ + streamId: z.string().uuid(), + headCursor: UIntStringSchema, + nextCursor: UIntStringSchema, + moduleActive: z.boolean(), + items: z.array( + z.object({ + cursor: UIntStringSchema, + provider: z.string().min(1), + previous: TierStateSchema, + current: TierStateSchema, + changes: z.array(z.enum(["tier_gate", "snapshot_eligibility"])), + observedHeight: UIntStringSchema, + observedAt: z.string().datetime() + }) + ) +}); + +export type ProviderTierDemotionFeed = z.infer; +export type ProviderTierDemotion = ProviderTierDemotionFeed["items"][number]; diff --git a/apps/notifications/src/modules/chain/providers/registry.provider.spec.ts b/apps/notifications/src/modules/chain/providers/registry.provider.spec.ts new file mode 100644 index 0000000000..f4352efdc0 --- /dev/null +++ b/apps/notifications/src/modules/chain/providers/registry.provider.spec.ts @@ -0,0 +1,13 @@ +import type { Registry } from "@cosmjs/proto-signing"; +import type { FactoryProvider } from "@nestjs/common"; +import { describe, expect, it } from "vitest"; + +import { RegistryProvider } from "./registry.provider"; + +describe("RegistryProvider", () => { + it("registers AEP-86 transaction types from the expanded SDK barrel", () => { + const registry = (RegistryProvider as FactoryProvider).useFactory(); + + expect(registry.lookupType("/akash.verification.v1.MsgSubmitAttestation")).toBeDefined(); + }); +}); diff --git a/apps/notifications/src/modules/chain/providers/registry.provider.ts b/apps/notifications/src/modules/chain/providers/registry.provider.ts index 529e0135b9..c777d47e1f 100644 --- a/apps/notifications/src/modules/chain/providers/registry.provider.ts +++ b/apps/notifications/src/modules/chain/providers/registry.provider.ts @@ -12,18 +12,16 @@ import type { Provider } from "@nestjs/common"; export const RegistryProvider: Provider = { provide: Registry, useFactory: () => { - const akashTypes: ReadonlyArray<[string, GeneratedType]> = [ - ...Object.values(v1), - ...Object.values(v1beta4), - ...Object.values(v1beta5), - ...Object.values(cosmosv1), - ...Object.values(cosmosv1beta1), - ...Object.values(cosmosv1alpha1), - ...Object.values(cosmosv2alpha1) - ] - .filter(x => "$type" in x) - .map(x => ["/" + x.$type, x as unknown as GeneratedType]); + const modules: ReadonlyArray> = [v1, v1beta4, v1beta5, cosmosv1, cosmosv1beta1, cosmosv1alpha1, cosmosv2alpha1]; + const akashTypes: ReadonlyArray<[string, GeneratedType]> = modules + .flatMap(module => Object.values(module)) + .filter(hasType) + .map(type => ["/" + type.$type, type as unknown as GeneratedType]); return new Registry(akashTypes); } }; + +function hasType(value: unknown): value is { $type: string } { + return typeof value === "object" && value !== null && "$type" in value && typeof value.$type === "string"; +} diff --git a/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.spec.ts b/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.spec.ts index 6db8d4e175..61abe841a2 100644 --- a/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.spec.ts +++ b/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.spec.ts @@ -94,12 +94,32 @@ describe(ChainEventsPollerService.name, () => { CURRENT_HEIGHT + 1, expect.arrayContaining([ { module: "deployment", version: "v1", source: "akash", action: ["deployment-closed"] }, - { module: "market", version: "v1", source: "akash", action: ["lease-reclaim-started"] } + { module: "market", version: "v1", source: "akash", action: ["lease-reclaim-started"] }, + { module: "provider", version: "v1beta4", source: "akash", action: ["provider-maintenance-opened"] } ]), expect.any(AbortSignal) ); }); + it("does not commit the block cursor when event fetching fails", async () => { + const { service, blockCursorRepository, blockMessageService, txEventsService, CURRENT_HEIGHT } = await setup(); + const committedHeights: number[] = []; + blockMessageService.getMessages.mockResolvedValue(generateMockBlockData({ height: CURRENT_HEIGHT + 1, time: new Date().toISOString() })); + txEventsService.getBlockEvents.mockRejectedValue(new Error("block results unavailable")); + blockCursorRepository.getNextBlockForProcessing.mockImplementation(async callback => { + const block = await callback(CURRENT_HEIGHT + 1); + committedHeights.push(block.height); + return block; + }); + + service.onApplicationBootstrap(); + await delay(100); + await service.onModuleDestroy(); + + expect(blockCursorRepository.getNextBlockForProcessing).toHaveBeenCalled(); + expect(committedHeights).toEqual([]); + }); + it("retries instead of shutting down when block processing consistently fails", async () => { const { service, blockCursorRepository, blockMessageService, CURRENT_HEIGHT } = await setup(); diff --git a/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.ts b/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.ts index 143df8c3f1..580de3920e 100644 --- a/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.ts +++ b/apps/notifications/src/modules/chain/services/chain-events-poller/chain-events-poller.service.ts @@ -149,7 +149,8 @@ export class ChainEventsPollerService implements OnApplicationBootstrap, OnModul nextBlockHeight, [ { module: "deployment", version: "v1", source: "akash", action: ["deployment-closed"] }, - { module: "market", version: "v1", source: "akash", action: ["lease-reclaim-started"] } + { module: "market", version: "v1", source: "akash", action: ["lease-reclaim-started"] }, + { module: "provider", version: "v1beta4", source: "akash", action: ["provider-maintenance-opened"] } ], this.signal ); diff --git a/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.spec.ts b/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.spec.ts index 4e4da54aa3..019df07169 100644 --- a/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.spec.ts +++ b/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.spec.ts @@ -1,7 +1,7 @@ import type { comet38 } from "@cosmjs/tendermint-rpc"; import { Comet38Client } from "@cosmjs/tendermint-rpc"; import { Test } from "@nestjs/testing"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { MockProxy } from "vitest-mock-extended"; import { mock } from "vitest-mock-extended"; @@ -12,6 +12,29 @@ import { MockProvider } from "@test/mocks/provider.mock"; describe(TxEventsService.name, () => { describe("getBlockEvents", () => { + it("propagates an exhausted block-results failure", async () => { + vi.useFakeTimers(); + + try { + const { module } = await setup(); + const service = module.get(TxEventsService); + const cometClient = module.get>(Comet38Client); + const error = new Error("block results unavailable"); + cometClient.blockResults.mockRejectedValue(error); + + const result = service.getBlockEvents(1).then( + () => undefined, + rejection => rejection + ); + await vi.runAllTimersAsync(); + + expect(await result).toBe(error); + expect(cometClient.blockResults).toHaveBeenCalledTimes(6); + } finally { + vi.useRealTimers(); + } + }); + it("should extract certain events from tx logs", async () => { const { module } = await setup(); const service = module.get(TxEventsService); @@ -237,6 +260,62 @@ describe(TxEventsService.name, () => { ]); }); + it("extracts a provider maintenance opened event", async () => { + const { module } = await setup(); + const service = module.get(TxEventsService); + const cometClient = module.get>(Comet38Client); + const blockResults: comet38.BlockResultsResponse = { + height: 1, + results: [ + { + code: 0, + codespace: "", + data: Uint8Array.from([]), + events: [ + { + type: "akash.provider.v1beta4.EventProviderMaintenanceOpened", + attributes: [ + { key: "maintenance_id", value: '"17"' }, + { key: "provider", value: '"akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx"' }, + { key: "maintenance_type", value: '"provider_maintenance_type_planned"' }, + { key: "starts_at", value: '"2026-08-25T12:00:00Z"' }, + { key: "expected_ends_at", value: '"2026-08-25T14:00:00Z"' }, + { key: "metadata_hash", value: '"AQID"' }, + { key: "msg_index", value: "0" } + ] + } + ], + gasWanted: 100000n, + gasUsed: 80000n + } + ], + validatorUpdates: [], + finalizeBlockEvents: [] + }; + cometClient.blockResults.mockResolvedValue(blockResults); + + const result = await service.getBlockEvents(1, { + source: "akash", + module: "provider", + version: "v1beta4", + action: ["provider-maintenance-opened"] + }); + + expect(result).toEqual([ + { + type: "akash.v1beta4", + module: "provider", + action: "provider-maintenance-opened", + maintenance_id: "17", + provider: "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx", + maintenance_type: "provider_maintenance_type_planned", + starts_at: "2026-08-25T12:00:00Z", + expected_ends_at: "2026-08-25T14:00:00Z", + metadata_hash: "AQID" + } + ]); + }); + it("applies multiple filters in a single block fetch", async () => { const { module } = await setup(); const service = module.get(TxEventsService); diff --git a/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.ts b/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.ts index 7ae67b99da..e7c2fcffef 100644 --- a/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.ts +++ b/apps/notifications/src/modules/chain/services/tx-events-service/tx-events.service.ts @@ -18,7 +18,7 @@ interface ProcessedEvent { /** * Supported blockchain event actions */ -type Action = "deployment-closed" | "deployment-created" | "lease-reclaim-started"; +type Action = "deployment-closed" | "deployment-created" | "lease-reclaim-started" | "provider-maintenance-opened"; /** * Filter criteria for blockchain events @@ -26,8 +26,8 @@ type Action = "deployment-closed" | "deployment-created" | "lease-reclaim-starte interface EventFilter { source?: "akash"; action?: Action | Action[]; - module?: "deployment" | "market"; - version?: "v1"; + module?: "deployment" | "market" | "provider"; + version?: "v1" | "v1beta4"; } /** @@ -47,7 +47,8 @@ export class TxEventsService { private readonly EVENT_ACTIONS: Record = { EventDeploymentClosed: "deployment-closed", EventDeploymentCreated: "deployment-created", - EventLeaseReclaimStarted: "lease-reclaim-started" + EventLeaseReclaimStarted: "lease-reclaim-started", + EventProviderMaintenanceOpened: "provider-maintenance-opened" }; private readonly ACTION_EVENTS: Record = Object.fromEntries(Object.entries(this.EVENT_ACTIONS).map(([k, v]) => [v, k])); @@ -90,7 +91,7 @@ export class TxEventsService { blockHeight, error }); - return []; + throw error; } } diff --git a/apps/notifications/test/functional/provider-maintenance-alert.spec.ts b/apps/notifications/test/functional/provider-maintenance-alert.spec.ts new file mode 100644 index 0000000000..a61d727e4f --- /dev/null +++ b/apps/notifications/test/functional/provider-maintenance-alert.spec.ts @@ -0,0 +1,220 @@ +import { faker } from "@faker-js/faker"; +import { Test } from "@nestjs/testing"; +import { eq } from "drizzle-orm"; +import { describe, expect, it, vi } from "vitest"; + +import { eventKeyRegistry } from "@src/common/config/event-key-registry.config"; +import { BrokerService } from "@src/infrastructure/broker"; +import { DRIZZLE_PROVIDER_TOKEN } from "@src/infrastructure/db/config/db.config"; +import AlertEventsModule from "@src/interfaces/alert-events/alert-events.module"; +import { ChainEventsHandler } from "@src/interfaces/alert-events/handlers/chain-events/chain-events.handler"; +import type { EventProviderMaintenanceOpenedDto } from "@src/modules/alert/dto/event-provider-maintenance-opened.dto"; +import * as schema from "@src/modules/alert/model-schemas"; +import { ProviderActiveLeasesService } from "@src/modules/alert/services/provider-active-leases/provider-active-leases.service"; +import type { ProviderLeaseId } from "@src/modules/alert/types/provider-lease.type"; +import { NotificationChannel } from "@src/modules/notifications/model-schemas"; + +import { mockAkashAddress } from "@test/seeders/akash-address.seeder"; +import { generateGeneralAlert } from "@test/seeders/general-alert.seeder"; +import { generateNotificationChannel } from "@test/seeders/notification-channel.seeder"; + +describe("provider maintenance alerts", () => { + it("publishes once for the provider, maintenance and lease tuple", async () => { + const provider = mockAkashAddress(); + const owner = mockAkashAddress(); + const dseq = String(faker.number.int({ min: 1, max: 999999 })); + const lease: ProviderLeaseId = { owner, dseq, provider, gseq: 1, oseq: 1, bseq: 4 }; + const activeLeases = { list: vi.fn().mockResolvedValue([lease]) }; + const module = await setup(activeLeases); + + try { + const handler = module.get(ChainEventsHandler); + const brokerService = module.get(BrokerService); + const db = module.get(DRIZZLE_PROVIDER_TOKEN); + vi.spyOn(brokerService, "publish").mockResolvedValue(undefined); + + const [channel] = await db + .insert(NotificationChannel) + .values([generateNotificationChannel({})]) + .returning(); + const [alert] = await db + .insert(schema.Alert) + .values([generateClosedAlert({ owner, dseq, notificationChannelId: channel.id })]) + .returning(); + const event = maintenanceEvent(provider); + + await handler.processProviderMaintenanceOpened(event); + await handler.processProviderMaintenanceOpened(event); + + expect(activeLeases.list).toHaveBeenCalledWith(provider); + expect(brokerService.publish).toHaveBeenCalledTimes(1); + expect(brokerService.publish).toHaveBeenCalledWith( + eventKeyRegistry.createNotification, + expect.objectContaining({ + notificationChannelId: channel.id, + payload: expect.objectContaining({ + summary: expect.stringContaining(dseq), + description: expect.stringContaining("The lease remains open") + }) + }) + ); + + const saved = await db.query.Alert.findFirst({ where: (table, { eq }) => eq(table.id, alert.id) }); + expect(saved.params.providerMaintenanceNotifications[notificationKey(event, lease)]).toEqual({ + status: "sent", + sentAt: expect.any(String) + }); + } finally { + await module.close(); + } + }); + + it("does not reclaim a fresh pending notification", async () => { + const context = await setupScenario(); + + try { + const { alert, brokerService, db, event, handler, lease } = context; + const key = notificationKey(event, lease); + const pending = { + status: "pending", + claimId: faker.string.uuid(), + claimedAt: new Date().toISOString() + }; + await db + .update(schema.Alert) + .set({ params: { ...alert.params, providerMaintenanceNotifications: { [key]: pending } } }) + .where(eq(schema.Alert.id, alert.id)); + + await handler.processProviderMaintenanceOpened(event); + + expect(brokerService.publish).not.toHaveBeenCalled(); + const saved = await db.query.Alert.findFirst({ where: (table, { eq }) => eq(table.id, alert.id) }); + expect(saved.params.providerMaintenanceNotifications[key]).toEqual(pending); + } finally { + await context.module.close(); + } + }); + + it("reclaims a stale pending notification and marks it sent", async () => { + const context = await setupScenario(); + + try { + const { alert, brokerService, db, event, handler, lease } = context; + const key = notificationKey(event, lease); + await db + .update(schema.Alert) + .set({ + params: { + ...alert.params, + providerMaintenanceNotifications: { + [key]: { status: "pending", claimId: faker.string.uuid(), claimedAt: new Date(0).toISOString() } + } + } + }) + .where(eq(schema.Alert.id, alert.id)); + + await handler.processProviderMaintenanceOpened(event); + + expect(brokerService.publish).toHaveBeenCalledTimes(1); + const saved = await db.query.Alert.findFirst({ where: (table, { eq }) => eq(table.id, alert.id) }); + expect(saved.params.providerMaintenanceNotifications[key]).toEqual({ status: "sent", sentAt: expect.any(String) }); + } finally { + await context.module.close(); + } + }); + + it("releases a failed publication so replay can send it", async () => { + const context = await setupScenario(); + + try { + const { alert, brokerService, db, event, handler, lease } = context; + const key = notificationKey(event, lease); + vi.mocked(brokerService.publish).mockRejectedValueOnce(new Error("publish failed")).mockResolvedValueOnce(undefined); + + await expect(handler.processProviderMaintenanceOpened(event)).rejects.toThrow("publish failed"); + const failed = await db.query.Alert.findFirst({ where: (table, { eq }) => eq(table.id, alert.id) }); + expect(failed.params.providerMaintenanceNotifications?.[key]).toBeUndefined(); + + await handler.processProviderMaintenanceOpened(event); + + expect(brokerService.publish).toHaveBeenCalledTimes(2); + const sent = await db.query.Alert.findFirst({ where: (table, { eq }) => eq(table.id, alert.id) }); + expect(sent.params.providerMaintenanceNotifications[key]).toEqual({ status: "sent", sentAt: expect.any(String) }); + } finally { + await context.module.close(); + } + }); + + async function setupScenario() { + const provider = mockAkashAddress(); + const owner = mockAkashAddress(); + const dseq = String(faker.number.int({ min: 1, max: 999999 })); + const lease: ProviderLeaseId = { owner, dseq, provider, gseq: 1, oseq: 1, bseq: 4 }; + const module = await setup({ list: vi.fn().mockResolvedValue([lease]) }); + const brokerService = module.get(BrokerService); + const db = module.get(DRIZZLE_PROVIDER_TOKEN); + vi.spyOn(brokerService, "publish").mockResolvedValue(undefined); + const [channel] = await db + .insert(NotificationChannel) + .values([generateNotificationChannel({})]) + .returning(); + const [alert] = await db + .insert(schema.Alert) + .values([generateClosedAlert({ owner, dseq, notificationChannelId: channel.id })]) + .returning(); + + return { + alert, + brokerService, + db, + event: maintenanceEvent(provider), + handler: module.get(ChainEventsHandler), + lease, + module + }; + } + + async function setup(activeLeases: Pick) { + process.env.PROVIDER_MAINTENANCE_ALERTS_ENABLED = "true"; + + return await Test.createTestingModule({ imports: [AlertEventsModule] }) + .overrideProvider(ProviderActiveLeasesService) + .useValue(activeLeases) + .compile(); + } +}); + +function generateClosedAlert(input: { owner: string; dseq: string; notificationChannelId: string }) { + return generateGeneralAlert({ + type: "CHAIN_EVENT", + notificationChannelId: input.notificationChannelId, + enabled: true, + params: { dseq: input.dseq, type: "DEPLOYMENT_CLOSED" }, + conditions: { + operator: "and", + value: [ + { field: "action", value: "deployment-closed", operator: "eq" }, + { field: "owner", value: input.owner, operator: "eq" }, + { field: "dseq", value: input.dseq, operator: "eq" } + ] + }, + summary: "Deployment closed", + description: "Deployment closed" + }); +} + +function maintenanceEvent(provider: string): EventProviderMaintenanceOpenedDto { + return { + module: "provider", + action: "provider-maintenance-opened", + maintenance_id: "17", + provider, + maintenance_type: "provider_maintenance_type_planned", + starts_at: "2026-08-25T12:00:00Z", + expected_ends_at: "2026-08-25T14:00:00Z" + } as EventProviderMaintenanceOpenedDto; +} + +function notificationKey(event: EventProviderMaintenanceOpenedDto, lease: ProviderLeaseId): string { + return [event.provider, event.maintenance_id, lease.owner, lease.dseq, lease.gseq, lease.oseq, lease.bseq, lease.provider].join("/"); +} diff --git a/apps/notifications/test/functional/provider-tier-demotion-alert.spec.ts b/apps/notifications/test/functional/provider-tier-demotion-alert.spec.ts new file mode 100644 index 0000000000..3a13cd4a06 --- /dev/null +++ b/apps/notifications/test/functional/provider-tier-demotion-alert.spec.ts @@ -0,0 +1,89 @@ +import { faker } from "@faker-js/faker"; +import { Test } from "@nestjs/testing"; +import { describe, expect, it } from "vitest"; + +import { DRIZZLE_PROVIDER_TOKEN } from "@src/infrastructure/db/config/db.config"; +import AlertEventsModule from "@src/interfaces/alert-events/alert-events.module"; +import * as schema from "@src/modules/alert/model-schemas"; +import { ProviderTierDemotionRepository } from "@src/modules/alert/repositories/provider-tier-demotion/provider-tier-demotion.repository"; +import { NotificationChannel } from "@src/modules/notifications/model-schemas"; + +import { mockAkashAddress } from "@test/seeders/akash-address.seeder"; +import { generateGeneralAlert } from "@test/seeders/general-alert.seeder"; +import { generateNotificationChannel } from "@test/seeders/notification-channel.seeder"; + +describe("provider tier-demotion alert persistence", () => { + it("serializes feed processing and persists sent-delivery deduplication", async () => { + const module = await Test.createTestingModule({ imports: [AlertEventsModule] }).compile(); + + try { + const repository = module.get(ProviderTierDemotionRepository); + const db = module.get(DRIZZLE_PROVIDER_TOKEN); + const firstClaim = await repository.claimFeed(); + + expect(firstClaim).toMatchObject({ streamId: null, cursor: "0" }); + await expect(repository.claimFeed()).resolves.toBeUndefined(); + + await repository.setFeedPosition(firstClaim.claimId, STREAM_ID, "10"); + await repository.releaseFeed(firstClaim.claimId); + await expect(repository.claimFeed()).resolves.toMatchObject({ streamId: STREAM_ID, cursor: "10" }); + + const [channel] = await db.insert(NotificationChannel).values(generateNotificationChannel({})).returning(); + const owner = mockAkashAddress(); + const dseq = String(faker.number.int({ min: 1, max: 999999 })); + const [alert] = await db + .insert(schema.Alert) + .values(generateClosedAlert({ owner, dseq, notificationChannelId: channel.id })) + .returning(); + const delivery = { + streamId: STREAM_ID, + cursor: "11", + alertId: alert.id, + provider: PROVIDER, + lease: { owner, dseq, provider: PROVIDER, gseq: 1, oseq: 1, bseq: 3 } + }; + + const deliveryClaim = await repository.claimDelivery(delivery); + expect(deliveryClaim.status).toBe("claimed"); + if (deliveryClaim.status !== "claimed") throw new Error("Expected a claimed delivery"); + await repository.completeDelivery(delivery, deliveryClaim.claimId); + + await expect(repository.claimDelivery(delivery)).resolves.toEqual({ status: "sent" }); + await expect(db.select().from(schema.ProviderTierDemotionNotification)).resolves.toMatchObject([ + { streamId: STREAM_ID, cursor: 11n, status: "SENT", sentAt: expect.any(Date) } + ]); + + const retryableDelivery = { ...delivery, cursor: "12" }; + const pendingClaim = await repository.claimDelivery(retryableDelivery); + expect(pendingClaim.status).toBe("claimed"); + await expect(repository.claimDelivery(retryableDelivery)).resolves.toEqual({ status: "busy" }); + if (pendingClaim.status !== "claimed") throw new Error("Expected a claimed delivery"); + await repository.releaseDelivery(retryableDelivery, pendingClaim.claimId); + await expect(repository.claimDelivery(retryableDelivery)).resolves.toMatchObject({ status: "claimed" }); + } finally { + await module.close(); + } + }); +}); + +function generateClosedAlert(input: { owner: string; dseq: string; notificationChannelId: string }) { + return generateGeneralAlert({ + type: "CHAIN_EVENT", + notificationChannelId: input.notificationChannelId, + enabled: true, + params: { dseq: input.dseq, type: "DEPLOYMENT_CLOSED" }, + conditions: { + operator: "and", + value: [ + { field: "action", value: "deployment-closed", operator: "eq" }, + { field: "owner", value: input.owner, operator: "eq" }, + { field: "dseq", value: input.dseq, operator: "eq" } + ] + }, + summary: "Deployment closed", + description: "Deployment closed" + }); +} + +const STREAM_ID = "5be32550-fbc2-4f02-9ac2-7d58f0362451"; +const PROVIDER = "akash1provideraddressxxxxxxxxxxxxxxxxxxxxxx"; From 169992e16f4a125a7ba58c9d2b7e9d89a506594d Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 20:37:00 -0700 Subject: [PATCH 07/12] feat(stats): render provider verification transactions Give AEP-86 messages readable labels and structured transaction details in Stats without changing the generic fallback for unknown message types. Signed-off-by: Joseph Chalabi --- .../components/transactions/TxMessageRow.tsx | 6 + .../akash/aep86/Aep86RecordView.tsx | 23 ++++ .../akash/aep86/aep86Formatting.ts | 110 ++++++++++++++++++ .../akash/aep86/aep86Types.spec.ts | 68 +++++++++++ .../transactions/akash/aep86/aep86Types.ts | 78 +++++++++++++ .../src/hooks/useFriendlyMessageType.spec.ts | 17 +++ .../src/hooks/useFriendlyMessageType.ts | 13 +-- 7 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 apps/stats-web/src/components/transactions/akash/aep86/Aep86RecordView.tsx create mode 100644 apps/stats-web/src/components/transactions/akash/aep86/aep86Formatting.ts create mode 100644 apps/stats-web/src/components/transactions/akash/aep86/aep86Types.spec.ts create mode 100644 apps/stats-web/src/components/transactions/akash/aep86/aep86Types.ts create mode 100644 apps/stats-web/src/hooks/useFriendlyMessageType.spec.ts diff --git a/apps/stats-web/src/components/transactions/TxMessageRow.tsx b/apps/stats-web/src/components/transactions/TxMessageRow.tsx index 81be020d36..e4606feafb 100644 --- a/apps/stats-web/src/components/transactions/TxMessageRow.tsx +++ b/apps/stats-web/src/components/transactions/TxMessageRow.tsx @@ -2,6 +2,8 @@ import { useMemo } from "react"; import { DynamicReactJson } from "../DynamicJsonView"; +import { Aep86RecordView } from "./akash/aep86/Aep86RecordView"; +import { isAep86MessageType } from "./akash/aep86/aep86Types"; import * as akashMessages from "./akash"; import * as cosmosMessages from "./generic"; @@ -53,6 +55,10 @@ type TxMessageProps = { message: TransactionMessage; }; const TxMessage: React.FunctionComponent = ({ message }) => { + if (isAep86MessageType(message.type)) { + return ; + } + const [namespace, ...typeDetails] = message.type.split("."); const version = typeDetails[typeDetails.length - 2]; const name = typeDetails[typeDetails.length - 1]; diff --git a/apps/stats-web/src/components/transactions/akash/aep86/Aep86RecordView.tsx b/apps/stats-web/src/components/transactions/akash/aep86/Aep86RecordView.tsx new file mode 100644 index 0000000000..3ed06935b6 --- /dev/null +++ b/apps/stats-web/src/components/transactions/akash/aep86/Aep86RecordView.tsx @@ -0,0 +1,23 @@ +"use client"; +import type { ReactNode } from "react"; + +import type { Aep86DisplayField } from "./aep86Formatting"; +import { toAep86DisplayFields } from "./aep86Formatting"; + +import { AddressLink } from "@/components/AddressLink"; +import { DynamicReactJson } from "@/components/DynamicJsonView"; +import { LabelValue } from "@/components/LabelValue"; + +function renderValue(field: Aep86DisplayField): ReactNode { + if (field.kind === "address") return ; + if (field.kind === "json") return ; + + return {field.value as string}; +} + +export function Aep86RecordView({ data }: { data: unknown }) { + const fields = toAep86DisplayFields(data); + if (!fields.length) return No fields; + + return fields.map(field => ); +} diff --git a/apps/stats-web/src/components/transactions/akash/aep86/aep86Formatting.ts b/apps/stats-web/src/components/transactions/akash/aep86/aep86Formatting.ts new file mode 100644 index 0000000000..eab8e83d8b --- /dev/null +++ b/apps/stats-web/src/components/transactions/akash/aep86/aep86Formatting.ts @@ -0,0 +1,110 @@ +type Coin = { + amount: string; + denom: string; +}; + +export type Aep86DisplayField = { + key: string; + kind: "address" | "json" | "text"; + label: string; + value: object | string; +}; + +const tierLabels: Record = { + verification_tier_unspecified: "L0", + verification_tier_identified: "L1", + verification_tier_verified: "L2", + verification_tier_established: "L3", + verification_tier_trusted: "L4" +}; + +const enumPrefixes = [ + "attestation_revocation_reason_", + "audit_escrow_settlement_reason_", + "discrepancy_resolution_reason_", + "governance_attestation_reason_", + "provider_bond_slash_reason_", + "provider_maintenance_status_", + "provider_maintenance_type_", + "verification_grace_status_", + "fault_attribution_", + "capability_" +]; + +const addressFields = new Set(["auditor", "auditorA", "auditorB", "authority", "initiator", "provider", "vindicatedAuditor"]); + +function snakeToCamelCase(value: string): string { + return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} + +function titleCase(value: string): string { + return value + .split("_") + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function isCoin(value: unknown): value is Coin { + return !!value && typeof value === "object" && typeof (value as Coin).amount === "string" && typeof (value as Coin).denom === "string"; +} + +function isJsonObject(value: unknown): value is object { + return !!value && typeof value === "object"; +} + +export function getAep86FieldLabel(key: string): string { + return snakeToCamelCase(key) + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/^./, value => value.toUpperCase()) + .replace(/\bId\b/g, "ID") + .replace(/\bUri\b/g, "URI"); +} + +export function formatAep86Scalar(value: unknown): string { + if (value === null || value === undefined || value === "") return "Not provided"; + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (value instanceof Date) return value.toISOString(); + + const stringValue = String(value); + if (tierLabels[stringValue]) return tierLabels[stringValue]; + + const prefix = enumPrefixes.find(candidate => stringValue.startsWith(candidate)); + return prefix ? titleCase(stringValue.slice(prefix.length)) : stringValue; +} + +function formatArray(value: unknown[]): Aep86DisplayField["value"] { + if (value.every(item => !isJsonObject(item))) { + return value.length ? value.map(formatAep86Scalar).join(", ") : "None"; + } + + return value; +} + +export function toAep86DisplayFields(data: unknown): Aep86DisplayField[] { + if (!data || typeof data !== "object" || Array.isArray(data)) return []; + + return Object.entries(data).map(([key, value]) => { + const normalizedKey = snakeToCamelCase(key); + const label = getAep86FieldLabel(key); + + if (addressFields.has(normalizedKey) && typeof value === "string") { + return { key, kind: "address", label, value }; + } + + if (isCoin(value)) { + return { key, kind: "text", label, value: `${value.amount} ${value.denom}` }; + } + + if (Array.isArray(value)) { + const formattedValue = formatArray(value); + return { key, kind: typeof formattedValue === "string" ? "text" : "json", label, value: formattedValue }; + } + + if (isJsonObject(value)) { + return { key, kind: "json", label, value }; + } + + return { key, kind: "text", label, value: formatAep86Scalar(value) }; + }); +} diff --git a/apps/stats-web/src/components/transactions/akash/aep86/aep86Types.spec.ts b/apps/stats-web/src/components/transactions/akash/aep86/aep86Types.spec.ts new file mode 100644 index 0000000000..744f177b3d --- /dev/null +++ b/apps/stats-web/src/components/transactions/akash/aep86/aep86Types.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { formatAep86Scalar, getAep86FieldLabel, toAep86DisplayFields } from "./aep86Formatting"; +import { AEP86_EVENT_TYPE_URLS, AEP86_MESSAGE_TYPE_URLS, isAep86EventType, isAep86MessageType } from "./aep86Types"; + +describe("AEP-86 type dispatch", () => { + it("recognizes every verification and maintenance message by full type URL", () => { + expect(AEP86_MESSAGE_TYPE_URLS).toHaveLength(22); + expect(AEP86_MESSAGE_TYPE_URLS.every(isAep86MessageType)).toBe(true); + }); + + it("recognizes every verification and maintenance event with or without a leading slash", () => { + expect(AEP86_EVENT_TYPE_URLS).toHaveLength(33); + expect(AEP86_EVENT_TYPE_URLS.every(isAep86EventType)).toBe(true); + expect(isAep86EventType("akash.verification.v1.EventAttestationSubmitted")).toBe(true); + }); + + it("does not confuse update-params messages from different modules", () => { + expect(isAep86MessageType("/akash.verification.v1.MsgUpdateParams")).toBe(true); + expect(isAep86MessageType("/akash.provider.v1beta4.MsgUpdateParams")).toBe(false); + expect(isAep86MessageType("MsgUpdateParams")).toBe(false); + }); +}); + +describe("AEP-86 record formatting", () => { + it("formats tiers, capabilities, booleans, and field labels", () => { + expect(formatAep86Scalar("verification_tier_established")).toBe("L3"); + expect(formatAep86Scalar("capability_persistent_storage")).toBe("Persistent Storage"); + expect(formatAep86Scalar(true)).toBe("Yes"); + expect(getAep86FieldLabel("audit_escrow_id")).toBe("Audit Escrow ID"); + }); + + it("builds a readable attestation view without losing hashes or coin denominations", () => { + expect( + toAep86DisplayFields({ + provider: "akash1provider", + tier: "verification_tier_identified", + capabilities: ["capability_persistent_storage"], + evidenceHash: "evidence-base64", + fee: { amount: "10000000", denom: "uakt" }, + slashAuditorA: false + }) + ).toEqual([ + { key: "provider", kind: "address", label: "Provider", value: "akash1provider" }, + { key: "tier", kind: "text", label: "Tier", value: "L1" }, + { key: "capabilities", kind: "text", label: "Capabilities", value: "Persistent Storage" }, + { key: "evidenceHash", kind: "text", label: "Evidence Hash", value: "evidence-base64" }, + { key: "fee", kind: "text", label: "Fee", value: "10000000 uakt" }, + { key: "slashAuditorA", kind: "text", label: "Slash Auditor A", value: "No" } + ]); + }); + + it("formats provider maintenance event fields", () => { + expect( + toAep86DisplayFields({ + maintenanceId: "7", + provider: "akash1provider", + maintenanceType: "provider_maintenance_type_security", + startsAt: "2026-08-24T10:00:00Z" + }) + ).toEqual([ + { key: "maintenanceId", kind: "text", label: "Maintenance ID", value: "7" }, + { key: "provider", kind: "address", label: "Provider", value: "akash1provider" }, + { key: "maintenanceType", kind: "text", label: "Maintenance Type", value: "Security" }, + { key: "startsAt", kind: "text", label: "Starts At", value: "2026-08-24T10:00:00Z" } + ]); + }); +}); diff --git a/apps/stats-web/src/components/transactions/akash/aep86/aep86Types.ts b/apps/stats-web/src/components/transactions/akash/aep86/aep86Types.ts new file mode 100644 index 0000000000..9b54adad0c --- /dev/null +++ b/apps/stats-web/src/components/transactions/akash/aep86/aep86Types.ts @@ -0,0 +1,78 @@ +const VERIFICATION_TYPE_PREFIX = "/akash.verification.v1."; +const PROVIDER_TYPE_PREFIX = "/akash.provider.v1beta4."; + +export const AEP86_MESSAGE_TYPE_URLS = [ + `${VERIFICATION_TYPE_PREFIX}MsgPostAuditorBond`, + `${VERIFICATION_TYPE_PREFIX}MsgSubmitAttestation`, + `${VERIFICATION_TYPE_PREFIX}MsgOpenAuditEscrow`, + `${VERIFICATION_TYPE_PREFIX}MsgCancelAuditEscrow`, + `${VERIFICATION_TYPE_PREFIX}MsgSettleAuditEscrow`, + `${VERIFICATION_TYPE_PREFIX}MsgRevokeAttestation`, + `${VERIFICATION_TYPE_PREFIX}MsgRemoveAttestation`, + `${VERIFICATION_TYPE_PREFIX}MsgResignAuditor`, + `${VERIFICATION_TYPE_PREFIX}MsgPostProviderBond`, + `${VERIFICATION_TYPE_PREFIX}MsgWithdrawProviderBond`, + `${VERIFICATION_TYPE_PREFIX}MsgPostSnapshotHash`, + `${VERIFICATION_TYPE_PREFIX}MsgRegisterAuditor`, + `${VERIFICATION_TYPE_PREFIX}MsgRenewAuditor`, + `${VERIFICATION_TYPE_PREFIX}MsgRemoveAuditor`, + `${VERIFICATION_TYPE_PREFIX}MsgRevokeProviderAttestation`, + `${VERIFICATION_TYPE_PREFIX}MsgRevokeAllProviderAttestations`, + `${VERIFICATION_TYPE_PREFIX}MsgRevokeAuditorAttestations`, + `${VERIFICATION_TYPE_PREFIX}MsgResolveDiscrepancy`, + `${VERIFICATION_TYPE_PREFIX}MsgSlashProviderBond`, + `${VERIFICATION_TYPE_PREFIX}MsgUpdateParams`, + `${PROVIDER_TYPE_PREFIX}MsgOpenProviderMaintenance`, + `${PROVIDER_TYPE_PREFIX}MsgCloseProviderMaintenance` +] as const; + +export const AEP86_EVENT_TYPE_URLS = [ + `${VERIFICATION_TYPE_PREFIX}EventAuditorRegistered`, + `${VERIFICATION_TYPE_PREFIX}EventAuditorBondPosted`, + `${VERIFICATION_TYPE_PREFIX}EventAuditorFrozen`, + `${VERIFICATION_TYPE_PREFIX}EventAuditorLapsed`, + `${VERIFICATION_TYPE_PREFIX}EventAuditorResigned`, + `${VERIFICATION_TYPE_PREFIX}EventAuditorRemoved`, + `${VERIFICATION_TYPE_PREFIX}EventAuditorRenewed`, + `${VERIFICATION_TYPE_PREFIX}EventAttestationSubmitted`, + `${VERIFICATION_TYPE_PREFIX}EventAttestationExpired`, + `${VERIFICATION_TYPE_PREFIX}EventAttestationReplaced`, + `${VERIFICATION_TYPE_PREFIX}EventAttestationRevoked`, + `${VERIFICATION_TYPE_PREFIX}EventAttestationVoided`, + `${VERIFICATION_TYPE_PREFIX}EventDiscrepancyDetected`, + `${VERIFICATION_TYPE_PREFIX}EventDiscrepancyResolved`, + `${VERIFICATION_TYPE_PREFIX}EventDiscrepancyTimedOut`, + `${VERIFICATION_TYPE_PREFIX}EventProviderBondPosted`, + `${VERIFICATION_TYPE_PREFIX}EventProviderBondSlashed`, + `${VERIFICATION_TYPE_PREFIX}EventProviderBondWithdrawalInitiated`, + `${VERIFICATION_TYPE_PREFIX}EventProviderBondWithdrawalCompleted`, + `${VERIFICATION_TYPE_PREFIX}EventSnapshotHashPosted`, + `${VERIFICATION_TYPE_PREFIX}EventSnapshotSuspended`, + `${VERIFICATION_TYPE_PREFIX}EventSnapshotResumed`, + `${VERIFICATION_TYPE_PREFIX}EventFeeEscrowed`, + `${VERIFICATION_TYPE_PREFIX}EventFeeReleasedToAuditor`, + `${VERIFICATION_TYPE_PREFIX}EventFeeReturnedToProvider`, + `${VERIFICATION_TYPE_PREFIX}EventAuditEscrowOpened`, + `${VERIFICATION_TYPE_PREFIX}EventAuditEscrowSettled`, + `${VERIFICATION_TYPE_PREFIX}EventDepositReturnedToAuditor`, + `${VERIFICATION_TYPE_PREFIX}EventDepositSlashed`, + `${VERIFICATION_TYPE_PREFIX}EventVerificationGraceStarted`, + `${VERIFICATION_TYPE_PREFIX}EventVerificationGraceEnded`, + `${PROVIDER_TYPE_PREFIX}EventProviderMaintenanceOpened`, + `${PROVIDER_TYPE_PREFIX}EventProviderMaintenanceClosed` +] as const; + +const messageTypeUrls = new Set(AEP86_MESSAGE_TYPE_URLS); +const eventTypeUrls = new Set(AEP86_EVENT_TYPE_URLS); + +function normalizeTypeUrl(typeUrl: string): string { + return typeUrl.startsWith("/") ? typeUrl : `/${typeUrl}`; +} + +export function isAep86MessageType(typeUrl: string): boolean { + return messageTypeUrls.has(normalizeTypeUrl(typeUrl)); +} + +export function isAep86EventType(typeUrl: string): boolean { + return eventTypeUrls.has(normalizeTypeUrl(typeUrl)); +} diff --git a/apps/stats-web/src/hooks/useFriendlyMessageType.spec.ts b/apps/stats-web/src/hooks/useFriendlyMessageType.spec.ts new file mode 100644 index 0000000000..d99302b900 --- /dev/null +++ b/apps/stats-web/src/hooks/useFriendlyMessageType.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { getFriendlyMessageType } from "./useFriendlyMessageType"; + +describe("getFriendlyMessageType", () => { + it("formats full message type URLs", () => { + expect(getFriendlyMessageType("/akash.verification.v1.MsgSubmitAttestation")).toBe("Submit Attestation"); + }); + + it("formats typed events without leaving the Event prefix behind", () => { + expect(getFriendlyMessageType("akash.provider.v1beta4.EventProviderMaintenanceOpened")).toBe("Provider Maintenance Opened"); + }); + + it("preserves existing unprefixed names", () => { + expect(getFriendlyMessageType("akash.market.v1.LeaseClosed")).toBe("Lease Closed"); + }); +}); diff --git a/apps/stats-web/src/hooks/useFriendlyMessageType.ts b/apps/stats-web/src/hooks/useFriendlyMessageType.ts index c809d875fa..7ea866d2af 100644 --- a/apps/stats-web/src/hooks/useFriendlyMessageType.ts +++ b/apps/stats-web/src/hooks/useFriendlyMessageType.ts @@ -1,12 +1,9 @@ -export const useFriendlyMessageType = (type: string) => { +export const getFriendlyMessageType = (type: string) => { if (!type) return ""; const splittedType = type.split("."); - const msgType = splittedType[splittedType.length - 1]; - const friendlyMessageType = msgType - .substring(3) // Remove "Msg" - .split(/(?=[A-Z])/) - .join(" "); - - return friendlyMessageType; + const messageType = splittedType[splittedType.length - 1].replace(/^(Msg|Event)/, ""); + return messageType.split(/(?=[A-Z])/).join(" "); }; + +export const useFriendlyMessageType = getFriendlyMessageType; From c9b0f02de316f914d0265283a6adb6bf91d359f7 Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 20:37:14 -0700 Subject: [PATCH 08/12] build(repo): sync provider verification workspace Register the shared evaluator and package-level test dependencies in the workspace lockfile so clean installs resolve the AEP-86 package graph. Signed-off-by: Joseph Chalabi --- package-lock.json | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c5f312e3f..900df4835b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -2173,6 +2174,7 @@ "@akashnetwork/env-loader": "*", "@akashnetwork/instrumentation": "*", "@akashnetwork/logging": "*", + "@akashnetwork/provider-verification": "*", "@cosmjs/crypto": "~0.38.0", "@cosmjs/encoding": "~0.38.0", "@cosmjs/proto-signing": "~0.38.0", @@ -3705,6 +3707,7 @@ "@akashnetwork/env-loader": "*", "@akashnetwork/instrumentation": "*", "@akashnetwork/logging": "*", + "@akashnetwork/provider-verification": "*", "@hono/node-server": "^1.19.0", "@hono/otel": "~0.4.0", "@hono/zod-openapi": "^0.18.4", @@ -5657,6 +5660,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 @@ -44976,7 +44983,8 @@ "sequelize-typescript": "^2.1.5" }, "devDependencies": { - "@akashnetwork/dev-config": "*" + "@akashnetwork/dev-config": "*", + "vitest": "^4.1.5" } }, "packages/dev-config": { @@ -45938,7 +45946,8 @@ "jotai": "^2.9.2" }, "devDependencies": { - "@akashnetwork/dev-config": "*" + "@akashnetwork/dev-config": "*", + "vitest": "^4.1.5" } }, "packages/openapi-sdk": { @@ -45950,6 +45959,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.41" + }, + "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", From b6c769ee22605d719663f61dd656c3060d0ba4df Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 22:40:27 -0700 Subject: [PATCH 09/12] fix(stats): keep formatter export canonical Use the existing hook export directly. This avoids a duplicate public symbol. Signed-off-by: Joseph Chalabi --- .../stats-web/src/hooks/useFriendlyMessageType.spec.ts | 10 +++++----- apps/stats-web/src/hooks/useFriendlyMessageType.ts | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/stats-web/src/hooks/useFriendlyMessageType.spec.ts b/apps/stats-web/src/hooks/useFriendlyMessageType.spec.ts index d99302b900..3c5f11a364 100644 --- a/apps/stats-web/src/hooks/useFriendlyMessageType.spec.ts +++ b/apps/stats-web/src/hooks/useFriendlyMessageType.spec.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from "vitest"; -import { getFriendlyMessageType } from "./useFriendlyMessageType"; +import { useFriendlyMessageType } from "./useFriendlyMessageType"; -describe("getFriendlyMessageType", () => { +describe("useFriendlyMessageType", () => { it("formats full message type URLs", () => { - expect(getFriendlyMessageType("/akash.verification.v1.MsgSubmitAttestation")).toBe("Submit Attestation"); + expect(useFriendlyMessageType("/akash.verification.v1.MsgSubmitAttestation")).toBe("Submit Attestation"); }); it("formats typed events without leaving the Event prefix behind", () => { - expect(getFriendlyMessageType("akash.provider.v1beta4.EventProviderMaintenanceOpened")).toBe("Provider Maintenance Opened"); + expect(useFriendlyMessageType("akash.provider.v1beta4.EventProviderMaintenanceOpened")).toBe("Provider Maintenance Opened"); }); it("preserves existing unprefixed names", () => { - expect(getFriendlyMessageType("akash.market.v1.LeaseClosed")).toBe("Lease Closed"); + expect(useFriendlyMessageType("akash.market.v1.LeaseClosed")).toBe("Lease Closed"); }); }); diff --git a/apps/stats-web/src/hooks/useFriendlyMessageType.ts b/apps/stats-web/src/hooks/useFriendlyMessageType.ts index 7ea866d2af..68c1caa26b 100644 --- a/apps/stats-web/src/hooks/useFriendlyMessageType.ts +++ b/apps/stats-web/src/hooks/useFriendlyMessageType.ts @@ -1,9 +1,7 @@ -export const getFriendlyMessageType = (type: string) => { +export const useFriendlyMessageType = (type: string) => { if (!type) return ""; const splittedType = type.split("."); const messageType = splittedType[splittedType.length - 1].replace(/^(Msg|Event)/, ""); return messageType.split(/(?=[A-Z])/).join(" "); }; - -export const useFriendlyMessageType = getFriendlyMessageType; From a484ef3ce17e1866fb1cebef47300103f4c9f9d6 Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 22:40:38 -0700 Subject: [PATCH 10/12] test(sdl): await verification selections Drive the Radix selects through user events so form assertions wait for state updates. Signed-off-by: Joseph Chalabi --- .../sdl/PlacementVerificationFormControl.spec.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.spec.tsx b/apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.spec.tsx index 615bb5f92f..7ad2ce2455 100644 --- a/apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.spec.tsx +++ b/apps/deploy-web/src/components/sdl/PlacementVerificationFormControl.spec.tsx @@ -10,7 +10,7 @@ import { defaultServiceWithPlacement } from "@src/utils/sdl/data"; import type { PlacementVerificationRefType } from "./PlacementVerificationFormControl"; import { PlacementVerificationFormControl } from "./PlacementVerificationFormControl"; -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; describe(PlacementVerificationFormControl.name, () => { @@ -42,11 +42,12 @@ describe(PlacementVerificationFormControl.name, () => { expect(screen.getByText("Any provider may bid")).toBeInTheDocument(); }); - it("updates the tier and its concise meaning", () => { + it("updates the tier and its concise meaning", async () => { + const user = userEvent.setup(); const { form } = setup({ verification: buildVerification() }); - fireEvent.click(screen.getByRole("combobox", { name: "Minimum verification tier" })); - fireEvent.click(screen.getByRole("option", { name: "L3 - Established" })); + await user.click(screen.getByRole("combobox", { name: "Minimum verification tier" })); + await user.click(screen.getByRole("option", { name: "L3 - Established" })); expect(form().getValues("placements.0.verification.minTier")).toBe(3); expect(screen.getByText("Sustained reliability checked")).toBeInTheDocument(); @@ -85,8 +86,8 @@ describe(PlacementVerificationFormControl.name, () => { await user.click(screen.getByRole("button", { name: "Add auditor" })); await user.type(screen.getByRole("textbox", { name: "Auditor 1" }), "akash1auditor"); - fireEvent.click(screen.getByRole("combobox", { name: "Named auditor policy" })); - fireEvent.click(screen.getByRole("option", { name: "All listed auditors" })); + await user.click(screen.getByRole("combobox", { name: "Named auditor policy" })); + await user.click(screen.getByRole("option", { name: "All listed auditors" })); expect(form().getValues("placements.0.verification.auditors.0.value")).toBe("akash1auditor"); expect(form().getValues("placements.0.verification.auditorMode")).toBe("all"); From 97b6977bee46abe4114fb08340e68858960f9ea4 Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 22:40:48 -0700 Subject: [PATCH 11/12] build(repo): use AEP-86 SDK alpha Pin the workspace to chain-sdk 1.0.0-alpha.43, including the verification manifest projection. Signed-off-by: Joseph Chalabi --- apps/api/package.json | 2 +- apps/deploy-web/package.json | 2 +- apps/indexer/package.json | 2 +- apps/notifications/package.json | 2 +- apps/provider-inventory/package.json | 2 +- apps/provider-proxy/package.json | 2 +- apps/stats-web/package.json | 2 +- apps/tx-signer/package.json | 2 +- package-lock.json | 26 ++++++++++----------- packages/network-store/package.json | 2 +- packages/provider-verification/package.json | 2 +- 11 files changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 90f59a0c05..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": "*", diff --git a/apps/deploy-web/package.json b/apps/deploy-web/package.json index a189307cb0..44ee772dab 100644 --- a/apps/deploy-web/package.json +++ b/apps/deploy-web/package.json @@ -26,7 +26,7 @@ "type-check": "tsc" }, "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41", + "@akashnetwork/chain-sdk": "1.0.0-alpha.43", "@akashnetwork/console-api-types": "*", "@akashnetwork/env-loader": "*", "@akashnetwork/http-sdk": "*", diff --git a/apps/indexer/package.json b/apps/indexer/package.json index 88f8b43dd7..298bd3346d 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/provider-inventory/package.json b/apps/provider-inventory/package.json index 736dca6947..2ed4d7cb05 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-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 900df4835b..0fa2dad0e5 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": "*", @@ -543,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": "*", @@ -2169,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": "*", @@ -2560,7 +2560,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": "*", @@ -3703,7 +3703,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": "*", @@ -4044,7 +4044,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": "*", @@ -4221,7 +4221,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": "*", @@ -4985,7 +4985,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": "*", @@ -5476,9 +5476,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", @@ -45941,7 +45941,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" }, @@ -45964,7 +45964,7 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41" + "@akashnetwork/chain-sdk": "1.0.0-alpha.43" }, "devDependencies": { "@akashnetwork/dev-config": "*", diff --git a/packages/network-store/package.json b/packages/network-store/package.json index e7e5e58801..aae3153786 100644 --- a/packages/network-store/package.json +++ b/packages/network-store/package.json @@ -20,7 +20,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 index 7373ea7841..904a0c8c51 100644 --- a/packages/provider-verification/package.json +++ b/packages/provider-verification/package.json @@ -20,7 +20,7 @@ "validate:types": "tsc -p tsconfig.build.json --noEmit && echo" }, "dependencies": { - "@akashnetwork/chain-sdk": "1.0.0-alpha.41" + "@akashnetwork/chain-sdk": "1.0.0-alpha.43" }, "devDependencies": { "@akashnetwork/dev-config": "*", From 7e691011b4baa1161176f519a5de3cc7a2b1f1f8 Mon Sep 17 00:00:00 2001 From: Joseph Chalabi Date: Wed, 26 Aug 2026 23:19:17 -0700 Subject: [PATCH 12/12] test(provider): update verification API snapshot Signed-off-by: Joseph Chalabi --- .../__snapshots__/docs.spec.ts.snap | 2486 +++++++++++++++-- 1 file changed, 2179 insertions(+), 307 deletions(-) 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",