From 73c30edf21ccc8e336e6d184fc24b4c492e01ff4 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:34:44 +0530 Subject: [PATCH 1/3] feat(indexer): akash provider and audit attribute handlers Handles MsgCreate/Update/DeleteProvider across provider v1beta1-v1beta4 and MsgSign/DeleteProviderAttributes across audit v1beta1-v1beta3 + v1, writing to typed akash.providers and akash.provider_audit_signatures tables via a new ProviderWriter with watermark-guarded upserts. Part of CON-812 --- .../drizzle/0007_secret_overlord.sql | 24 + .../drizzle/meta/0007_snapshot.json | 2321 +++++++++++++++++ apps/chain-indexer/drizzle/meta/_journal.json | 7 + .../src/akash/akash-changes.spec.ts | 46 + apps/chain-indexer/src/akash/akash-changes.ts | 35 + .../src/akash/akash-deriver.spec.ts | 70 + apps/chain-indexer/src/akash/akash-deriver.ts | 10 +- .../src/akash/akash-writer.service.spec.ts | 14 + .../src/akash/akash-writer.service.ts | 7 + .../src/akash/deployment-reducer.spec.ts | 9 + .../src/akash/deployment-reducer.ts | 4 + .../src/akash/normalize-audit.spec.ts | 62 + .../src/akash/normalize-audit.ts | 35 + .../src/akash/normalize-provider.spec.ts | 74 + .../src/akash/normalize-provider.ts | 46 + .../src/akash/provider-writer.service.spec.ts | 260 ++ .../src/akash/provider-writer.service.ts | 262 ++ apps/chain-indexer/src/akash/resources.ts | 2 +- apps/chain-indexer/src/db/schema.spec.ts | 24 + apps/chain-indexer/src/db/schema.ts | 50 + .../pipeline/block-committer.service.spec.ts | 18 +- .../src/pipeline/block-committer.service.ts | 5 + 22 files changed, 3380 insertions(+), 5 deletions(-) create mode 100644 apps/chain-indexer/drizzle/0007_secret_overlord.sql create mode 100644 apps/chain-indexer/drizzle/meta/0007_snapshot.json create mode 100644 apps/chain-indexer/src/akash/akash-changes.spec.ts create mode 100644 apps/chain-indexer/src/akash/normalize-audit.spec.ts create mode 100644 apps/chain-indexer/src/akash/normalize-audit.ts create mode 100644 apps/chain-indexer/src/akash/normalize-provider.spec.ts create mode 100644 apps/chain-indexer/src/akash/normalize-provider.ts create mode 100644 apps/chain-indexer/src/akash/provider-writer.service.spec.ts create mode 100644 apps/chain-indexer/src/akash/provider-writer.service.ts diff --git a/apps/chain-indexer/drizzle/0007_secret_overlord.sql b/apps/chain-indexer/drizzle/0007_secret_overlord.sql new file mode 100644 index 0000000000..f84d5497bc --- /dev/null +++ b/apps/chain-indexer/drizzle/0007_secret_overlord.sql @@ -0,0 +1,24 @@ +CREATE TABLE "akash"."provider_audit_signatures" ( + "owner_account_id" integer NOT NULL, + "auditor_account_id" integer NOT NULL, + "key" text NOT NULL, + "value" text NOT NULL, + "height" bigint NOT NULL, + CONSTRAINT "provider_audit_signatures_owner_account_id_auditor_account_id_key_pk" PRIMARY KEY("owner_account_id","auditor_account_id","key") +); +--> statement-breakpoint +CREATE TABLE "akash"."providers" ( + "owner_account_id" integer PRIMARY KEY NOT NULL, + "host_uri" text NOT NULL, + "email" text, + "website" text, + "attributes" jsonb NOT NULL, + "last_processed_height" bigint NOT NULL, + "created_height" bigint NOT NULL, + "updated_height" bigint, + "deleted_height" bigint +); +--> statement-breakpoint +ALTER TABLE "akash"."provider_audit_signatures" ADD CONSTRAINT "provider_audit_signatures_owner_account_id_accounts_id_fk" FOREIGN KEY ("owner_account_id") REFERENCES "cosmos"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "akash"."provider_audit_signatures" ADD CONSTRAINT "provider_audit_signatures_auditor_account_id_accounts_id_fk" FOREIGN KEY ("auditor_account_id") REFERENCES "cosmos"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "akash"."providers" ADD CONSTRAINT "providers_owner_account_id_accounts_id_fk" FOREIGN KEY ("owner_account_id") REFERENCES "cosmos"."accounts"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/apps/chain-indexer/drizzle/meta/0007_snapshot.json b/apps/chain-indexer/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000000..cc6c10eb4e --- /dev/null +++ b/apps/chain-indexer/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2321 @@ +{ + "id": "a2860b14-74a0-48d3-825f-dc6cecbee0d3", + "prevId": "7703e3c3-9988-452e-ae30-44e28b863168", + "version": "7", + "dialect": "postgresql", + "tables": { + "cosmos.account_balances": { + "name": "account_balances", + "schema": "cosmos", + "columns": { + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "denom": { + "name": "denom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_balances_account_id_accounts_id_fk": { + "name": "account_balances_account_id_accounts_id_fk", + "tableFrom": "account_balances", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_balances_account_id_denom_pk": { + "name": "account_balances_account_id_denom_pk", + "columns": [ + "account_id", + "denom" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.account_txs": { + "name": "account_txs", + "schema": "cosmos", + "columns": { + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tx_index": { + "name": "tx_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "account_tx_role", + "typeSchema": "cosmos", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_txs_account_id_accounts_id_fk": { + "name": "account_txs_account_id_accounts_id_fk", + "tableFrom": "account_txs", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_txs_account_id_height_tx_index_role_pk": { + "name": "account_txs_account_id_height_tx_index_role_pk", + "columns": [ + "account_id", + "height", + "tx_index", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.accounts": { + "name": "accounts", + "schema": "cosmos", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_number": { + "name": "account_number", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_module_account": { + "name": "is_module_account", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "accounts_address_idx": { + "name": "accounts_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.balance_changes": { + "name": "balance_changes", + "schema": "cosmos", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "denom": { + "name": "denom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delta": { + "name": "delta", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "balance_change_reason", + "typeSchema": "cosmos", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tx_index": { + "name": "tx_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "event_index": { + "name": "event_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "counterparty_account_id": { + "name": "counterparty_account_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "balance_changes_account_denom_height_idx": { + "name": "balance_changes_account_denom_height_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "denom", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "height", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "balance_changes_height_event_index_idx": { + "name": "balance_changes_height_event_index_idx", + "columns": [ + { + "expression": "height", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "balance_changes_account_id_accounts_id_fk": { + "name": "balance_changes_account_id_accounts_id_fk", + "tableFrom": "balance_changes", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "balance_changes_counterparty_account_id_accounts_id_fk": { + "name": "balance_changes_counterparty_account_id_accounts_id_fk", + "tableFrom": "balance_changes", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "counterparty_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "akash.bids": { + "name": "bids", + "schema": "akash", + "columns": { + "deployment_id": { + "name": "deployment_id", + "type": "bigint", + "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, + "default": 0 + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "denom": { + "name": "denom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "bid_state", + "typeSchema": "akash", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_height": { + "name": "created_height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "closed_height": { + "name": "closed_height", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "bids_deployment_id_deployments_id_fk": { + "name": "bids_deployment_id_deployments_id_fk", + "tableFrom": "bids", + "tableTo": "deployments", + "schemaTo": "akash", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "bids_provider_account_id_accounts_id_fk": { + "name": "bids_provider_account_id_accounts_id_fk", + "tableFrom": "bids", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "provider_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bids_deployment_id_gseq_oseq_bseq_provider_account_id_pk": { + "name": "bids_deployment_id_gseq_oseq_bseq_provider_account_id_pk", + "columns": [ + "deployment_id", + "gseq", + "oseq", + "bseq", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.blocks": { + "name": "blocks", + "schema": "cosmos", + "columns": { + "height": { + "name": "height", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "datetime": { + "name": "datetime", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "parent_hash": { + "name": "parent_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "proposer_address": { + "name": "proposer_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_count": { + "name": "tx_count", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.delegations": { + "name": "delegations", + "schema": "cosmos", + "columns": { + "delegator_account_id": { + "name": "delegator_account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "validator_operator_address": { + "name": "validator_operator_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shares": { + "name": "shares", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "delegations_delegator_account_id_accounts_id_fk": { + "name": "delegations_delegator_account_id_accounts_id_fk", + "tableFrom": "delegations", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "delegator_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "delegations_delegator_account_id_validator_operator_address_pk": { + "name": "delegations_delegator_account_id_validator_operator_address_pk", + "columns": [ + "delegator_account_id", + "validator_operator_address" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "akash.deployment_events": { + "name": "deployment_events", + "schema": "akash", + "columns": { + "deployment_id": { + "name": "deployment_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tx_index": { + "name": "tx_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "msg_index": { + "name": "msg_index", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "deployment_event_type", + "typeSchema": "akash", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_events_deployment_id_deployments_id_fk": { + "name": "deployment_events_deployment_id_deployments_id_fk", + "tableFrom": "deployment_events", + "tableTo": "deployments", + "schemaTo": "akash", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deployment_events_deployment_id_height_ordinal_pk": { + "name": "deployment_events_deployment_id_height_ordinal_pk", + "columns": [ + "deployment_id", + "height", + "ordinal" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "akash.deployment_group_resources": { + "name": "deployment_group_resources", + "schema": "akash", + "columns": { + "deployment_group_id": { + "name": "deployment_group_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "idx": { + "name": "idx", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cpu_units": { + "name": "cpu_units", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "gpu_units": { + "name": "gpu_units", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "gpu_vendor": { + "name": "gpu_vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gpu_model": { + "name": "gpu_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memory_bytes": { + "name": "memory_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ephemeral_storage_bytes": { + "name": "ephemeral_storage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "persistent_storage_bytes": { + "name": "persistent_storage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "price_denom": { + "name": "price_denom", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_group_resources_deployment_group_id_deployment_groups_id_fk": { + "name": "deployment_group_resources_deployment_group_id_deployment_groups_id_fk", + "tableFrom": "deployment_group_resources", + "tableTo": "deployment_groups", + "schemaTo": "akash", + "columnsFrom": [ + "deployment_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deployment_group_resources_deployment_group_id_idx_pk": { + "name": "deployment_group_resources_deployment_group_id_idx_pk", + "columns": [ + "deployment_group_id", + "idx" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "akash.deployment_groups": { + "name": "deployment_groups", + "schema": "akash", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "gseq": { + "name": "gseq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "group_state", + "typeSchema": "akash", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "closed_height": { + "name": "closed_height", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deployment_groups_deployment_gseq_idx": { + "name": "deployment_groups_deployment_gseq_idx", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gseq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_groups_deployment_id_deployments_id_fk": { + "name": "deployment_groups_deployment_id_deployments_id_fk", + "tableFrom": "deployment_groups", + "tableTo": "deployments", + "schemaTo": "akash", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "akash.deployments": { + "name": "deployments", + "schema": "akash", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "owner_account_id": { + "name": "owner_account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dseq": { + "name": "dseq", + "type": "numeric(20, 0)", + "primaryKey": false, + "notNull": true + }, + "denom": { + "name": "denom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deposit": { + "name": "deposit", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "withdrawn_amount": { + "name": "withdrawn_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "block_rate": { + "name": "block_rate", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_withdraw_height": { + "name": "last_withdraw_height", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_processed_height": { + "name": "last_processed_height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_height": { + "name": "created_height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "closed_height": { + "name": "closed_height", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "close_reason": { + "name": "close_reason", + "type": "deployment_close_reason", + "typeSchema": "akash", + "primaryKey": false, + "notNull": false + }, + "cpu_units": { + "name": "cpu_units", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "gpu_units": { + "name": "gpu_units", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "memory_bytes": { + "name": "memory_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ephemeral_storage_bytes": { + "name": "ephemeral_storage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "persistent_storage_bytes": { + "name": "persistent_storage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "deployments_owner_dseq_idx": { + "name": "deployments_owner_dseq_idx", + "columns": [ + { + "expression": "owner_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dseq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployments_owner_created_idx": { + "name": "deployments_owner_created_idx", + "columns": [ + { + "expression": "owner_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_height", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployments_open_idx": { + "name": "deployments_open_idx", + "columns": [ + { + "expression": "created_height", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"akash\".\"deployments\".\"closed_height\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_owner_account_id_accounts_id_fk": { + "name": "deployments_owner_account_id_accounts_id_fk", + "tableFrom": "deployments", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "owner_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.indexer_state": { + "name": "indexer_state", + "schema": "", + "columns": { + "stream": { + "name": "stream", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_height": { + "name": "last_height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "akash.leases": { + "name": "leases", + "schema": "akash", + "columns": { + "deployment_id": { + "name": "deployment_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "deployment_group_id": { + "name": "deployment_group_id", + "type": "bigint", + "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, + "default": 0 + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "denom": { + "name": "denom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "withdrawn_amount": { + "name": "withdrawn_amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "predicted_closed_height": { + "name": "predicted_closed_height", + "type": "numeric(30, 0)", + "primaryKey": false, + "notNull": true + }, + "created_height": { + "name": "created_height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "closed_height": { + "name": "closed_height", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cpu_units": { + "name": "cpu_units", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "gpu_units": { + "name": "gpu_units", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "memory_bytes": { + "name": "memory_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ephemeral_storage_bytes": { + "name": "ephemeral_storage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "persistent_storage_bytes": { + "name": "persistent_storage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "leases_provider_idx": { + "name": "leases_provider_idx", + "columns": [ + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "closed_height", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_height", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "leases_open_idx": { + "name": "leases_open_idx", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"akash\".\"leases\".\"closed_height\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "leases_deployment_id_deployments_id_fk": { + "name": "leases_deployment_id_deployments_id_fk", + "tableFrom": "leases", + "tableTo": "deployments", + "schemaTo": "akash", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leases_deployment_group_id_deployment_groups_id_fk": { + "name": "leases_deployment_group_id_deployment_groups_id_fk", + "tableFrom": "leases", + "tableTo": "deployment_groups", + "schemaTo": "akash", + "columnsFrom": [ + "deployment_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "leases_provider_account_id_accounts_id_fk": { + "name": "leases_provider_account_id_accounts_id_fk", + "tableFrom": "leases", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "provider_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "leases_deployment_id_gseq_oseq_bseq_provider_account_id_pk": { + "name": "leases_deployment_id_gseq_oseq_bseq_provider_account_id_pk", + "columns": [ + "deployment_id", + "gseq", + "oseq", + "bseq", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.message_dead_letters": { + "name": "message_dead_letters", + "schema": "cosmos", + "columns": { + "height": { + "name": "height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tx_index": { + "name": "tx_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "raw": { + "name": "raw", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "message_dead_letters_type_id_idx": { + "name": "message_dead_letters_type_id_idx", + "columns": [ + { + "expression": "type_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_dead_letters_type_id_message_types_id_fk": { + "name": "message_dead_letters_type_id_message_types_id_fk", + "tableFrom": "message_dead_letters", + "tableTo": "message_types", + "schemaTo": "cosmos", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "message_dead_letters_height_tx_index_index_pk": { + "name": "message_dead_letters_height_tx_index_index_pk", + "columns": [ + "height", + "tx_index", + "index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.message_types": { + "name": "message_types", + "schema": "cosmos", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "message_types_type_idx": { + "name": "message_types_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.messages": { + "name": "messages", + "schema": "cosmos", + "columns": { + "height": { + "name": "height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tx_index": { + "name": "tx_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "messages_type_id_idx": { + "name": "messages_type_id_idx", + "columns": [ + { + "expression": "type_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_type_id_message_types_id_fk": { + "name": "messages_type_id_message_types_id_fk", + "tableFrom": "messages", + "tableTo": "message_types", + "schemaTo": "cosmos", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "messages_height_tx_index_index_pk": { + "name": "messages_height_tx_index_index_pk", + "columns": [ + "height", + "tx_index", + "index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.proposal_deposits": { + "name": "proposal_deposits", + "schema": "cosmos", + "columns": { + "proposal_id": { + "name": "proposal_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "depositor_account_id": { + "name": "depositor_account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "proposal_deposits_depositor_account_id_accounts_id_fk": { + "name": "proposal_deposits_depositor_account_id_accounts_id_fk", + "tableFrom": "proposal_deposits", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "depositor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "proposal_deposits_proposal_id_depositor_account_id_height_pk": { + "name": "proposal_deposits_proposal_id_depositor_account_id_height_pk", + "columns": [ + "proposal_id", + "depositor_account_id", + "height" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.proposal_votes": { + "name": "proposal_votes", + "schema": "cosmos", + "columns": { + "proposal_id": { + "name": "proposal_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "voter_account_id": { + "name": "voter_account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "proposal_votes_voter_account_id_accounts_id_fk": { + "name": "proposal_votes_voter_account_id_accounts_id_fk", + "tableFrom": "proposal_votes", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "voter_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "proposal_votes_proposal_id_voter_account_id_pk": { + "name": "proposal_votes_proposal_id_voter_account_id_pk", + "columns": [ + "proposal_id", + "voter_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.proposals": { + "name": "proposals", + "schema": "cosmos", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "proposer_account_id": { + "name": "proposer_account_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "proposal_status", + "typeSchema": "cosmos", + "primaryKey": false, + "notNull": true + }, + "submit_time": { + "name": "submit_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deposit_end_time": { + "name": "deposit_end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "voting_start_time": { + "name": "voting_start_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "voting_end_time": { + "name": "voting_end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_deposit": { + "name": "total_deposit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "final_tally_yes": { + "name": "final_tally_yes", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": false + }, + "final_tally_abstain": { + "name": "final_tally_abstain", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": false + }, + "final_tally_no": { + "name": "final_tally_no", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": false + }, + "final_tally_no_with_veto": { + "name": "final_tally_no_with_veto", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": false + }, + "submit_height": { + "name": "submit_height", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "proposals_proposer_account_id_accounts_id_fk": { + "name": "proposals_proposer_account_id_accounts_id_fk", + "tableFrom": "proposals", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "proposer_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "akash.provider_audit_signatures": { + "name": "provider_audit_signatures", + "schema": "akash", + "columns": { + "owner_account_id": { + "name": "owner_account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auditor_account_id": { + "name": "auditor_account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "provider_audit_signatures_owner_account_id_accounts_id_fk": { + "name": "provider_audit_signatures_owner_account_id_accounts_id_fk", + "tableFrom": "provider_audit_signatures", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "owner_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "provider_audit_signatures_auditor_account_id_accounts_id_fk": { + "name": "provider_audit_signatures_auditor_account_id_accounts_id_fk", + "tableFrom": "provider_audit_signatures", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "auditor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "provider_audit_signatures_owner_account_id_auditor_account_id_key_pk": { + "name": "provider_audit_signatures_owner_account_id_auditor_account_id_key_pk", + "columns": [ + "owner_account_id", + "auditor_account_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "akash.providers": { + "name": "providers", + "schema": "akash", + "columns": { + "owner_account_id": { + "name": "owner_account_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "host_uri": { + "name": "host_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_processed_height": { + "name": "last_processed_height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_height": { + "name": "created_height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_height": { + "name": "updated_height", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "deleted_height": { + "name": "deleted_height", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "providers_owner_account_id_accounts_id_fk": { + "name": "providers_owner_account_id_accounts_id_fk", + "tableFrom": "providers", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "owner_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.transactions": { + "name": "transactions", + "schema": "cosmos", + "columns": { + "height": { + "name": "height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "gas_used": { + "name": "gas_used", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "gas_wanted": { + "name": "gas_wanted", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "fee": { + "name": "fee", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "transactions_hash_idx": { + "name": "transactions_hash_idx", + "columns": [ + { + "expression": "hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "transactions_height_index_pk": { + "name": "transactions_height_index_pk", + "columns": [ + "height", + "index" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.unbonding_delegations": { + "name": "unbonding_delegations", + "schema": "cosmos", + "columns": { + "delegator_account_id": { + "name": "delegator_account_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "validator_operator_address": { + "name": "validator_operator_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creation_height": { + "name": "creation_height", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "completion_time": { + "name": "completion_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "initial_balance": { + "name": "initial_balance", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "unbonding_delegations_delegator_account_id_accounts_id_fk": { + "name": "unbonding_delegations_delegator_account_id_accounts_id_fk", + "tableFrom": "unbonding_delegations", + "tableTo": "accounts", + "schemaTo": "cosmos", + "columnsFrom": [ + "delegator_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "unbonding_delegations_delegator_account_id_validator_operator_address_creation_height_pk": { + "name": "unbonding_delegations_delegator_account_id_validator_operator_address_creation_height_pk", + "columns": [ + "delegator_account_id", + "validator_operator_address", + "creation_height" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "cosmos.validators": { + "name": "validators", + "schema": "cosmos", + "columns": { + "operator_address": { + "name": "operator_address", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_address": { + "name": "account_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hex_address": { + "name": "hex_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moniker": { + "name": "moniker", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity": { + "name": "identity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "security_contact": { + "name": "security_contact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commission_rate": { + "name": "commission_rate", + "type": "numeric(20, 18)", + "primaryKey": false, + "notNull": false + }, + "commission_max_rate": { + "name": "commission_max_rate", + "type": "numeric(20, 18)", + "primaryKey": false, + "notNull": false + }, + "commission_max_change_rate": { + "name": "commission_max_change_rate", + "type": "numeric(20, 18)", + "primaryKey": false, + "notNull": false + }, + "min_self_delegation": { + "name": "min_self_delegation", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": false + }, + "jailed": { + "name": "jailed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "validator_status", + "typeSchema": "cosmos", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "numeric(38, 0)", + "primaryKey": false, + "notNull": false + }, + "delegator_shares": { + "name": "delegator_shares", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "unbonding_height": { + "name": "unbonding_height", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "unbonding_time": { + "name": "unbonding_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "cosmos.account_tx_role": { + "name": "account_tx_role", + "schema": "cosmos", + "values": [ + "signer", + "sender", + "receiver" + ] + }, + "cosmos.balance_change_reason": { + "name": "balance_change_reason", + "schema": "cosmos", + "values": [ + "genesis", + "transfer", + "fee", + "reward", + "commission", + "slash", + "gov", + "ibc", + "escrow", + "bme", + "mint", + "burn", + "staking" + ] + }, + "akash.bid_state": { + "name": "bid_state", + "schema": "akash", + "values": [ + "open", + "active", + "closed" + ] + }, + "akash.deployment_close_reason": { + "name": "deployment_close_reason", + "schema": "akash", + "values": [ + "close_message", + "overdrawn", + "close_event" + ] + }, + "akash.deployment_event_type": { + "name": "deployment_event_type", + "schema": "akash", + "values": [ + "created", + "deposited", + "updated", + "closed", + "group_closed", + "group_paused", + "group_started", + "bid_created", + "bid_closed", + "lease_created", + "lease_closed", + "lease_withdrawn" + ] + }, + "akash.group_state": { + "name": "group_state", + "schema": "akash", + "values": [ + "open", + "paused", + "closed" + ] + }, + "cosmos.proposal_status": { + "name": "proposal_status", + "schema": "cosmos", + "values": [ + "deposit_period", + "voting_period", + "passed", + "rejected", + "failed" + ] + }, + "cosmos.validator_status": { + "name": "validator_status", + "schema": "cosmos", + "values": [ + "unbonded", + "unbonding", + "bonded" + ] + }, + "cosmos.vote_option": { + "name": "vote_option", + "schema": "cosmos", + "values": [ + "yes", + "abstain", + "no", + "no_with_veto" + ] + } + }, + "schemas": { + "akash": "akash", + "cosmos": "cosmos" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/chain-indexer/drizzle/meta/_journal.json b/apps/chain-indexer/drizzle/meta/_journal.json index eda4b50e95..dc53cc0966 100644 --- a/apps/chain-indexer/drizzle/meta/_journal.json +++ b/apps/chain-indexer/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1786877216729, "tag": "0006_next_earthquake", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1786898959325, + "tag": "0007_secret_overlord", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/chain-indexer/src/akash/akash-changes.spec.ts b/apps/chain-indexer/src/akash/akash-changes.spec.ts new file mode 100644 index 0000000000..55ca1ef94f --- /dev/null +++ b/apps/chain-indexer/src/akash/akash-changes.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import type { AkashBlockChanges, AkashChangeBody } from "@src/akash/akash-changes"; +import { collectAkashAddresses, isProviderChange } from "@src/akash/akash-changes"; + +const BLOCK_TIME = new Date("2026-08-13T00:00:00Z"); + +describe("collectAkashAddresses", () => { + it("collects owners, providers and depositors from deployment-keyed changes", () => { + const addresses = collectAkashAddresses([ + block([ + { kind: "deploymentCreated", key: { owner: "akash1owner", dseq: "1" }, denom: "uakt", deposit: "1", depositor: "akash1depositor", groups: [] }, + { kind: "bidCreated", key: { owner: "akash1owner", dseq: "1", gseq: 1, oseq: 1, bseq: 0, provider: "akash1bidder" }, price: "1", priceDenom: "uakt" } + ]) + ]); + + expect(addresses).toEqual(new Set(["akash1owner", "akash1depositor", "akash1bidder"])); + }); + + it("collects the owner and auditor from provider and audit changes", () => { + const addresses = collectAkashAddresses([ + block([ + { kind: "providerCreated", owner: "akash1prov", hostUri: "https://x", email: null, website: null, attributes: [] }, + { kind: "providerDeleted", owner: "akash1gone" }, + { kind: "providerAttributesSigned", owner: "akash1prov", auditor: "akash1auditor", attributes: [] }, + { kind: "providerAttributesUnsigned", owner: "akash1prov", auditor: "akash1revoker", keys: [] } + ]) + ]); + + expect(addresses).toEqual(new Set(["akash1prov", "akash1gone", "akash1auditor", "akash1revoker"])); + }); +}); + +describe("isProviderChange", () => { + it("narrows provider and audit kinds and rejects deployment-keyed ones", () => { + const provider: AkashChangeBody = { kind: "providerDeleted", owner: "akash1prov" }; + const deployment: AkashChangeBody = { kind: "deploymentClosed", key: { owner: "akash1owner", dseq: "1" } }; + + expect(isProviderChange({ ...provider, txIndex: 0, msgIndex: 0 })).toBe(true); + expect(isProviderChange({ ...deployment, txIndex: 0, msgIndex: 0 })).toBe(false); + }); +}); + +function block(bodies: AkashChangeBody[]): AkashBlockChanges { + return { height: 100, datetime: BLOCK_TIME, changes: bodies.map((body, index) => ({ ...body, txIndex: 0, msgIndex: index })) }; +} diff --git a/apps/chain-indexer/src/akash/akash-changes.ts b/apps/chain-indexer/src/akash/akash-changes.ts index ad34fe836c..60af27b64e 100644 --- a/apps/chain-indexer/src/akash/akash-changes.ts +++ b/apps/chain-indexer/src/akash/akash-changes.ts @@ -41,7 +41,21 @@ interface ChangeOrigin { msgIndex: number | null; } +export interface ProviderAttribute { + key: string; + value: string; +} + +export type ProviderChangeBody = + | { kind: "providerCreated"; owner: string; hostUri: string; email: string | null; website: string | null; attributes: ProviderAttribute[] } + | { kind: "providerUpdated"; owner: string; hostUri: string; email: string | null; website: string | null; attributes: ProviderAttribute[] } + | { kind: "providerDeleted"; owner: string } + | { kind: "providerAttributesSigned"; owner: string; auditor: string; attributes: ProviderAttribute[] } + /** Empty `keys` means the auditor revoked every attribute they signed for this provider. */ + | { kind: "providerAttributesUnsigned"; owner: string; auditor: string; keys: string[] }; + export type AkashChangeBody = + | ProviderChangeBody | { kind: "deploymentCreated"; key: DeploymentKey; denom: string; deposit: string; depositor: string | null; groups: NormalizedGroup[] } | { kind: "deploymentDeposited"; key: DeploymentKey; amount: string; depositor: string | null } | { kind: "deploymentUpdated"; key: DeploymentKey } @@ -61,6 +75,20 @@ export type AkashChange = AkashChangeBody & ChangeOrigin; export type AkashChangeKind = AkashChange["kind"]; +export type ProviderChange = ProviderChangeBody & ChangeOrigin; + +const PROVIDER_CHANGE_KINDS = new Set([ + "providerCreated", + "providerUpdated", + "providerDeleted", + "providerAttributesSigned", + "providerAttributesUnsigned" +]); + +export function isProviderChange(change: AkashChange): change is ProviderChange { + return PROVIDER_CHANGE_KINDS.has(change.kind); +} + /** Everything derived from one block, in the exact order the chain applied it (tx order, then message order, then that tx's close events). */ export interface AkashBlockChanges { height: number; @@ -74,6 +102,13 @@ export function collectAkashAddresses(blocks: AkashBlockChanges[]): Set for (const block of blocks) { for (const change of block.changes) { + if (isProviderChange(change)) { + addresses.add(change.owner); + if ("auditor" in change) { + addresses.add(change.auditor); + } + continue; + } addresses.add(change.key.owner); if ("provider" in change) { addresses.add(change.provider); diff --git a/apps/chain-indexer/src/akash/akash-deriver.spec.ts b/apps/chain-indexer/src/akash/akash-deriver.spec.ts index b29628df0e..eb14ee295c 100644 --- a/apps/chain-indexer/src/akash/akash-deriver.spec.ts +++ b/apps/chain-indexer/src/akash/akash-deriver.spec.ts @@ -30,6 +30,76 @@ describe("deriveAkashChanges", () => { ]); }); + it("derives provider and audit changes alongside deployment ones", () => { + const changes = deriveAkashChanges( + block({ + messages: [ + { + typeUrl: "/akash.provider.v1beta4.MsgCreateProvider", + body: { owner: "akash1owner", hostUri: "https://provider.example.com:8443", attributes: [{ key: "region", value: "us-west" }], info: {} } + }, + { + typeUrl: "/akash.audit.v1.MsgSignProviderAttributes", + body: { owner: "akash1owner", auditor: "akash1auditor", attributes: [{ key: "region", value: "us-west" }] } + } + ] + }) + ); + + expect(changes.changes).toEqual([ + { + kind: "providerCreated", + owner: "akash1owner", + hostUri: "https://provider.example.com:8443", + email: null, + website: null, + attributes: [{ key: "region", value: "us-west" }], + txIndex: 0, + msgIndex: 0 + }, + { + kind: "providerAttributesSigned", + owner: "akash1owner", + auditor: "akash1auditor", + attributes: [{ key: "region", value: "us-west" }], + txIndex: 0, + msgIndex: 1 + } + ]); + }); + + it("unwraps an authz-wrapped audit sign", () => { + const changes = deriveAkashChanges( + block({ + messages: [ + { + typeUrl: "/cosmos.authz.v1beta1.MsgExec", + body: { + grantee: "akash1grantee", + msgs: [ + { + typeUrl: "/akash.audit.v1beta3.MsgSignProviderAttributes", + decoded: { owner: "akash1owner", auditor: "akash1auditor", attributes: [{ key: "tier", value: "community" }] } + } + ] + } + } + ] + }) + ); + + expect(changes.changes).toEqual([ + { + kind: "providerAttributesSigned", + owner: "akash1owner", + auditor: "akash1auditor", + attributes: [{ key: "tier", value: "community" }], + txIndex: 0, + msgIndex: 0 + } + ]); + }); + it("skips messages in failed transactions", () => { const changes = deriveAkashChanges( block({ diff --git a/apps/chain-indexer/src/akash/akash-deriver.ts b/apps/chain-indexer/src/akash/akash-deriver.ts index 6f600ce495..d69315550c 100644 --- a/apps/chain-indexer/src/akash/akash-deriver.ts +++ b/apps/chain-indexer/src/akash/akash-deriver.ts @@ -1,7 +1,9 @@ import type { AkashBlockChanges, AkashChange, AkashChangeBody } from "@src/akash/akash-changes"; import { asInteger, asRecord, asString } from "@src/akash/json"; +import { normalizeAuditMessage } from "@src/akash/normalize-audit"; import { normalizeDeploymentMessage } from "@src/akash/normalize-deployment"; import { normalizeMarketMessage } from "@src/akash/normalize-market"; +import { normalizeProviderMessage } from "@src/akash/normalize-provider"; import { asUint64String } from "@src/akash/uint64"; import type { DecodedBlock, DecodedEvent } from "@src/pipeline/decoded-block"; import { MAX_EXEC_DEPTH, MSG_EXEC_TYPE_URL } from "@src/pipeline/msg-exec"; @@ -11,7 +13,7 @@ const DEPLOYMENT_CLOSED_EVENT_TYPE = "akash.deployment.v1.EventDeploymentClosed" const LEASE_CLOSED_EVENT_TYPE = "akash.market.v1.EventLeaseClosed"; /** - * Extracts the deployment and market lifecycle from a block's messages and close events, in the exact + * Extracts the deployment, market, provider and audit lifecycle from a block's messages and close events, in the exact * order the chain applied it: per transaction, messages first (authz MsgExec unwrapped through the * decoder-provided `decoded` field), then that transaction's close events, which catch deployment and * lease closes happening as side effects (group close, authz revoke, overdraw on withdraw). Messages @@ -56,7 +58,11 @@ function addMessage(changes: AkashChange[], typeUrl: string, body: unknown, txIn return; } - const normalized = normalizeDeploymentMessage(typeUrl, record) ?? normalizeMarketMessage(typeUrl, record); + const normalized = + normalizeDeploymentMessage(typeUrl, record) ?? + normalizeMarketMessage(typeUrl, record) ?? + normalizeProviderMessage(typeUrl, record) ?? + normalizeAuditMessage(typeUrl, record); if (normalized) { changes.push({ ...normalized, txIndex, msgIndex }); diff --git a/apps/chain-indexer/src/akash/akash-writer.service.spec.ts b/apps/chain-indexer/src/akash/akash-writer.service.spec.ts index 2f332f3321..fa1e0fb5d9 100644 --- a/apps/chain-indexer/src/akash/akash-writer.service.spec.ts +++ b/apps/chain-indexer/src/akash/akash-writer.service.spec.ts @@ -95,6 +95,20 @@ describe(AkashWriter.name, () => { expect(inserts).toEqual([]); }); + it("ignores provider changes entirely", async () => { + const { writer, tx, inserts, selects, logger } = setup(); + + await writer.write( + tx, + [block(100, [{ kind: "providerCreated", owner: OWNER, hostUri: "https://x", email: null, website: null, attributes: [] }])], + ACCOUNT_IDS + ); + + expect(inserts).toEqual([]); + expect(selects).toEqual([]); + expect(logger.warn).not.toHaveBeenCalled(); + }); + it("logs orphan references without aborting the batch", async () => { const { writer, tx, logger, inserts } = setup(); diff --git a/apps/chain-indexer/src/akash/akash-writer.service.ts b/apps/chain-indexer/src/akash/akash-writer.service.ts index 1471145dd1..605f4e699b 100644 --- a/apps/chain-indexer/src/akash/akash-writer.service.ts +++ b/apps/chain-indexer/src/akash/akash-writer.service.ts @@ -3,6 +3,7 @@ import groupBy from "lodash/groupBy"; import { inject, singleton } from "tsyringe"; import type { AkashBlockChanges, DeploymentKey, NormalizedResource } from "@src/akash/akash-changes"; +import { isProviderChange } from "@src/akash/akash-changes"; import { decFromString, decToString } from "@src/akash/dec"; import type { BidStateValue, DeploymentAggState, GroupStateValue, ReducerWarning } from "@src/akash/deployment-reducer"; import { applyBlockChanges, stateKey } from "@src/akash/deployment-reducer"; @@ -42,6 +43,9 @@ export class AkashWriter { } const keyed = this.#collectKeys(withChanges, accountIds); + if (keyed.length === 0) { + return; + } const { states, deploymentIds, groupIds, loadedAddressIds } = await this.#loadStates(tx, keyed); const warnings = withChanges.flatMap(block => applyBlockChanges(states, block)); @@ -68,6 +72,9 @@ export class AkashWriter { const byKey = new Map(); for (const block of blocks) { for (const change of block.changes) { + if (isProviderChange(change)) { + continue; + } byKey.set(stateKey(change.key), change.key); } } diff --git a/apps/chain-indexer/src/akash/deployment-reducer.spec.ts b/apps/chain-indexer/src/akash/deployment-reducer.spec.ts index 6ebc1dd3dd..c24ffbec13 100644 --- a/apps/chain-indexer/src/akash/deployment-reducer.spec.ts +++ b/apps/chain-indexer/src/akash/deployment-reducer.spec.ts @@ -166,6 +166,15 @@ describe("applyBlockChanges", () => { expect(warnings).toEqual([{ code: "AKASH_ORPHAN_REFERENCE", kind: "deploymentDeposited", owner: OWNER, dseq: "42", height: 100 }]); }); + it("ignores provider changes without warning or mutating state", () => { + const { states } = setup(); + + const warnings = applyBlockChanges(states, block(100, [{ kind: "providerDeleted", owner: OWNER }])); + + expect(states.size).toBe(0); + expect(warnings).toEqual([]); + }); + it("applies close-event fallbacks with settlement, only when not already closed", () => { const { states } = setup(); diff --git a/apps/chain-indexer/src/akash/deployment-reducer.ts b/apps/chain-indexer/src/akash/deployment-reducer.ts index 59809b9385..6cb1b4dea9 100644 --- a/apps/chain-indexer/src/akash/deployment-reducer.ts +++ b/apps/chain-indexer/src/akash/deployment-reducer.ts @@ -1,4 +1,5 @@ import type { AkashBlockChanges, AkashChange, DeploymentKey, LeaseSlot, NormalizedGroup, NormalizedResource } from "@src/akash/akash-changes"; +import { isProviderChange } from "@src/akash/akash-changes"; import { decCeilInt, decFromInt, decFromString, decQuo, decToString, decTruncateInt } from "@src/akash/dec"; import { normalizeDenom } from "@src/akash/denom"; import { settle, sumLeaseRate } from "@src/akash/settlement"; @@ -109,6 +110,9 @@ export function applyBlockChanges(states: Map, block const decided = new Map(); for (const change of block.changes) { + if (isProviderChange(change)) { + continue; + } const key = stateKey(change.key); if (change.kind === "deploymentCreated") { diff --git a/apps/chain-indexer/src/akash/normalize-audit.spec.ts b/apps/chain-indexer/src/akash/normalize-audit.spec.ts new file mode 100644 index 0000000000..1d5c4563fd --- /dev/null +++ b/apps/chain-indexer/src/akash/normalize-audit.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeAuditMessage } from "@src/akash/normalize-audit"; + +describe("normalizeAuditMessage", () => { + it("normalizes a legacy v1beta1 sign with its attributes", () => { + const change = normalizeAuditMessage("/akash.audit.v1beta1.MsgSignProviderAttributes", { + owner: "akash1owner", + auditor: "akash1auditor", + attributes: [ + { key: "region", value: "us-west" }, + { key: "tier", value: "community" } + ] + }); + + expect(change).toEqual({ + kind: "providerAttributesSigned", + owner: "akash1owner", + auditor: "akash1auditor", + attributes: [ + { key: "region", value: "us-west" }, + { key: "tier", value: "community" } + ] + }); + }); + + it("normalizes a current v1 sign identically to the legacy eras", () => { + const change = normalizeAuditMessage("/akash.audit.v1.MsgSignProviderAttributes", { + owner: "akash1owner", + auditor: "akash1auditor", + attributes: [] + }); + + expect(change).toEqual({ kind: "providerAttributesSigned", owner: "akash1owner", auditor: "akash1auditor", attributes: [] }); + }); + + it("normalizes a keyed delete, dropping non-string keys", () => { + const change = normalizeAuditMessage("/akash.audit.v1beta3.MsgDeleteProviderAttributes", { + owner: "akash1owner", + auditor: "akash1auditor", + keys: ["region", 7, "tier"] + }); + + expect(change).toEqual({ kind: "providerAttributesUnsigned", owner: "akash1owner", auditor: "akash1auditor", keys: ["region", "tier"] }); + }); + + it("normalizes a delete without keys to an empty list meaning delete-all", () => { + const change = normalizeAuditMessage("/akash.audit.v1.MsgDeleteProviderAttributes", { owner: "akash1owner", auditor: "akash1auditor" }); + + expect(change).toEqual({ kind: "providerAttributesUnsigned", owner: "akash1owner", auditor: "akash1auditor", keys: [] }); + }); + + it("returns null when the owner or auditor is missing", () => { + expect(normalizeAuditMessage("/akash.audit.v1.MsgSignProviderAttributes", { owner: "akash1owner" })).toBeNull(); + expect(normalizeAuditMessage("/akash.audit.v1.MsgDeleteProviderAttributes", { auditor: "akash1auditor" })).toBeNull(); + }); + + it("returns null for unrelated type urls", () => { + expect(normalizeAuditMessage("/akash.audit.v1beta4.MsgSignProviderAttributes", { owner: "a", auditor: "b" })).toBeNull(); + expect(normalizeAuditMessage("/akash.provider.v1beta4.MsgCreateProvider", {})).toBeNull(); + }); +}); diff --git a/apps/chain-indexer/src/akash/normalize-audit.ts b/apps/chain-indexer/src/akash/normalize-audit.ts new file mode 100644 index 0000000000..59db22a7c3 --- /dev/null +++ b/apps/chain-indexer/src/akash/normalize-audit.ts @@ -0,0 +1,35 @@ +import type { AkashChangeBody } from "@src/akash/akash-changes"; +import { akashTypeUrlSet } from "@src/akash/akash-changes"; +import { asString } from "@src/akash/json"; +import { attributeList } from "@src/akash/resources"; + +const AUDIT_VERSIONS = ["v1beta1", "v1beta2", "v1beta3", "v1"] as const; + +const SIGN_ATTRIBUTES = typeUrlSet("MsgSignProviderAttributes"); +const DELETE_ATTRIBUTES = typeUrlSet("MsgDeleteProviderAttributes"); + +function typeUrlSet(name: string): Set { + return akashTypeUrlSet("audit", name, AUDIT_VERSIONS); +} + +export function normalizeAuditMessage(typeUrl: string, body: Record): AkashChangeBody | null { + if (SIGN_ATTRIBUTES.has(typeUrl)) { + const identity = auditIdentity(body); + return identity ? { kind: "providerAttributesSigned", ...identity, attributes: attributeList(body.attributes) } : null; + } + if (DELETE_ATTRIBUTES.has(typeUrl)) { + const identity = auditIdentity(body); + return identity ? { kind: "providerAttributesUnsigned", ...identity, keys: stringKeys(body.keys) } : null; + } + return null; +} + +function auditIdentity(body: Record): { owner: string; auditor: string } | null { + const owner = asString(body.owner); + const auditor = asString(body.auditor); + return owner && auditor ? { owner, auditor } : null; +} + +function stringKeys(keys: unknown): string[] { + return Array.isArray(keys) ? keys.filter((key): key is string => typeof key === "string") : []; +} diff --git a/apps/chain-indexer/src/akash/normalize-provider.spec.ts b/apps/chain-indexer/src/akash/normalize-provider.spec.ts new file mode 100644 index 0000000000..954d81df09 --- /dev/null +++ b/apps/chain-indexer/src/akash/normalize-provider.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeProviderMessage } from "@src/akash/normalize-provider"; + +describe("normalizeProviderMessage", () => { + it("normalizes a legacy v1beta1 create with info and attributes", () => { + const change = normalizeProviderMessage("/akash.provider.v1beta1.MsgCreateProvider", { + owner: "akash1owner", + hostUri: "https://provider.example.com:8443", + attributes: [{ key: "region", value: "us-west" }], + info: { email: "ops@example.com", website: "https://example.com" } + }); + + expect(change).toEqual({ + kind: "providerCreated", + owner: "akash1owner", + hostUri: "https://provider.example.com:8443", + email: "ops@example.com", + website: "https://example.com", + attributes: [{ key: "region", value: "us-west" }] + }); + }); + + it("normalizes a current v1beta4 update identically to the legacy eras", () => { + const change = normalizeProviderMessage("/akash.provider.v1beta4.MsgUpdateProvider", { + owner: "akash1owner", + hostUri: "https://new.example.com:8443", + attributes: [], + info: { email: "", website: "" } + }); + + expect(change).toEqual({ + kind: "providerUpdated", + owner: "akash1owner", + hostUri: "https://new.example.com:8443", + email: null, + website: null, + attributes: [] + }); + }); + + it("normalizes a create without info or attributes", () => { + const change = normalizeProviderMessage("/akash.provider.v1beta3.MsgCreateProvider", { + owner: "akash1owner", + hostUri: "https://provider.example.com:8443" + }); + + expect(change).toEqual({ + kind: "providerCreated", + owner: "akash1owner", + hostUri: "https://provider.example.com:8443", + email: null, + website: null, + attributes: [] + }); + }); + + it("normalizes a delete", () => { + const change = normalizeProviderMessage("/akash.provider.v1beta2.MsgDeleteProvider", { owner: "akash1owner" }); + + expect(change).toEqual({ kind: "providerDeleted", owner: "akash1owner" }); + }); + + it("returns null when the owner or host uri is missing", () => { + expect(normalizeProviderMessage("/akash.provider.v1beta4.MsgCreateProvider", { hostUri: "https://x" })).toBeNull(); + expect(normalizeProviderMessage("/akash.provider.v1beta4.MsgUpdateProvider", { owner: "akash1owner" })).toBeNull(); + expect(normalizeProviderMessage("/akash.provider.v1beta4.MsgDeleteProvider", {})).toBeNull(); + }); + + it("returns null for unrelated type urls", () => { + expect(normalizeProviderMessage("/akash.provider.v1beta5.MsgCreateProvider", { owner: "akash1owner", hostUri: "https://x" })).toBeNull(); + expect(normalizeProviderMessage("/akash.deployment.v1beta4.MsgCreateDeployment", {})).toBeNull(); + }); +}); diff --git a/apps/chain-indexer/src/akash/normalize-provider.ts b/apps/chain-indexer/src/akash/normalize-provider.ts new file mode 100644 index 0000000000..5ab7272d4c --- /dev/null +++ b/apps/chain-indexer/src/akash/normalize-provider.ts @@ -0,0 +1,46 @@ +import type { AkashChangeBody } from "@src/akash/akash-changes"; +import { akashTypeUrlSet } from "@src/akash/akash-changes"; +import { asRecord, asString } from "@src/akash/json"; +import { attributeList } from "@src/akash/resources"; + +const PROVIDER_VERSIONS = ["v1beta1", "v1beta2", "v1beta3", "v1beta4"] as const; + +const CREATE_PROVIDER = typeUrlSet("MsgCreateProvider"); +const UPDATE_PROVIDER = typeUrlSet("MsgUpdateProvider"); +const DELETE_PROVIDER = typeUrlSet("MsgDeleteProvider"); + +function typeUrlSet(name: string): Set { + return akashTypeUrlSet("provider", name, PROVIDER_VERSIONS); +} + +/** All four provider proto eras share the same message shape, so parsing is version-independent. */ +export function normalizeProviderMessage(typeUrl: string, body: Record): AkashChangeBody | null { + if (CREATE_PROVIDER.has(typeUrl)) { + return normalizeUpsert(body, "providerCreated"); + } + if (UPDATE_PROVIDER.has(typeUrl)) { + return normalizeUpsert(body, "providerUpdated"); + } + if (DELETE_PROVIDER.has(typeUrl)) { + const owner = asString(body.owner); + return owner ? { kind: "providerDeleted", owner } : null; + } + return null; +} + +function normalizeUpsert(body: Record, kind: "providerCreated" | "providerUpdated"): AkashChangeBody | null { + const owner = asString(body.owner); + const hostUri = asString(body.hostUri); + if (!owner || !hostUri) { + return null; + } + const info = asRecord(body.info); + return { + kind, + owner, + hostUri, + email: asString(info?.email) || null, + website: asString(info?.website) || null, + attributes: attributeList(body.attributes) + }; +} diff --git a/apps/chain-indexer/src/akash/provider-writer.service.spec.ts b/apps/chain-indexer/src/akash/provider-writer.service.spec.ts new file mode 100644 index 0000000000..5df8b8092f --- /dev/null +++ b/apps/chain-indexer/src/akash/provider-writer.service.spec.ts @@ -0,0 +1,260 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import type { AkashBlockChanges, AkashChangeBody } from "@src/akash/akash-changes"; +import { ProviderWriter } from "@src/akash/provider-writer.service"; +import { ProviderAuditSignatures, Providers } from "@src/db/schema"; +import type { ChainTransaction } from "@src/providers/db.provider"; +import type { LoggerService } from "@src/providers/logging.provider"; + +const OWNER = "akash1prov"; +const AUDITOR = "akash1auditor"; +const BLOCK_TIME = new Date("2026-08-13T00:00:00Z"); +const ACCOUNT_IDS = new Map([ + [OWNER, 7], + [AUDITOR, 8] +]); + +describe(ProviderWriter.name, () => { + it("does nothing for blocks without provider changes", async () => { + const { writer, tx, inserts, deletes, selects } = setup(); + + await writer.write(tx, [block(100, [{ kind: "deploymentClosed", key: { owner: OWNER, dseq: "1" } }])], ACCOUNT_IDS); + + expect(inserts).toEqual([]); + expect(deletes).toEqual([]); + expect(selects).toEqual([]); + }); + + it("folds a create, update and delete across blocks into one guarded upsert with the final state", async () => { + const { writer, tx, inserts, upserts } = setup(); + + await writer.write( + tx, + [ + block(100, [created()]), + block(110, [updated({ hostUri: "https://new.example.com:8443", email: "new@example.com", attributes: [{ key: "tier", value: "pro" }] })]), + block(120, [{ kind: "providerDeleted", owner: OWNER }]) + ], + ACCOUNT_IDS + ); + + expect(rowsFor(inserts, Providers)).toEqual([ + { + ownerAccountId: 7, + hostUri: "https://new.example.com:8443", + email: "new@example.com", + website: null, + attributes: [{ key: "tier", value: "pro" }], + lastProcessedHeight: 120, + createdHeight: 100, + updatedHeight: 110, + deletedHeight: 120 + } + ]); + + const upsert = upserts.find(entry => entry.table === Providers); + expect(whereSql(upsert?.config.setWhere as SQL)).toContain('excluded.last_processed_height >= "akash"."providers"."last_processed_height"'); + }); + + it("resets the row when a deleted provider re-registers", async () => { + const { writer, tx, inserts } = setup({ + providers: [providerRow({ lastProcessedHeight: 120, updatedHeight: 110, deletedHeight: 120 })] + }); + + await writer.write(tx, [block(200, [created({ hostUri: "https://back.example.com:8443" })])], ACCOUNT_IDS); + + expect(rowsFor(inserts, Providers)).toEqual([ + expect.objectContaining({ + hostUri: "https://back.example.com:8443", + lastProcessedHeight: 200, + createdHeight: 200, + updatedHeight: null, + deletedHeight: null + }) + ]); + }); + + it("skips blocks at or below the stored watermark", async () => { + const { writer, tx, inserts } = setup({ providers: [providerRow({ lastProcessedHeight: 500 })] }); + + await writer.write(tx, [block(400, [created()]), block(500, [{ kind: "providerDeleted", owner: OWNER }])], ACCOUNT_IDS); + + expect(inserts).toEqual([]); + }); + + it("warns on an update for an unknown provider without writing", async () => { + const { writer, tx, logger, inserts } = setup(); + + await writer.write(tx, [block(100, [updated({})])], ACCOUNT_IDS); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ event: "PROVIDER_ORPHAN_REFERENCE", count: 1 })); + expect(inserts).toEqual([]); + }); + + it("upserts signed attributes deduped last-wins with a height guard", async () => { + const { writer, tx, inserts, upserts } = setup(); + + await writer.write( + tx, + [ + block(100, [ + { + kind: "providerAttributesSigned", + owner: OWNER, + auditor: AUDITOR, + attributes: [ + { key: "region", value: "us-east" }, + { key: "region", value: "us-west" }, + { key: "tier", value: "community" } + ] + } + ]) + ], + ACCOUNT_IDS + ); + + expect(rowsFor(inserts, ProviderAuditSignatures)).toEqual([ + { ownerAccountId: 7, auditorAccountId: 8, key: "region", value: "us-west", height: 100 }, + { ownerAccountId: 7, auditorAccountId: 8, key: "tier", value: "community", height: 100 } + ]); + + const upsert = upserts.find(entry => entry.table === ProviderAuditSignatures); + expect(whereSql(upsert?.config.setWhere as SQL)).toContain('excluded.height >= "akash"."provider_audit_signatures"."height"'); + }); + + it("deletes the given keys with a height guard, and all of the auditor's keys when none are given", async () => { + const { writer, tx, deletes } = setup(); + + await writer.write( + tx, + [ + block(100, [ + { kind: "providerAttributesUnsigned", owner: OWNER, auditor: AUDITOR, keys: ["region", "tier"] }, + { kind: "providerAttributesUnsigned", owner: OWNER, auditor: AUDITOR, keys: [] } + ]) + ], + ACCOUNT_IDS + ); + + expect(deletes).toHaveLength(2); + const [keyedDelete, deleteAll] = deletes.map(entry => whereSql(entry.where as SQL)); + expect(keyedDelete).toContain('"owner_account_id" = '); + expect(keyedDelete).toContain('"auditor_account_id" = '); + expect(keyedDelete).toContain('"height" <= '); + expect(keyedDelete).toContain('"key" in '); + expect(deleteAll).toContain('"height" <= '); + expect(deleteAll).not.toContain('"key" in '); + }); + + it("applies audit changes even when the provider was never registered", async () => { + const { writer, tx, inserts, logger } = setup(); + + await writer.write( + tx, + [block(100, [{ kind: "providerAttributesSigned", owner: OWNER, auditor: AUDITOR, attributes: [{ key: "region", value: "us-west" }] }])], + ACCOUNT_IDS + ); + + expect(rowsFor(inserts, ProviderAuditSignatures)).toHaveLength(1); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + function setup(input?: { providers?: Record[] }) { + const inserts: { table: unknown; rows: Record[] }[] = []; + const upserts: { table: unknown; config: Record }[] = []; + const deletes: { table: unknown; where: unknown }[] = []; + const selects: unknown[] = []; + + const selectChain = () => { + const chain = { + where: () => chain, + orderBy: () => chain, + for: () => chain, + then: (resolve: (rows: unknown[]) => unknown, reject?: (error: unknown) => unknown) => Promise.resolve(input?.providers ?? []).then(resolve, reject) + }; + return chain; + }; + + const tx = { + insert: (table: unknown) => ({ + values: (rows: Record | Record[]) => { + const rowArray = Array.isArray(rows) ? rows : [rows]; + inserts.push({ table, rows: rowArray }); + return Object.assign(Promise.resolve(), { + onConflictDoUpdate: (config: Record) => { + upserts.push({ table, config }); + return Promise.resolve(); + } + }); + } + }), + select: (fields?: unknown) => { + selects.push(fields); + return { from: () => selectChain() }; + }, + delete: (table: unknown) => ({ + where: (condition: unknown) => { + deletes.push({ table, where: condition }); + return Promise.resolve(); + } + }) + }; + + const logger = mock(); + return { writer: new ProviderWriter(logger), tx: tx as unknown as ChainTransaction, inserts, upserts, deletes, selects, logger }; + } + + function providerRow(overrides: Record) { + return { + ownerAccountId: 7, + hostUri: "https://provider.example.com:8443", + email: null, + website: null, + attributes: [], + lastProcessedHeight: 100, + createdHeight: 100, + updatedHeight: null, + deletedHeight: null, + ...overrides + }; + } + + function created(overrides?: Partial>): AkashChangeBody { + return { + kind: "providerCreated", + owner: OWNER, + hostUri: "https://provider.example.com:8443", + email: null, + website: null, + attributes: [{ key: "region", value: "us-west" }], + ...overrides + }; + } + + function updated(overrides: Partial>): AkashChangeBody { + return { + kind: "providerUpdated", + owner: OWNER, + hostUri: "https://provider.example.com:8443", + email: null, + website: null, + attributes: [], + ...overrides + }; + } + + function block(height: number, bodies: AkashChangeBody[]): AkashBlockChanges { + return { height, datetime: BLOCK_TIME, changes: bodies.map((body, index) => ({ ...body, txIndex: 0, msgIndex: index })) }; + } + + function rowsFor(inserts: { table: unknown; rows: Record[] }[], table: unknown): Record[] { + return inserts.filter(insert => insert.table === table).flatMap(insert => insert.rows); + } + + function whereSql(where: SQL): string { + return new PgDialect().sqlToQuery(where).sql; + } +}); diff --git a/apps/chain-indexer/src/akash/provider-writer.service.ts b/apps/chain-indexer/src/akash/provider-writer.service.ts new file mode 100644 index 0000000000..3fdaea90b6 --- /dev/null +++ b/apps/chain-indexer/src/akash/provider-writer.service.ts @@ -0,0 +1,262 @@ +import { and, eq, inArray, lte, sql } from "drizzle-orm"; +import chunk from "lodash/chunk"; +import { inject, singleton } from "tsyringe"; + +import type { AkashBlockChanges, ProviderAttribute, ProviderChange } from "@src/akash/akash-changes"; +import { isProviderChange } from "@src/akash/akash-changes"; +import { INSERT_CHUNK_SIZE } from "@src/db/insert-chunk-size"; +import { ProviderAuditSignatures, Providers } from "@src/db/schema"; +import { sqlExcluded } from "@src/db/sql-excluded"; +import type { ChainTransaction } from "@src/providers/db.provider"; +import { LoggerService } from "@src/providers/logging.provider"; + +interface ProviderBlockChanges { + height: number; + changes: ProviderChange[]; +} + +interface ProviderState { + ownerAccountId: number; + hostUri: string; + email: string | null; + website: string | null; + attributes: ProviderAttribute[]; + lastProcessedHeight: number; + createdHeight: number; + updatedHeight: number | null; + deletedHeight: number | null; + touched: boolean; +} + +interface ProviderWarning { + code: "PROVIDER_ORPHAN_REFERENCE"; + kind: ProviderChange["kind"]; + owner: string; + height: number; +} + +/** + * Persists the provider registry and audited attributes inside the block transaction, mirroring the + * fold-then-flush shape of AkashWriter for the aggregate keyed by owner instead of (owner, dseq). + * Provider rows are locked FOR UPDATE in ascending owner-account order and flushed with the + * `last_processed_height` watermark guard, so overlapping writers replaying the same blocks stay + * idempotent. Audit signatures skip the fold: each sign/unsign applies in block order with a per-row + * height guard, since x/audit state is independent of x/provider and audit traffic is sparse. + */ +@singleton() +export class ProviderWriter { + readonly #logger: LoggerService; + + constructor(@inject(LoggerService) logger: LoggerService) { + this.#logger = logger; + this.#logger.setContext("PROVIDER_WRITER"); + } + + async write(tx: ChainTransaction, blocks: AkashBlockChanges[], accountIds: Map): Promise { + const withChanges = blocks + .map(block => ({ height: block.height, changes: block.changes.filter(isProviderChange) })) + .filter(block => block.changes.length > 0); + if (withChanges.length === 0) { + return; + } + + await this.#writeProviders(tx, withChanges, accountIds); + await this.#writeAuditSignatures(tx, withChanges, accountIds); + } + + async #writeProviders(tx: ChainTransaction, blocks: ProviderBlockChanges[], accountIds: Map): Promise { + const ownerIds = this.#collectOwnerIds(blocks, accountIds); + if (ownerIds.size === 0) { + return; + } + + const states = await this.#loadStates( + tx, + [...ownerIds.values()].sort((a, b) => a - b) + ); + const warnings: ProviderWarning[] = []; + + for (const block of blocks) { + this.#applyBlockChanges(states, block, ownerIds, warnings); + } + this.#logWarnings(warnings); + + const touched = [...states.values()].filter(state => state.touched); + if (touched.length === 0) { + return; + } + + await tx + .insert(Providers) + .values(touched.map(({ touched: _, ...row }) => row)) + .onConflictDoUpdate({ + target: Providers.ownerAccountId, + set: { + hostUri: sqlExcluded("host_uri"), + email: sqlExcluded("email"), + website: sqlExcluded("website"), + attributes: sqlExcluded("attributes"), + lastProcessedHeight: sqlExcluded("last_processed_height"), + createdHeight: sqlExcluded("created_height"), + updatedHeight: sqlExcluded("updated_height"), + deletedHeight: sqlExcluded("deleted_height") + }, + setWhere: sql`excluded.last_processed_height >= ${Providers.lastProcessedHeight}` + }); + } + + #applyBlockChanges(states: Map, block: ProviderBlockChanges, ownerIds: Map, warnings: ProviderWarning[]): void { + const skippedOwners = this.#ownersAtOrPastWatermark(states, block.height); + + for (const change of block.changes) { + if (change.kind === "providerAttributesSigned" || change.kind === "providerAttributesUnsigned") { + continue; + } + const ownerAccountId = this.#requireId(ownerIds, change.owner); + if (skippedOwners.has(ownerAccountId)) { + continue; + } + + const state = states.get(ownerAccountId); + if (change.kind === "providerCreated") { + states.set(ownerAccountId, { + ownerAccountId, + hostUri: change.hostUri, + email: change.email, + website: change.website, + attributes: change.attributes, + lastProcessedHeight: block.height, + createdHeight: block.height, + updatedHeight: null, + deletedHeight: null, + touched: true + }); + continue; + } + + if (!state) { + warnings.push({ code: "PROVIDER_ORPHAN_REFERENCE", kind: change.kind, owner: change.owner, height: block.height }); + continue; + } + + if (change.kind === "providerUpdated") { + state.hostUri = change.hostUri; + state.email = change.email; + state.website = change.website; + state.attributes = change.attributes; + state.updatedHeight = block.height; + } else { + state.deletedHeight = block.height; + } + state.lastProcessedHeight = block.height; + state.touched = true; + } + } + + /** Providers already at or past this block's height saw it in a previous commit; the whole block is a duplicate for them. */ + #ownersAtOrPastWatermark(states: Map, height: number): Set { + const skipped = new Set(); + for (const state of states.values()) { + if (state.lastProcessedHeight >= height) { + skipped.add(state.ownerAccountId); + } + } + return skipped; + } + + async #writeAuditSignatures(tx: ChainTransaction, blocks: ProviderBlockChanges[], accountIds: Map): Promise { + for (const block of blocks) { + for (const change of block.changes) { + if (change.kind === "providerAttributesSigned") { + await this.#upsertSignatures(tx, change, block.height, accountIds); + } else if (change.kind === "providerAttributesUnsigned") { + await this.#deleteSignatures(tx, change, block.height, accountIds); + } + } + } + } + + async #upsertSignatures( + tx: ChainTransaction, + change: Extract, + height: number, + accountIds: Map + ): Promise { + const ownerAccountId = this.#requireId(accountIds, change.owner); + const auditorAccountId = this.#requireId(accountIds, change.auditor); + const rows = dedupeByKeyLastWins(change.attributes).map(attribute => ({ + ownerAccountId, + auditorAccountId, + key: attribute.key, + value: attribute.value, + height + })); + + for (const rowChunk of chunk(rows, INSERT_CHUNK_SIZE)) { + await tx + .insert(ProviderAuditSignatures) + .values(rowChunk) + .onConflictDoUpdate({ + target: [ProviderAuditSignatures.ownerAccountId, ProviderAuditSignatures.auditorAccountId, ProviderAuditSignatures.key], + set: { value: sqlExcluded("value"), height: sqlExcluded("height") }, + setWhere: sql`excluded.height >= ${ProviderAuditSignatures.height}` + }); + } + } + + /** The `height <=` guard keeps a replayed delete from removing a signature re-signed at a later height. */ + async #deleteSignatures( + tx: ChainTransaction, + change: Extract, + height: number, + accountIds: Map + ): Promise { + const identity = and( + eq(ProviderAuditSignatures.ownerAccountId, this.#requireId(accountIds, change.owner)), + eq(ProviderAuditSignatures.auditorAccountId, this.#requireId(accountIds, change.auditor)), + lte(ProviderAuditSignatures.height, height) + ); + await tx.delete(ProviderAuditSignatures).where(change.keys.length > 0 ? and(identity, inArray(ProviderAuditSignatures.key, change.keys)) : identity); + } + + #collectOwnerIds(blocks: ProviderBlockChanges[], accountIds: Map): Map { + const ownerIds = new Map(); + for (const block of blocks) { + for (const change of block.changes) { + if (change.kind === "providerCreated" || change.kind === "providerUpdated" || change.kind === "providerDeleted") { + ownerIds.set(change.owner, this.#requireId(accountIds, change.owner)); + } + } + } + return ownerIds; + } + + async #loadStates(tx: ChainTransaction, ownerAccountIds: number[]): Promise> { + const rows = await tx.select().from(Providers).where(inArray(Providers.ownerAccountId, ownerAccountIds)).orderBy(Providers.ownerAccountId).for("update"); + + return new Map(rows.map(row => [row.ownerAccountId, { ...row, touched: false }])); + } + + #logWarnings(warnings: ProviderWarning[]): void { + if (warnings.length === 0) { + return; + } + this.#logger.warn({ event: "PROVIDER_ORPHAN_REFERENCE", count: warnings.length, samples: warnings.slice(0, 5) }); + } + + #requireId(accountIds: Map, address: string): number { + const id = accountIds.get(address); + if (id === undefined) { + throw new Error(`No interned account id for address ${address}`); + } + return id; + } +} + +/** + * A message may sign the same key twice; feeding both rows to one ON CONFLICT DO UPDATE would raise + * `21000: command cannot affect row a second time`, so collapse to the last occurrence. + */ +function dedupeByKeyLastWins(attributes: ProviderAttribute[]): ProviderAttribute[] { + return [...new Map(attributes.map(attribute => [attribute.key, attribute])).values()]; +} diff --git a/apps/chain-indexer/src/akash/resources.ts b/apps/chain-indexer/src/akash/resources.ts index b9f813c882..0ea53cfce3 100644 --- a/apps/chain-indexer/src/akash/resources.ts +++ b/apps/chain-indexer/src/akash/resources.ts @@ -75,7 +75,7 @@ function gpuAttributes(gpu: Record | null): { vendor: string | return { vendor: match[1], model: match[2] !== "*" ? match[2] : null }; } -function attributeList(attributes: unknown): Array<{ key: string; value: string }> { +export function attributeList(attributes: unknown): Array<{ key: string; value: string }> { if (!Array.isArray(attributes)) { return []; } diff --git a/apps/chain-indexer/src/db/schema.spec.ts b/apps/chain-indexer/src/db/schema.spec.ts index ef9abcd1a9..3ddc63b16e 100644 --- a/apps/chain-indexer/src/db/schema.spec.ts +++ b/apps/chain-indexer/src/db/schema.spec.ts @@ -17,6 +17,8 @@ import { ProposalDeposits, Proposals, ProposalVotes, + ProviderAuditSignatures, + Providers, UnbondingDelegations, Validators } from "@src/db/schema"; @@ -184,3 +186,25 @@ describe("akash deployment schema", () => { expect(config.columns.find(column => column.name === "tx_index")?.notNull).toBe(false); }); }); + +describe("akash provider schema", () => { + it("keys providers by their owner account with lifecycle heights and the replay watermark", () => { + const config = getTableConfig(Providers); + + expect(config.name).toBe("providers"); + expect(config.columns.find(column => column.name === "owner_account_id")?.primary).toBe(true); + expect(config.foreignKeys[0].reference().foreignColumns[0].name).toBe("id"); + expect(config.columns.map(column => column.name)).toEqual( + expect.arrayContaining(["host_uri", "email", "website", "attributes", "last_processed_height", "created_height", "updated_height", "deleted_height"]) + ); + }); + + it("keys audit signatures by owner, auditor and key with account foreign keys for both parties", () => { + const config = getTableConfig(ProviderAuditSignatures); + + expect(config.primaryKeys[0].columns.map(column => column.name)).toEqual(["owner_account_id", "auditor_account_id", "key"]); + expect(config.foreignKeys).toHaveLength(2); + config.foreignKeys.forEach(foreignKey => expect(foreignKey.reference().foreignColumns[0].name).toBe("id")); + expect(config.columns.map(column => column.name)).toEqual(expect.arrayContaining(["value", "height"])); + }); +}); diff --git a/apps/chain-indexer/src/db/schema.ts b/apps/chain-indexer/src/db/schema.ts index 2290be387f..5d2a537717 100644 --- a/apps/chain-indexer/src/db/schema.ts +++ b/apps/chain-indexer/src/db/schema.ts @@ -505,3 +505,53 @@ export const DeploymentEvents = akashSchema.table( }, t => [primaryKey({ columns: [t.deploymentId, t.height, t.ordinal] })] ); + +export interface ProviderAttribute { + key: string; + value: string; +} + +/** + * Current on-chain provider state, one row per owner — mirroring `akash query provider list`. + * A provider that deletes and re-registers reuses its row: create resets `created_height` and + * clears `updated_height`/`deleted_height`, so the original registration height is not kept. + * Attributes are the full replace-on-update set from MsgCreate/MsgUpdateProvider. + * `last_processed_height` is the replay watermark (same semantics as deployments). + */ +export const Providers = akashSchema.table("providers", { + ownerAccountId: integer("owner_account_id") + .primaryKey() + .references(() => Accounts.id), + hostUri: text("host_uri").notNull(), + email: text("email"), + website: text("website"), + attributes: jsonb("attributes").$type().notNull(), + lastProcessedHeight: bigint("last_processed_height", { mode: "number" }).notNull(), + createdHeight: bigint("created_height", { mode: "number" }).notNull(), + updatedHeight: bigint("updated_height", { mode: "number" }), + deletedHeight: bigint("deleted_height", { mode: "number" }) +}); + +/** + * Audited provider attributes, one row per (owner, auditor, key) — mirroring the x/audit store. + * Keyed by account rather than the providers row because x/audit never consults x/provider: + * signatures survive provider deletion and can precede registration. MsgSignProviderAttributes + * merges per-key; MsgDeleteProviderAttributes deletes the given keys, or all of the auditor's + * keys when none are given. `height` is the per-row replay guard: signs only apply at or above + * it and deletes only remove rows written at or below the deleting block. + */ +export const ProviderAuditSignatures = akashSchema.table( + "provider_audit_signatures", + { + ownerAccountId: integer("owner_account_id") + .notNull() + .references(() => Accounts.id), + auditorAccountId: integer("auditor_account_id") + .notNull() + .references(() => Accounts.id), + key: text("key").notNull(), + value: text("value").notNull(), + height: bigint("height", { mode: "number" }).notNull() + }, + t => [primaryKey({ columns: [t.ownerAccountId, t.auditorAccountId, t.key] })] +); diff --git a/apps/chain-indexer/src/pipeline/block-committer.service.spec.ts b/apps/chain-indexer/src/pipeline/block-committer.service.spec.ts index b1e32e3c35..eb276ac896 100644 --- a/apps/chain-indexer/src/pipeline/block-committer.service.spec.ts +++ b/apps/chain-indexer/src/pipeline/block-committer.service.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { mock } from "vitest-mock-extended"; import type { AkashWriter } from "@src/akash/akash-writer.service"; +import type { ProviderWriter } from "@src/akash/provider-writer.service"; import { AccountTxs, Blocks, IndexerState, MessageDeadLetters, Messages, MessageTypes } from "@src/db/schema"; import type { GovWriter } from "@src/gov/gov-writer.service"; import type { AccountInterner } from "@src/pipeline/balance/account-interner.service"; @@ -255,6 +256,18 @@ describe(BlockCommitterService.name, () => { const accountIds = akashWriter.write.mock.calls[0][2]; expect(accountIds.get("akash1owner")).toBeDefined(); }); + + it("hands the provider writer the same derived changes and account ids as the akash writer", async () => { + const { committer, akashWriter, providerWriter } = setup({ selectResults: [[{ id: 7, type: MSG_SEND }]] }); + + await committer.commit( + buildBlock([MSG_SEND], 10, { + events: [{ type: "akash.deployment.v1.EventDeploymentClosed", attributes: { id: '{"owner":"akash1owner","dseq":"42"}' } }] + }) + ); + + expect(providerWriter.write).toHaveBeenCalledWith(expect.anything(), akashWriter.write.mock.calls[0][1], akashWriter.write.mock.calls[0][2]); + }); }); function setup(input?: { @@ -307,9 +320,10 @@ describe(BlockCommitterService.name, () => { const govWriter = mock(); const akashWriter = mock(); + const providerWriter = mock(); const logger = mock(); - const committer = new BlockCommitterService(dbFake as unknown as ChainDatabase, interner, balanceWriter, govWriter, akashWriter, logger); - return { committer, insertedRows, conflictUpdates, deletions, interner, balanceWriter, govWriter, akashWriter, logger }; + const committer = new BlockCommitterService(dbFake as unknown as ChainDatabase, interner, balanceWriter, govWriter, akashWriter, providerWriter, logger); + return { committer, insertedRows, conflictUpdates, deletions, interner, balanceWriter, govWriter, akashWriter, providerWriter, logger }; } function buildBlock( diff --git a/apps/chain-indexer/src/pipeline/block-committer.service.ts b/apps/chain-indexer/src/pipeline/block-committer.service.ts index d9568d28a8..a1aec9c2b9 100644 --- a/apps/chain-indexer/src/pipeline/block-committer.service.ts +++ b/apps/chain-indexer/src/pipeline/block-committer.service.ts @@ -6,6 +6,7 @@ import type { AkashBlockChanges } from "@src/akash/akash-changes"; import { collectAkashAddresses } from "@src/akash/akash-changes"; import { deriveAkashChanges } from "@src/akash/akash-deriver"; import { AkashWriter } from "@src/akash/akash-writer.service"; +import { ProviderWriter } from "@src/akash/provider-writer.service"; import { INSERT_CHUNK_SIZE } from "@src/db/insert-chunk-size"; import { insertChunked } from "@src/db/insert-chunked"; import { AccountTxs, Blocks, IndexerState, MessageDeadLetters, Messages, MessageTypes, Transactions } from "@src/db/schema"; @@ -47,6 +48,7 @@ export class BlockCommitterService { readonly #balanceWriter: BalanceWriter; readonly #govWriter: GovWriter; readonly #akashWriter: AkashWriter; + readonly #providerWriter: ProviderWriter; readonly #logger: LoggerService; readonly #moduleRegistry = buildModuleAddressRegistry(); readonly #typeIds = new Map(); @@ -57,6 +59,7 @@ export class BlockCommitterService { @inject(BalanceWriter) balanceWriter: BalanceWriter, @inject(GovWriter) govWriter: GovWriter, @inject(AkashWriter) akashWriter: AkashWriter, + @inject(ProviderWriter) providerWriter: ProviderWriter, @inject(LoggerService) logger: LoggerService ) { this.#db = db; @@ -64,6 +67,7 @@ export class BlockCommitterService { this.#balanceWriter = balanceWriter; this.#govWriter = govWriter; this.#akashWriter = akashWriter; + this.#providerWriter = providerWriter; this.#logger = logger; this.#logger.setContext("COMMITTER"); } @@ -148,6 +152,7 @@ export class BlockCommitterService { await insertChunked(tx, AccountTxs, accountTxRows); await this.#govWriter.writeForBlocks(tx, blocks, accountIds); await this.#akashWriter.write(tx, akashChanges, accountIds); + await this.#providerWriter.write(tx, akashChanges, accountIds); await tx .insert(IndexerState) From 8a8778b4a9a845ee536662223e552e7a273026c2 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:55:20 +0530 Subject: [PATCH 2/3] refactor(indexer): dedupe ProviderAttribute type and tidy provider change guards Code-review cleanups on the provider/audit handlers, no behavior change: - single ProviderAttribute definition in akash-changes, imported by schema/resources - guards isProviderRegistryChange/isProviderAuditChange replace ad-hoc kind cascades - rename normalizeUpsert -> normalizeProviderInfo (names behavior, not the DB op) --- apps/chain-indexer/src/akash/akash-changes.ts | 22 ++++++++++++------- .../src/akash/normalize-provider.ts | 6 ++--- .../src/akash/provider-writer.service.ts | 6 ++--- apps/chain-indexer/src/akash/resources.ts | 4 ++-- apps/chain-indexer/src/db/schema.ts | 6 +---- 5 files changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/chain-indexer/src/akash/akash-changes.ts b/apps/chain-indexer/src/akash/akash-changes.ts index 60af27b64e..c282358de5 100644 --- a/apps/chain-indexer/src/akash/akash-changes.ts +++ b/apps/chain-indexer/src/akash/akash-changes.ts @@ -77,16 +77,22 @@ export type AkashChangeKind = AkashChange["kind"]; export type ProviderChange = ProviderChangeBody & ChangeOrigin; -const PROVIDER_CHANGE_KINDS = new Set([ - "providerCreated", - "providerUpdated", - "providerDeleted", - "providerAttributesSigned", - "providerAttributesUnsigned" -]); +const PROVIDER_REGISTRY_KINDS = new Set(["providerCreated", "providerUpdated", "providerDeleted"]); +const PROVIDER_AUDIT_KINDS = new Set(["providerAttributesSigned", "providerAttributesUnsigned"]); + +export type ProviderRegistryChange = Extract; +export type ProviderAuditChange = Extract; + +export function isProviderRegistryChange(change: AkashChange): change is ProviderRegistryChange { + return PROVIDER_REGISTRY_KINDS.has(change.kind); +} + +export function isProviderAuditChange(change: AkashChange): change is ProviderAuditChange { + return PROVIDER_AUDIT_KINDS.has(change.kind); +} export function isProviderChange(change: AkashChange): change is ProviderChange { - return PROVIDER_CHANGE_KINDS.has(change.kind); + return isProviderRegistryChange(change) || isProviderAuditChange(change); } /** Everything derived from one block, in the exact order the chain applied it (tx order, then message order, then that tx's close events). */ diff --git a/apps/chain-indexer/src/akash/normalize-provider.ts b/apps/chain-indexer/src/akash/normalize-provider.ts index 5ab7272d4c..5a1c157c8b 100644 --- a/apps/chain-indexer/src/akash/normalize-provider.ts +++ b/apps/chain-indexer/src/akash/normalize-provider.ts @@ -16,10 +16,10 @@ function typeUrlSet(name: string): Set { /** All four provider proto eras share the same message shape, so parsing is version-independent. */ export function normalizeProviderMessage(typeUrl: string, body: Record): AkashChangeBody | null { if (CREATE_PROVIDER.has(typeUrl)) { - return normalizeUpsert(body, "providerCreated"); + return normalizeProviderInfo(body, "providerCreated"); } if (UPDATE_PROVIDER.has(typeUrl)) { - return normalizeUpsert(body, "providerUpdated"); + return normalizeProviderInfo(body, "providerUpdated"); } if (DELETE_PROVIDER.has(typeUrl)) { const owner = asString(body.owner); @@ -28,7 +28,7 @@ export function normalizeProviderMessage(typeUrl: string, body: Record, kind: "providerCreated" | "providerUpdated"): AkashChangeBody | null { +function normalizeProviderInfo(body: Record, kind: "providerCreated" | "providerUpdated"): AkashChangeBody | null { const owner = asString(body.owner); const hostUri = asString(body.hostUri); if (!owner || !hostUri) { diff --git a/apps/chain-indexer/src/akash/provider-writer.service.ts b/apps/chain-indexer/src/akash/provider-writer.service.ts index 3fdaea90b6..9fa6ea4301 100644 --- a/apps/chain-indexer/src/akash/provider-writer.service.ts +++ b/apps/chain-indexer/src/akash/provider-writer.service.ts @@ -3,7 +3,7 @@ import chunk from "lodash/chunk"; import { inject, singleton } from "tsyringe"; import type { AkashBlockChanges, ProviderAttribute, ProviderChange } from "@src/akash/akash-changes"; -import { isProviderChange } from "@src/akash/akash-changes"; +import { isProviderAuditChange, isProviderChange, isProviderRegistryChange } from "@src/akash/akash-changes"; import { INSERT_CHUNK_SIZE } from "@src/db/insert-chunk-size"; import { ProviderAuditSignatures, Providers } from "@src/db/schema"; import { sqlExcluded } from "@src/db/sql-excluded"; @@ -109,7 +109,7 @@ export class ProviderWriter { const skippedOwners = this.#ownersAtOrPastWatermark(states, block.height); for (const change of block.changes) { - if (change.kind === "providerAttributesSigned" || change.kind === "providerAttributesUnsigned") { + if (isProviderAuditChange(change)) { continue; } const ownerAccountId = this.#requireId(ownerIds, change.owner); @@ -223,7 +223,7 @@ export class ProviderWriter { const ownerIds = new Map(); for (const block of blocks) { for (const change of block.changes) { - if (change.kind === "providerCreated" || change.kind === "providerUpdated" || change.kind === "providerDeleted") { + if (isProviderRegistryChange(change)) { ownerIds.set(change.owner, this.#requireId(accountIds, change.owner)); } } diff --git a/apps/chain-indexer/src/akash/resources.ts b/apps/chain-indexer/src/akash/resources.ts index 0ea53cfce3..f56954a3cf 100644 --- a/apps/chain-indexer/src/akash/resources.ts +++ b/apps/chain-indexer/src/akash/resources.ts @@ -1,4 +1,4 @@ -import type { NormalizedGroup, NormalizedResource } from "@src/akash/akash-changes"; +import type { NormalizedGroup, NormalizedResource, ProviderAttribute } from "@src/akash/akash-changes"; import { asRecord } from "@src/akash/json"; /** @@ -75,7 +75,7 @@ function gpuAttributes(gpu: Record | null): { vendor: string | return { vendor: match[1], model: match[2] !== "*" ? match[2] : null }; } -export function attributeList(attributes: unknown): Array<{ key: string; value: string }> { +export function attributeList(attributes: unknown): ProviderAttribute[] { if (!Array.isArray(attributes)) { return []; } diff --git a/apps/chain-indexer/src/db/schema.ts b/apps/chain-indexer/src/db/schema.ts index 5d2a537717..99fcb2dd0b 100644 --- a/apps/chain-indexer/src/db/schema.ts +++ b/apps/chain-indexer/src/db/schema.ts @@ -16,6 +16,7 @@ import { uniqueIndex } from "drizzle-orm/pg-core"; +import type { ProviderAttribute } from "@src/akash/akash-changes"; import { bytea } from "@src/db/bytea"; export const cosmosSchema = pgSchema("cosmos"); @@ -506,11 +507,6 @@ export const DeploymentEvents = akashSchema.table( t => [primaryKey({ columns: [t.deploymentId, t.height, t.ordinal] })] ); -export interface ProviderAttribute { - key: string; - value: string; -} - /** * Current on-chain provider state, one row per owner — mirroring `akash query provider list`. * A provider that deletes and re-registers reuses its row: create resets `created_height` and From 9731fdc37698f163f1fa1c2a945815e13c1280d1 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:27:03 +0530 Subject: [PATCH 3/3] fix(indexer): serialize provider audit-signature writes to avoid deadlock Overlapping committers (sync pods during a rolling deploy, or sync racing a backfill) apply audit signs and deletes in block order, so two writers with different block windows can lock the same audit rows in opposite orders and deadlock. The provider path already guards this with an ordered FOR UPDATE, but the audit path did not. Take one transaction-scoped advisory lock over the audit section before writing, reusing the mechanism already used for the migration lock. Audit traffic is sparse, so the lock is near-free in steady state. Also assert the provider state load locks rows FOR UPDATE in owner-account order, which no test previously covered. --- .../src/akash/provider-writer.service.spec.ts | 67 ++++++++++++++++--- .../src/akash/provider-writer.service.ts | 20 +++++- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/chain-indexer/src/akash/provider-writer.service.spec.ts b/apps/chain-indexer/src/akash/provider-writer.service.spec.ts index 5df8b8092f..96d6d70c20 100644 --- a/apps/chain-indexer/src/akash/provider-writer.service.spec.ts +++ b/apps/chain-indexer/src/akash/provider-writer.service.spec.ts @@ -56,7 +56,7 @@ describe(ProviderWriter.name, () => { ]); const upsert = upserts.find(entry => entry.table === Providers); - expect(whereSql(upsert?.config.setWhere as SQL)).toContain('excluded.last_processed_height >= "akash"."providers"."last_processed_height"'); + expect(sqlText(upsert?.config.setWhere as SQL)).toContain('excluded.last_processed_height >= "akash"."providers"."last_processed_height"'); }); it("resets the row when a deleted provider re-registers", async () => { @@ -122,7 +122,7 @@ describe(ProviderWriter.name, () => { ]); const upsert = upserts.find(entry => entry.table === ProviderAuditSignatures); - expect(whereSql(upsert?.config.setWhere as SQL)).toContain('excluded.height >= "akash"."provider_audit_signatures"."height"'); + expect(sqlText(upsert?.config.setWhere as SQL)).toContain('excluded.height >= "akash"."provider_audit_signatures"."height"'); }); it("deletes the given keys with a height guard, and all of the auditor's keys when none are given", async () => { @@ -140,7 +140,7 @@ describe(ProviderWriter.name, () => { ); expect(deletes).toHaveLength(2); - const [keyedDelete, deleteAll] = deletes.map(entry => whereSql(entry.where as SQL)); + const [keyedDelete, deleteAll] = deletes.map(entry => sqlText(entry.where as SQL)); expect(keyedDelete).toContain('"owner_account_id" = '); expect(keyedDelete).toContain('"auditor_account_id" = '); expect(keyedDelete).toContain('"height" <= '); @@ -162,17 +162,62 @@ describe(ProviderWriter.name, () => { expect(logger.warn).not.toHaveBeenCalled(); }); + it("locks provider state rows for update ordered by owner account id", async () => { + const { writer, tx, stateLocks } = setup(); + + await writer.write(tx, [block(100, [created()])], ACCOUNT_IDS); + + expect(stateLocks).toHaveLength(1); + expect(stateLocks[0].orderBy).toHaveLength(1); + expect(stateLocks[0].orderBy[0]).toBe(Providers.ownerAccountId); + expect(stateLocks[0].for).toEqual(["update"]); + }); + + it("serializes audit writes under a single transaction-scoped advisory lock", async () => { + const { writer, tx, executes } = setup(); + + await writer.write( + tx, + [ + block(100, [{ kind: "providerAttributesSigned", owner: OWNER, auditor: AUDITOR, attributes: [{ key: "region", value: "us-west" }] }]), + block(101, [{ kind: "providerAttributesUnsigned", owner: OWNER, auditor: AUDITOR, keys: ["region"] }]) + ], + ACCOUNT_IDS + ); + + expect(executes).toHaveLength(1); + expect(sqlText(executes[0])).toContain("pg_advisory_xact_lock"); + }); + + it("does not take the audit advisory lock when the batch has no audit changes", async () => { + const { writer, tx, executes } = setup(); + + await writer.write(tx, [block(100, [created()])], ACCOUNT_IDS); + + expect(executes).toEqual([]); + }); + function setup(input?: { providers?: Record[] }) { const inserts: { table: unknown; rows: Record[] }[] = []; const upserts: { table: unknown; config: Record }[] = []; const deletes: { table: unknown; where: unknown }[] = []; const selects: unknown[] = []; + const executes: SQL[] = []; + const stateLocks: { orderBy: unknown[]; for: unknown[] }[] = []; const selectChain = () => { + const lock: { orderBy: unknown[]; for: unknown[] } = { orderBy: [], for: [] }; + stateLocks.push(lock); const chain = { where: () => chain, - orderBy: () => chain, - for: () => chain, + orderBy: (...args: unknown[]) => { + lock.orderBy = args; + return chain; + }, + for: (...args: unknown[]) => { + lock.for = args; + return chain; + }, then: (resolve: (rows: unknown[]) => unknown, reject?: (error: unknown) => unknown) => Promise.resolve(input?.providers ?? []).then(resolve, reject) }; return chain; @@ -200,11 +245,15 @@ describe(ProviderWriter.name, () => { deletes.push({ table, where: condition }); return Promise.resolve(); } - }) + }), + execute: (query: SQL) => { + executes.push(query); + return Promise.resolve(); + } }; const logger = mock(); - return { writer: new ProviderWriter(logger), tx: tx as unknown as ChainTransaction, inserts, upserts, deletes, selects, logger }; + return { writer: new ProviderWriter(logger), tx: tx as unknown as ChainTransaction, inserts, upserts, deletes, selects, executes, stateLocks, logger }; } function providerRow(overrides: Record) { @@ -254,7 +303,7 @@ describe(ProviderWriter.name, () => { return inserts.filter(insert => insert.table === table).flatMap(insert => insert.rows); } - function whereSql(where: SQL): string { - return new PgDialect().sqlToQuery(where).sql; + function sqlText(query: SQL): string { + return new PgDialect().sqlToQuery(query).sql; } }); diff --git a/apps/chain-indexer/src/akash/provider-writer.service.ts b/apps/chain-indexer/src/akash/provider-writer.service.ts index 9fa6ea4301..f38198f0e3 100644 --- a/apps/chain-indexer/src/akash/provider-writer.service.ts +++ b/apps/chain-indexer/src/akash/provider-writer.service.ts @@ -10,6 +10,17 @@ import { sqlExcluded } from "@src/db/sql-excluded"; import type { ChainTransaction } from "@src/providers/db.provider"; import { LoggerService } from "@src/providers/logging.provider"; +/** + * Serializes the audit-signature section across concurrent committers (overlapping sync pods during a + * rolling deploy, or sync racing a backfill). Writers apply signs and deletes in block order, so two + * writers with different block windows can lock the same (owner, auditor, key) rows in opposite orders + * and deadlock; the provider path avoids this with an ordered `FOR UPDATE`, but audit's delete-all-keys + * touches rows that cannot be pre-locked by key. One transaction-scoped advisory lock removes the + * hazard, and sparse audit traffic makes it near-free in steady state. Shares Postgres's advisory-lock + * namespace with MIGRATION_LOCK_KEY (db.provider.ts), so the value must stay distinct from it. + */ +const AUDIT_SIGNATURE_LOCK_KEY = 7_431_001; + interface ProviderBlockChanges { height: number; changes: ProviderChange[]; @@ -41,7 +52,9 @@ interface ProviderWarning { * Provider rows are locked FOR UPDATE in ascending owner-account order and flushed with the * `last_processed_height` watermark guard, so overlapping writers replaying the same blocks stay * idempotent. Audit signatures skip the fold: each sign/unsign applies in block order with a per-row - * height guard, since x/audit state is independent of x/provider and audit traffic is sparse. + * height guard, serialized by a transaction-scoped advisory lock so overlapping writers can't lock + * audit rows in conflicting orders. x/audit state is independent of x/provider and audit traffic is + * sparse, so folding it is unnecessary and the lock is near-free. */ @singleton() export class ProviderWriter { @@ -165,6 +178,11 @@ export class ProviderWriter { } async #writeAuditSignatures(tx: ChainTransaction, blocks: ProviderBlockChanges[], accountIds: Map): Promise { + if (!blocks.some(block => block.changes.some(isProviderAuditChange))) { + return; + } + await tx.execute(sql`SELECT pg_advisory_xact_lock(${AUDIT_SIGNATURE_LOCK_KEY})`); + for (const block of blocks) { for (const change of block.changes) { if (change.kind === "providerAttributesSigned") {