From 3d813b7b0966c190cd30abbdcc646b635b5ec403 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:55:23 +0530 Subject: [PATCH 1/4] feat(indexer): akash deployment and market handlers with escrow settlement --- .../drizzle/0006_tired_turbo.sql | 117 + .../drizzle/meta/0006_snapshot.json | 2158 +++++++++++++++++ apps/chain-indexer/drizzle/meta/_journal.json | 7 + apps/chain-indexer/src/akash/akash-changes.ts | 82 + .../src/akash/akash-deriver.spec.ts | 173 ++ apps/chain-indexer/src/akash/akash-deriver.ts | 149 ++ .../src/akash/akash-writer.service.spec.ts | 281 +++ .../src/akash/akash-writer.service.ts | 518 ++++ apps/chain-indexer/src/akash/dec.spec.ts | 97 + apps/chain-indexer/src/akash/dec.ts | 75 + apps/chain-indexer/src/akash/denom.ts | 16 + .../src/akash/deployment-reducer.spec.ts | 286 +++ .../src/akash/deployment-reducer.ts | 575 +++++ apps/chain-indexer/src/akash/json.ts | 17 + .../src/akash/normalize-deployment.spec.ts | 96 + .../src/akash/normalize-deployment.ts | 130 + .../src/akash/normalize-market.spec.ts | 55 + .../src/akash/normalize-market.ts | 71 + .../chain-indexer/src/akash/resources.spec.ts | 131 + apps/chain-indexer/src/akash/resources.ts | 96 + .../src/akash/settlement.spec.ts | 112 + apps/chain-indexer/src/akash/settlement.ts | 78 + apps/chain-indexer/src/akash/uint64.spec.ts | 26 + apps/chain-indexer/src/akash/uint64.ts | 22 + apps/chain-indexer/src/db/schema.spec.ts | 64 + apps/chain-indexer/src/db/schema.ts | 197 ++ .../pipeline/block-committer.service.spec.ts | 24 +- .../src/pipeline/block-committer.service.ts | 26 +- .../pipeline/block-decoder.service.spec.ts | 54 + .../src/pipeline/block-decoder.service.ts | 46 +- 30 files changed, 5768 insertions(+), 11 deletions(-) create mode 100644 apps/chain-indexer/drizzle/0006_tired_turbo.sql create mode 100644 apps/chain-indexer/drizzle/meta/0006_snapshot.json create mode 100644 apps/chain-indexer/src/akash/akash-changes.ts create mode 100644 apps/chain-indexer/src/akash/akash-deriver.spec.ts create mode 100644 apps/chain-indexer/src/akash/akash-deriver.ts create mode 100644 apps/chain-indexer/src/akash/akash-writer.service.spec.ts create mode 100644 apps/chain-indexer/src/akash/akash-writer.service.ts create mode 100644 apps/chain-indexer/src/akash/dec.spec.ts create mode 100644 apps/chain-indexer/src/akash/dec.ts create mode 100644 apps/chain-indexer/src/akash/denom.ts create mode 100644 apps/chain-indexer/src/akash/deployment-reducer.spec.ts create mode 100644 apps/chain-indexer/src/akash/deployment-reducer.ts create mode 100644 apps/chain-indexer/src/akash/json.ts create mode 100644 apps/chain-indexer/src/akash/normalize-deployment.spec.ts create mode 100644 apps/chain-indexer/src/akash/normalize-deployment.ts create mode 100644 apps/chain-indexer/src/akash/normalize-market.spec.ts create mode 100644 apps/chain-indexer/src/akash/normalize-market.ts create mode 100644 apps/chain-indexer/src/akash/resources.spec.ts create mode 100644 apps/chain-indexer/src/akash/resources.ts create mode 100644 apps/chain-indexer/src/akash/settlement.spec.ts create mode 100644 apps/chain-indexer/src/akash/settlement.ts create mode 100644 apps/chain-indexer/src/akash/uint64.spec.ts create mode 100644 apps/chain-indexer/src/akash/uint64.ts diff --git a/apps/chain-indexer/drizzle/0006_tired_turbo.sql b/apps/chain-indexer/drizzle/0006_tired_turbo.sql new file mode 100644 index 0000000000..4d2b14d699 --- /dev/null +++ b/apps/chain-indexer/drizzle/0006_tired_turbo.sql @@ -0,0 +1,117 @@ +CREATE SCHEMA "akash"; +--> statement-breakpoint +CREATE TYPE "akash"."bid_state" AS ENUM('open', 'active', 'closed');--> statement-breakpoint +CREATE TYPE "akash"."deployment_close_reason" AS ENUM('close_message', 'overdrawn', 'close_event');--> statement-breakpoint +CREATE TYPE "akash"."deployment_event_type" AS ENUM('created', 'deposited', 'updated', 'closed', 'group_closed', 'group_paused', 'group_started', 'bid_created', 'bid_closed', 'lease_created', 'lease_closed', 'lease_withdrawn');--> statement-breakpoint +CREATE TYPE "akash"."group_state" AS ENUM('open', 'paused', 'closed');--> statement-breakpoint +CREATE TABLE "akash"."bids" ( + "deployment_id" integer NOT NULL, + "gseq" integer NOT NULL, + "oseq" integer NOT NULL, + "bseq" integer DEFAULT 0 NOT NULL, + "provider_account_id" integer NOT NULL, + "price" numeric(38, 18) NOT NULL, + "denom" text NOT NULL, + "state" "akash"."bid_state" DEFAULT 'open' NOT NULL, + "created_height" bigint NOT NULL, + "closed_height" bigint, + CONSTRAINT "bids_deployment_id_gseq_oseq_bseq_provider_account_id_pk" PRIMARY KEY("deployment_id","gseq","oseq","bseq","provider_account_id") +); +--> statement-breakpoint +CREATE TABLE "akash"."deployment_events" ( + "deployment_id" integer NOT NULL, + "height" bigint NOT NULL, + "ordinal" integer NOT NULL, + "tx_index" integer, + "msg_index" integer, + "type" "akash"."deployment_event_type" NOT NULL, + "details" jsonb, + CONSTRAINT "deployment_events_deployment_id_height_ordinal_pk" PRIMARY KEY("deployment_id","height","ordinal") +); +--> statement-breakpoint +CREATE TABLE "akash"."deployment_group_resources" ( + "deployment_group_id" integer NOT NULL, + "idx" integer NOT NULL, + "count" integer NOT NULL, + "cpu_units" bigint NOT NULL, + "gpu_units" bigint NOT NULL, + "gpu_vendor" text, + "gpu_model" text, + "memory_bytes" bigint NOT NULL, + "ephemeral_storage_bytes" bigint NOT NULL, + "persistent_storage_bytes" bigint NOT NULL, + "price" numeric(38, 18) NOT NULL, + "price_denom" text NOT NULL, + CONSTRAINT "deployment_group_resources_deployment_group_id_idx_pk" PRIMARY KEY("deployment_group_id","idx") +); +--> statement-breakpoint +CREATE TABLE "akash"."deployment_groups" ( + "id" serial PRIMARY KEY NOT NULL, + "deployment_id" integer NOT NULL, + "gseq" integer NOT NULL, + "state" "akash"."group_state" DEFAULT 'open' NOT NULL, + "closed_height" bigint +); +--> statement-breakpoint +CREATE TABLE "akash"."deployments" ( + "id" serial PRIMARY KEY NOT NULL, + "owner_account_id" integer NOT NULL, + "dseq" numeric(20, 0) NOT NULL, + "denom" text NOT NULL, + "deposit" numeric(38, 0) NOT NULL, + "balance" numeric(38, 18) NOT NULL, + "withdrawn_amount" numeric(38, 18) NOT NULL, + "block_rate" numeric(38, 18) DEFAULT '0' NOT NULL, + "last_withdraw_height" bigint, + "last_processed_height" bigint NOT NULL, + "created_height" bigint NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "closed_height" bigint, + "closed_at" timestamp with time zone, + "close_reason" "akash"."deployment_close_reason", + "cpu_units" bigint NOT NULL, + "gpu_units" bigint NOT NULL, + "memory_bytes" bigint NOT NULL, + "ephemeral_storage_bytes" bigint NOT NULL, + "persistent_storage_bytes" bigint NOT NULL +); +--> statement-breakpoint +CREATE TABLE "akash"."leases" ( + "deployment_id" integer NOT NULL, + "deployment_group_id" integer NOT NULL, + "gseq" integer NOT NULL, + "oseq" integer NOT NULL, + "bseq" integer DEFAULT 0 NOT NULL, + "provider_account_id" integer NOT NULL, + "price" numeric(38, 18) NOT NULL, + "denom" text NOT NULL, + "balance" numeric(38, 18) DEFAULT '0' NOT NULL, + "withdrawn_amount" numeric(38, 18) DEFAULT '0' NOT NULL, + "predicted_closed_height" numeric(30, 0) NOT NULL, + "created_height" bigint NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "closed_height" bigint, + "closed_at" timestamp with time zone, + "cpu_units" bigint NOT NULL, + "gpu_units" bigint NOT NULL, + "memory_bytes" bigint NOT NULL, + "ephemeral_storage_bytes" bigint NOT NULL, + "persistent_storage_bytes" bigint NOT NULL, + CONSTRAINT "leases_deployment_id_gseq_oseq_bseq_provider_account_id_pk" PRIMARY KEY("deployment_id","gseq","oseq","bseq","provider_account_id") +); +--> statement-breakpoint +ALTER TABLE "akash"."bids" ADD CONSTRAINT "bids_deployment_id_deployments_id_fk" FOREIGN KEY ("deployment_id") REFERENCES "akash"."deployments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "akash"."bids" ADD CONSTRAINT "bids_provider_account_id_accounts_id_fk" FOREIGN KEY ("provider_account_id") REFERENCES "cosmos"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "akash"."deployment_events" ADD CONSTRAINT "deployment_events_deployment_id_deployments_id_fk" FOREIGN KEY ("deployment_id") REFERENCES "akash"."deployments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "akash"."deployment_group_resources" ADD CONSTRAINT "deployment_group_resources_deployment_group_id_deployment_groups_id_fk" FOREIGN KEY ("deployment_group_id") REFERENCES "akash"."deployment_groups"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "akash"."deployment_groups" ADD CONSTRAINT "deployment_groups_deployment_id_deployments_id_fk" FOREIGN KEY ("deployment_id") REFERENCES "akash"."deployments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "akash"."deployments" ADD CONSTRAINT "deployments_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"."leases" ADD CONSTRAINT "leases_deployment_id_deployments_id_fk" FOREIGN KEY ("deployment_id") REFERENCES "akash"."deployments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "akash"."leases" ADD CONSTRAINT "leases_deployment_group_id_deployment_groups_id_fk" FOREIGN KEY ("deployment_group_id") REFERENCES "akash"."deployment_groups"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "akash"."leases" ADD CONSTRAINT "leases_provider_account_id_accounts_id_fk" FOREIGN KEY ("provider_account_id") REFERENCES "cosmos"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "deployment_groups_deployment_gseq_idx" ON "akash"."deployment_groups" USING btree ("deployment_id","gseq");--> statement-breakpoint +CREATE UNIQUE INDEX "deployments_owner_dseq_idx" ON "akash"."deployments" USING btree ("owner_account_id","dseq");--> statement-breakpoint +CREATE INDEX "deployments_owner_created_idx" ON "akash"."deployments" USING btree ("owner_account_id","created_height");--> statement-breakpoint +CREATE INDEX "deployments_open_idx" ON "akash"."deployments" USING btree ("created_height") WHERE "akash"."deployments"."closed_height" IS NULL;--> statement-breakpoint +CREATE INDEX "leases_provider_idx" ON "akash"."leases" USING btree ("provider_account_id","closed_height","created_height");--> statement-breakpoint +CREATE INDEX "leases_open_idx" ON "akash"."leases" USING btree ("deployment_id") WHERE "akash"."leases"."closed_height" IS NULL; \ No newline at end of file diff --git a/apps/chain-indexer/drizzle/meta/0006_snapshot.json b/apps/chain-indexer/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000000..eb3295617f --- /dev/null +++ b/apps/chain-indexer/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2158 @@ +{ + "id": "c46fb3b3-a7c9-40e6-aee5-060eb257da81", + "prevId": "4cf348d1-418b-4014-bbd3-4ffed0fa4e97", + "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": "integer", + "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": "integer", + "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": "integer", + "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": "serial", + "primaryKey": true, + "notNull": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "integer", + "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": "serial", + "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": "integer", + "primaryKey": false, + "notNull": true + }, + "deployment_group_id": { + "name": "deployment_group_id", + "type": "integer", + "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 + }, + "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 7636c3e164..477b602fc4 100644 --- a/apps/chain-indexer/drizzle/meta/_journal.json +++ b/apps/chain-indexer/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1786691068837, "tag": "0005_mysterious_medusa", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1786866393419, + "tag": "0006_tired_turbo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/chain-indexer/src/akash/akash-changes.ts b/apps/chain-indexer/src/akash/akash-changes.ts new file mode 100644 index 0000000000..3730f9c802 --- /dev/null +++ b/apps/chain-indexer/src/akash/akash-changes.ts @@ -0,0 +1,82 @@ +export interface DeploymentKey { + owner: string; + dseq: string; +} + +export interface LeaseKey extends DeploymentKey { + gseq: number; + oseq: number; + bseq: number; + provider: string; +} + +export interface NormalizedResource { + count: number; + cpuUnits: number; + gpuUnits: number; + gpuVendor: string | null; + gpuModel: string | null; + memoryBytes: number; + ephemeralStorageBytes: number; + persistentStorageBytes: number; + price: string; + priceDenom: string; +} + +export interface NormalizedGroup { + gseq: number; + resources: NormalizedResource[]; +} + +interface ChangeOrigin { + txIndex: number | null; + msgIndex: number | null; +} + +export type AkashChangeBody = + | { 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 } + | { kind: "deploymentClosed"; key: DeploymentKey } + | { kind: "groupClosed"; key: DeploymentKey; gseq: number } + | { kind: "groupPaused"; key: DeploymentKey; gseq: number } + | { kind: "groupStarted"; key: DeploymentKey; gseq: number } + | { kind: "bidCreated"; key: LeaseKey; price: string; priceDenom: string } + | { kind: "bidClosed"; key: LeaseKey } + | { kind: "leaseCreated"; key: LeaseKey } + | { kind: "leaseClosed"; key: LeaseKey } + | { kind: "leaseWithdrawn"; key: LeaseKey } + | { kind: "deploymentClosedEvent"; key: DeploymentKey } + | { kind: "leaseClosedEvent"; key: DeploymentKey; gseq: number; oseq: number; bseq: number | null; provider: string }; + +export type AkashChange = AkashChangeBody & ChangeOrigin; + +export type AkashChangeKind = AkashChange["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; + datetime: Date; + changes: AkashChange[]; +} + +/** Every address the batch's akash changes reference, for the committer's account interning. */ +export function collectAkashAddresses(blocks: AkashBlockChanges[]): Set { + const addresses = new Set(); + + for (const block of blocks) { + for (const change of block.changes) { + addresses.add(change.key.owner); + if ("provider" in change) { + addresses.add(change.provider); + } else if ("bseq" in change.key) { + addresses.add(change.key.provider); + } + if ("depositor" in change && change.depositor) { + addresses.add(change.depositor); + } + } + } + + return addresses; +} diff --git a/apps/chain-indexer/src/akash/akash-deriver.spec.ts b/apps/chain-indexer/src/akash/akash-deriver.spec.ts new file mode 100644 index 0000000000..b29628df0e --- /dev/null +++ b/apps/chain-indexer/src/akash/akash-deriver.spec.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; + +import { deriveAkashChanges } from "@src/akash/akash-deriver"; +import type { DecodedBlock, DecodedEvent } from "@src/pipeline/decoded-block"; + +const BLOCK_TIME = new Date("2026-08-13T00:00:00Z"); + +describe("deriveAkashChanges", () => { + it("derives message changes in transaction and message order with their coordinates", () => { + const changes = deriveAkashChanges( + block({ + messages: [ + { + typeUrl: "/akash.deployment.v1beta4.MsgCreateDeployment", + body: { id: { owner: "akash1owner", dseq: "1" }, groups: [], deposit: { amount: { denom: "uakt", amount: "500" } } } + }, + { + typeUrl: "/akash.market.v1beta5.MsgCreateBid", + body: { id: { owner: "akash1owner", dseq: "1", gseq: 1, oseq: 1, bseq: 0, provider: "akash1prov" }, price: { denom: "uakt", amount: "2.5" } } + } + ] + }) + ); + + expect(changes.height).toBe(100); + expect(changes.datetime).toBe(BLOCK_TIME); + expect(changes.changes.map(change => [change.kind, change.txIndex, change.msgIndex])).toEqual([ + ["deploymentCreated", 0, 0], + ["bidCreated", 0, 1] + ]); + }); + + it("skips messages in failed transactions", () => { + const changes = deriveAkashChanges( + block({ + code: 5, + messages: [{ typeUrl: "/akash.deployment.v1beta4.MsgCloseDeployment", body: { id: { owner: "akash1owner", dseq: "1" } } }] + }) + ); + + expect(changes.changes).toEqual([]); + }); + + it("unwraps authz MsgExec through the decoder-provided decoded field, recursively", () => { + const deposit = { + typeUrl: "/akash.escrow.v1.MsgAccountDeposit", + decoded: { signer: "akash1grantee", id: { scope: 1, xid: "akash1owner/7" }, deposit: { amount: { denom: "uakt", amount: "42" } } } + }; + const changes = deriveAkashChanges( + block({ + messages: [ + { + typeUrl: "/cosmos.authz.v1beta1.MsgExec", + body: { grantee: "akash1grantee", msgs: [{ typeUrl: "/cosmos.authz.v1beta1.MsgExec", decoded: { msgs: [deposit] } }] } + } + ] + }) + ); + + expect(changes.changes).toEqual([ + { + kind: "deploymentDeposited", + key: { owner: "akash1owner", dseq: "7" }, + amount: "42", + depositor: "akash1grantee", + txIndex: 0, + msgIndex: 0 + } + ]); + }); + + it("skips exec inner messages the decoder could not decode", () => { + const changes = deriveAkashChanges( + block({ + messages: [ + { + typeUrl: "/cosmos.authz.v1beta1.MsgExec", + body: { msgs: [{ typeUrl: "/akash.escrow.v1.MsgAccountDeposit", value: "AA==", decoded: null }] } + } + ] + }) + ); + + expect(changes.changes).toEqual([]); + }); + + it("derives legacy akash.v1 string close events after the transaction's messages", () => { + const changes = deriveAkashChanges( + block({ + messages: [{ typeUrl: "/akash.deployment.v1beta1.MsgCloseGroup", body: { id: { owner: "akash1owner", dseq: "3", gseq: 1 } } }], + txEvents: [ + event("akash.v1", { action: "lease-closed", owner: "akash1owner", dseq: "3", gseq: "1", oseq: "1", provider: "akash1prov" }), + event("akash.v1", { action: "deployment-closed", owner: "akash1owner", dseq: "3" }) + ] + }) + ); + + expect(changes.changes.map(change => change.kind)).toEqual(["groupClosed", "leaseClosedEvent", "deploymentClosedEvent"]); + expect(changes.changes[1]).toMatchObject({ key: { owner: "akash1owner", dseq: "3" }, gseq: 1, oseq: 1, bseq: null, provider: "akash1prov" }); + }); + + it("derives typed close events from their JSON id attribute", () => { + const changes = deriveAkashChanges( + block({ + txEvents: [ + event( + "akash.market.v1.EventLeaseClosed", + { id: JSON.stringify({ owner: "akash1owner", dseq: "3", gseq: 1, oseq: 1, bseq: 2, provider: "akash1prov" }) }, + 0 + ), + event("akash.deployment.v1.EventDeploymentClosed", { id: JSON.stringify({ owner: "akash1owner", dseq: "3" }) }) + ] + }) + ); + + expect(changes.changes).toEqual([ + { kind: "leaseClosedEvent", key: { owner: "akash1owner", dseq: "3" }, gseq: 1, oseq: 1, bseq: 2, provider: "akash1prov", txIndex: 0, msgIndex: 0 }, + { kind: "deploymentClosedEvent", key: { owner: "akash1owner", dseq: "3" }, txIndex: 0, msgIndex: null } + ]); + }); + + it("ignores malformed close events and unrelated event types", () => { + const changes = deriveAkashChanges( + block({ + txEvents: [ + event("akash.v1", { action: "deployment-closed" }), + event("akash.deployment.v1.EventDeploymentClosed", { id: "not-json" }), + event("transfer", { amount: "1uakt" }) + ] + }) + ); + + expect(changes.changes).toEqual([]); + }); + + function block(input: { + height?: number; + code?: number; + messages?: { typeUrl: string; body: unknown }[]; + txEvents?: DecodedEvent[]; + blockEvents?: DecodedEvent[]; + }): DecodedBlock { + const messages = input.messages ?? []; + return { + height: input.height ?? 100, + datetime: BLOCK_TIME, + hash: Buffer.alloc(0), + parentHash: null, + proposerAddress: "P", + transactions: + messages.length > 0 || input.txEvents + ? [ + { + index: 0, + hash: Buffer.alloc(0), + code: input.code ?? 0, + gasUsed: 0, + gasWanted: 0, + fee: [], + messages: messages.map((message, index) => ({ index, typeUrl: message.typeUrl, body: message.body })), + events: input.txEvents ?? [], + signerAddresses: [] + } + ] + : [], + blockEvents: input.blockEvents ?? [] + }; + } + + function event(type: string, attributes: Record, msgIndex?: number): DecodedEvent { + return msgIndex === undefined ? { type, attributes } : { type, attributes, msgIndex }; + } +}); diff --git a/apps/chain-indexer/src/akash/akash-deriver.ts b/apps/chain-indexer/src/akash/akash-deriver.ts new file mode 100644 index 0000000000..9a8526d3f0 --- /dev/null +++ b/apps/chain-indexer/src/akash/akash-deriver.ts @@ -0,0 +1,149 @@ +import type { AkashBlockChanges, AkashChange, AkashChangeBody } from "@src/akash/akash-changes"; +import { asInteger, asRecord, asString } from "@src/akash/json"; +import { isDeploymentTypeUrl, normalizeDeploymentMessage } from "@src/akash/normalize-deployment"; +import { isMarketTypeUrl, normalizeMarketMessage } from "@src/akash/normalize-market"; +import { asUint64String } from "@src/akash/uint64"; +import type { DecodedBlock, DecodedEvent } from "@src/pipeline/decoded-block"; + +const MSG_EXEC_TYPE_URL = "/cosmos.authz.v1beta1.MsgExec"; + +const MAX_EXEC_DEPTH = 2; + +const LEGACY_EVENT_TYPE = "akash.v1"; +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 + * 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 + * in failed transactions are skipped, since cosmos rolls back their state changes and no close event + * is emitted for them. + */ +export function deriveAkashChanges(block: DecodedBlock): AkashBlockChanges { + const changes: AkashChange[] = []; + + for (const tx of block.transactions) { + if (tx.code !== 0) { + continue; + } + for (const message of tx.messages) { + addMessage(changes, message.typeUrl, message.body, tx.index, message.index, 0); + } + addCloseEvents(changes, tx.events, tx.index); + } + + addCloseEvents(changes, block.blockEvents, null); + + return { height: block.height, datetime: block.datetime, changes }; +} + +function addMessage(changes: AkashChange[], typeUrl: string, body: unknown, txIndex: number, msgIndex: number, depth: number): void { + if (typeUrl === MSG_EXEC_TYPE_URL && depth < MAX_EXEC_DEPTH) { + const msgs = asRecord(body)?.msgs; + if (Array.isArray(msgs)) { + for (const inner of msgs) { + const innerRecord = asRecord(inner); + const innerTypeUrl = asString(innerRecord?.typeUrl); + if (innerTypeUrl && innerRecord?.decoded) { + addMessage(changes, innerTypeUrl, innerRecord.decoded, txIndex, msgIndex, depth + 1); + } + } + } + return; + } + + const record = asRecord(body); + if (!record) { + return; + } + + const normalized = isDeploymentTypeUrl(typeUrl) + ? normalizeDeploymentMessage(typeUrl, record) + : isMarketTypeUrl(typeUrl) + ? normalizeMarketMessage(typeUrl, record) + : null; + + if (normalized) { + changes.push({ ...normalized, txIndex, msgIndex }); + } +} + +function addCloseEvents(changes: AkashChange[], events: DecodedEvent[], txIndex: number | null): void { + for (const event of events) { + const change = closeEventChange(event); + if (change) { + changes.push({ ...change, txIndex, msgIndex: event.msgIndex ?? null }); + } + } +} + +function closeEventChange(event: DecodedEvent): AkashChangeBody | null { + if (event.type === LEGACY_EVENT_TYPE) { + if (event.attributes.action === "deployment-closed") { + return legacyDeploymentClosed(event.attributes); + } + if (event.attributes.action === "lease-closed") { + return legacyLeaseClosed(event.attributes); + } + return null; + } + if (event.type === DEPLOYMENT_CLOSED_EVENT_TYPE) { + return typedDeploymentClosed(event.attributes); + } + if (event.type === LEASE_CLOSED_EVENT_TYPE) { + return typedLeaseClosed(event.attributes); + } + return null; +} + +function legacyDeploymentClosed(attributes: Record): AkashChangeBody | null { + const owner = asString(attributes.owner); + const dseq = asUint64String(attributes.dseq); + return owner && dseq ? { kind: "deploymentClosedEvent", key: { owner, dseq } } : null; +} + +function legacyLeaseClosed(attributes: Record): AkashChangeBody | null { + const owner = asString(attributes.owner); + const dseq = asUint64String(attributes.dseq); + const gseq = asInteger(attributes.gseq); + const oseq = asInteger(attributes.oseq); + const provider = asString(attributes.provider); + if (!owner || !dseq || gseq === null || oseq === null || !provider) { + return null; + } + return { kind: "leaseClosedEvent", key: { owner, dseq }, gseq, oseq, bseq: null, provider }; +} + +function typedDeploymentClosed(attributes: Record): AkashChangeBody | null { + const id = parseIdAttribute(attributes.id); + const owner = asString(id?.owner); + const dseq = asUint64String(id?.dseq); + return owner && dseq ? { kind: "deploymentClosedEvent", key: { owner, dseq } } : null; +} + +function typedLeaseClosed(attributes: Record): AkashChangeBody | null { + const id = parseIdAttribute(attributes.id); + const owner = asString(id?.owner); + const dseq = asUint64String(id?.dseq); + const gseq = asInteger(id?.gseq); + const oseq = asInteger(id?.oseq); + const provider = asString(id?.provider); + if (!owner || !dseq || gseq === null || oseq === null || !provider) { + return null; + } + return { kind: "leaseClosedEvent", key: { owner, dseq }, gseq, oseq, bseq: asInteger(id?.bseq), provider }; +} + +/** The typed events carry their id as a JSON string attribute. */ +function parseIdAttribute(raw: string | undefined): Record | null { + if (!raw) { + return null; + } + try { + return asRecord(JSON.parse(raw)); + } catch { + return null; + } +} diff --git a/apps/chain-indexer/src/akash/akash-writer.service.spec.ts b/apps/chain-indexer/src/akash/akash-writer.service.spec.ts new file mode 100644 index 0000000000..fbdc26d3a7 --- /dev/null +++ b/apps/chain-indexer/src/akash/akash-writer.service.spec.ts @@ -0,0 +1,281 @@ +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 { AkashWriter } from "@src/akash/akash-writer.service"; +import { Bids, DeploymentEvents, DeploymentGroupResources, DeploymentGroups, Deployments, Leases } from "@src/db/schema"; +import type { ChainTransaction } from "@src/providers/db.provider"; +import type { LoggerService } from "@src/providers/logging.provider"; + +const OWNER = "akash1owner"; +const PROVIDER = "akash1prov"; +const KEY = { owner: OWNER, dseq: "42" }; +const LEASE_KEY = { ...KEY, gseq: 1, oseq: 1, bseq: 0, provider: PROVIDER }; +const BLOCK_TIME = new Date("2026-08-13T00:00:00Z"); +const ACCOUNT_IDS = new Map([ + [OWNER, 7], + [PROVIDER, 8] +]); + +describe(AkashWriter.name, () => { + it("does nothing for blocks without akash changes", async () => { + const { writer, tx, inserts, selects } = setup(); + + await writer.write(tx, [block(100, [])], ACCOUNT_IDS); + + expect(inserts).toEqual([]); + expect(selects).toEqual([]); + }); + + it("persists a full lifecycle batch as one consistent set of rows", async () => { + const { writer, tx, inserts, upserts } = setup(); + + await writer.write( + tx, + [block(100, [create(), bidCreated("10")]), block(110, [{ kind: "leaseCreated", key: LEASE_KEY }]), block(200, [{ kind: "deploymentClosed", key: KEY }])], + ACCOUNT_IDS + ); + + const [deploymentRow] = rowsFor(inserts, Deployments); + expect(deploymentRow).toMatchObject({ + ownerAccountId: 7, + dseq: "42", + denom: "uakt", + deposit: "5000000", + balance: "4999100", + withdrawnAmount: "900", + blockRate: "0", + lastWithdrawHeight: 200, + lastProcessedHeight: 200, + createdHeight: 100, + closedHeight: 200, + closeReason: "close_message", + cpuUnits: 2000 + }); + + expect(rowsFor(inserts, DeploymentGroups)).toEqual([{ deploymentId: 1, gseq: 1, state: "open", closedHeight: null }]); + expect(rowsFor(inserts, DeploymentGroupResources)).toEqual([ + expect.objectContaining({ deploymentGroupId: 2, idx: 0, count: 2, cpuUnits: 1000, price: "1" }) + ]); + expect(rowsFor(inserts, Bids)).toEqual([ + expect.objectContaining({ deploymentId: 1, providerAccountId: 8, price: "10", state: "closed", closedHeight: 200 }) + ]); + expect(rowsFor(inserts, Leases)).toEqual([ + expect.objectContaining({ + deploymentId: 1, + deploymentGroupId: 2, + providerAccountId: 8, + price: "10", + withdrawnAmount: "900", + createdHeight: 110, + closedHeight: 200, + cpuUnits: 2000 + }) + ]); + expect(rowsFor(inserts, DeploymentEvents).map(row => [row.type, row.height, row.ordinal])).toEqual([ + ["created", 100, 0], + ["bid_created", 100, 1], + ["lease_created", 110, 0], + ["closed", 200, 0] + ]); + + const deploymentUpsert = upserts.find(upsert => upsert.table === Deployments); + expect(whereSql(deploymentUpsert?.config.setWhere as SQL)).toContain('excluded.last_processed_height >= "akash"."deployments"."last_processed_height"'); + }); + + it("skips flushing entirely when every block is at or below the stored watermark", async () => { + const { writer, tx, inserts } = setup({ + deployments: [deploymentRow({ lastProcessedHeight: 500 })] + }); + + await writer.write(tx, [block(400, [{ kind: "deploymentDeposited", key: KEY, amount: "10", depositor: null }])], ACCOUNT_IDS); + + expect(inserts).toEqual([]); + }); + + it("logs orphan references without aborting the batch", async () => { + const { writer, tx, logger, inserts } = setup(); + + await writer.write(tx, [block(100, [{ kind: "leaseWithdrawn", key: LEASE_KEY }])], ACCOUNT_IDS); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ event: "AKASH_ORPHAN_REFERENCE", count: 1 })); + expect(inserts).toEqual([]); + }); + + it("applies new blocks on top of loaded state", async () => { + const { writer, tx, inserts } = setup({ + deployments: [deploymentRow({ lastProcessedHeight: 110, lastWithdrawHeight: 110, balance: "1000.000000000000000000" })], + leases: [leaseRow()] + }); + + await writer.write(tx, [block(150, [{ kind: "leaseWithdrawn", key: LEASE_KEY }])], ACCOUNT_IDS); + + const [row] = rowsFor(inserts, Deployments); + expect(row).toMatchObject({ balance: "600", withdrawnAmount: "400", lastWithdrawHeight: 150, lastProcessedHeight: 150 }); + const [lease] = rowsFor(inserts, Leases); + expect(lease).toMatchObject({ withdrawnAmount: "400" }); + }); + + function setup(input?: { + deployments?: Record[]; + groups?: Record[]; + bids?: Record[]; + leases?: Record[]; + }) { + const inserts: { table: unknown; rows: Record[] }[] = []; + const upserts: { table: unknown; config: Record }[] = []; + const selects: unknown[] = []; + let nextId = 1; + + const deployments = input?.deployments ?? []; + const rowsByTable = new Map[]>([ + [Deployments, deployments], + [DeploymentGroups, input?.groups ?? (deployments.length > 0 ? [{ id: 2, deploymentId: 1, gseq: 1, state: "open", closedHeight: null }] : [])], + [Bids, input?.bids ?? []], + [Leases, input?.leases ?? []] + ]); + + const selectChain = (table: unknown) => { + const rows = rowsByTable.get(table) ?? providerAccountRows(table); + const chain = { + where: () => chain, + orderBy: () => chain, + innerJoin: () => chain, + for: () => chain, + then: (resolve: (rows: unknown[]) => unknown, reject?: (error: unknown) => unknown) => Promise.resolve(rows).then(resolve, reject) + }; + return chain; + }; + + const providerAccountRows = (table: unknown) => { + void table; + return [ + { id: 7, address: OWNER }, + { id: 8, address: PROVIDER } + ]; + }; + + const tx = { + insert: (table: unknown) => ({ + values: (rows: Record | Record[]) => { + const rowArray = Array.isArray(rows) ? rows : [rows]; + inserts.push({ table, rows: rowArray }); + const returning = () => Promise.resolve(rowArray.map(row => ({ id: nextId++, ...row }))); + return Object.assign(Promise.resolve(), { + returning, + onConflictDoNothing: () => Object.assign(Promise.resolve(), { returning }), + onConflictDoUpdate: (config: Record) => { + upserts.push({ table, config }); + return Object.assign(Promise.resolve(), { returning }); + } + }); + } + }), + select: (fields?: unknown) => { + selects.push(fields); + return { from: (table: unknown) => selectChain(table) }; + } + }; + + const logger = mock(); + return { writer: new AkashWriter(logger), tx: tx as unknown as ChainTransaction, inserts, upserts, selects, logger }; + } + + function deploymentRow(overrides: Record) { + return { + id: 1, + ownerAccountId: 7, + dseq: "42", + denom: "uakt", + deposit: "1000", + balance: "1000.000000000000000000", + withdrawnAmount: "0.000000000000000000", + blockRate: "10.000000000000000000", + lastWithdrawHeight: null, + lastProcessedHeight: 100, + createdHeight: 100, + createdAt: BLOCK_TIME, + closedHeight: null, + closedAt: null, + closeReason: null, + cpuUnits: 2000, + gpuUnits: 0, + memoryBytes: 0, + ephemeralStorageBytes: 0, + persistentStorageBytes: 0, + ...overrides + }; + } + + function leaseRow() { + return { + deploymentId: 1, + deploymentGroupId: 2, + gseq: 1, + oseq: 1, + bseq: 0, + providerAccountId: 8, + price: "10.000000000000000000", + denom: "uakt", + balance: "0.000000000000000000", + withdrawnAmount: "0.000000000000000000", + predictedClosedHeight: "210", + createdHeight: 110, + createdAt: BLOCK_TIME, + closedHeight: null, + closedAt: null, + cpuUnits: 2000, + gpuUnits: 0, + memoryBytes: 0, + ephemeralStorageBytes: 0, + persistentStorageBytes: 0 + }; + } + + function block(height: number, bodies: AkashChangeBody[]): AkashBlockChanges { + return { height, datetime: BLOCK_TIME, changes: bodies.map((body, index) => ({ ...body, txIndex: 0, msgIndex: index })) }; + } + + function create(): AkashChangeBody { + return { + kind: "deploymentCreated", + key: KEY, + denom: "uakt", + deposit: "5000000", + depositor: null, + groups: [ + { + gseq: 1, + resources: [ + { + count: 2, + cpuUnits: 1000, + gpuUnits: 0, + gpuVendor: null, + gpuModel: null, + memoryBytes: 0, + ephemeralStorageBytes: 0, + persistentStorageBytes: 0, + price: "1", + priceDenom: "uakt" + } + ] + } + ] + }; + } + + function bidCreated(price: string): AkashChangeBody { + return { kind: "bidCreated", key: LEASE_KEY, price, priceDenom: "uakt" }; + } + + 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/akash-writer.service.ts b/apps/chain-indexer/src/akash/akash-writer.service.ts new file mode 100644 index 0000000000..a6da791652 --- /dev/null +++ b/apps/chain-indexer/src/akash/akash-writer.service.ts @@ -0,0 +1,518 @@ +import { and, eq, inArray, or, sql } from "drizzle-orm"; +import { inject, singleton } from "tsyringe"; + +import type { AkashBlockChanges, DeploymentKey, NormalizedResource } 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"; +import { insertChunked } from "@src/db/insert-chunked"; +import { Accounts, Bids, DeploymentEvents, DeploymentGroupResources, DeploymentGroups, Deployments, Leases } 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 KeyedDeployment { + key: DeploymentKey; + ownerAccountId: number; +} + +/** + * Persists the deployment and market lifecycle inside the block transaction. The touched deployment + * rows are locked `FOR UPDATE` in a deterministic order (so overlapping writers serialize instead of + * deadlocking), folded in memory through the reducer in strict block order, and flushed as guarded + * upserts. The per-deployment `last_processed_height` watermark makes a duplicate commit (replay, + * overlapping pod) a no-op; like the balance ledger, this requires a deployment's messages to be + * indexed in height order from its creation (backfill from genesis before live sync). + */ +@singleton() +export class AkashWriter { + readonly #logger: LoggerService; + + constructor(@inject(LoggerService) logger: LoggerService) { + this.#logger = logger; + this.#logger.setContext("AKASH_WRITER"); + } + + async write(tx: ChainTransaction, blocks: AkashBlockChanges[], accountIds: Map): Promise { + const withChanges = blocks.filter(block => block.changes.length > 0); + if (withChanges.length === 0) { + return; + } + + const keyed = this.#collectKeys(withChanges, accountIds); + const { states, deploymentIds, groupIds, loadedAddressIds } = await this.#loadStates(tx, keyed); + + const warnings = withChanges.flatMap(block => applyBlockChanges(states, block)); + this.#logWarnings(warnings); + + const touched = [...states.values()].filter(state => state.touched); + if (touched.length === 0) { + return; + } + + /** Providers of bids and leases loaded from prior batches aren't in this batch's interned map, so their ids come from the loaded rows. */ + const addressIds = new Map([...loadedAddressIds, ...accountIds]); + + await this.#flushDeployments(tx, touched, addressIds, deploymentIds); + await this.#flushGroups(tx, touched, deploymentIds, groupIds); + await this.#flushGroupResources(tx, touched, deploymentIds, groupIds); + await this.#flushBids(tx, touched, addressIds, deploymentIds); + await this.#flushLeases(tx, touched, addressIds, deploymentIds, groupIds); + await this.#flushEvents(tx, touched, deploymentIds); + } + + /** Deterministic (ownerAccountId, dseq) order for both the row locks and the flush statements, so concurrent writers cannot deadlock. */ + #collectKeys(blocks: AkashBlockChanges[], accountIds: Map): KeyedDeployment[] { + const byKey = new Map(); + for (const block of blocks) { + for (const change of block.changes) { + byKey.set(stateKey(change.key), change.key); + } + } + + return [...byKey.values()] + .map(key => ({ key, ownerAccountId: this.#requireId(accountIds, key.owner) })) + .sort((a, b) => a.ownerAccountId - b.ownerAccountId || compareDseq(a.key.dseq, b.key.dseq)); + } + + async #loadStates( + tx: ChainTransaction, + keyed: KeyedDeployment[] + ): Promise<{ + states: Map; + deploymentIds: Map; + groupIds: Map; + loadedAddressIds: Map; + }> { + const states = new Map(); + const deploymentIds = new Map(); + const groupIds = new Map(); + const loadedAddressIds = new Map(); + + const deploymentRows = await this.#selectDeploymentsForUpdate(tx, keyed); + if (deploymentRows.length === 0) { + return { states, deploymentIds, groupIds, loadedAddressIds }; + } + + const keyByOwnerDseq = new Map(keyed.map(entry => [`${entry.ownerAccountId}/${normalizeDseq(entry.key.dseq)}`, entry.key])); + const ids = deploymentRows.map(row => row.id); + const [groupRows, resourceRows, bidRows, leaseRows] = await Promise.all([ + tx.select().from(DeploymentGroups).where(inArray(DeploymentGroups.deploymentId, ids)), + tx + .select({ resource: DeploymentGroupResources, deploymentId: DeploymentGroups.deploymentId, gseq: DeploymentGroups.gseq }) + .from(DeploymentGroupResources) + .innerJoin(DeploymentGroups, eq(DeploymentGroupResources.deploymentGroupId, DeploymentGroups.id)) + .where(inArray(DeploymentGroups.deploymentId, ids)), + tx.select().from(Bids).where(inArray(Bids.deploymentId, ids)), + tx.select().from(Leases).where(inArray(Leases.deploymentId, ids)) + ]); + + const providerAddressById = await this.#providerAddresses(tx, [ + ...bidRows.map(row => row.providerAccountId), + ...leaseRows.map(row => row.providerAccountId) + ]); + for (const [id, address] of providerAddressById) { + loadedAddressIds.set(address, id); + } + + for (const row of deploymentRows) { + const key = keyByOwnerDseq.get(`${row.ownerAccountId}/${normalizeDseq(row.dseq)}`); + if (!key) { + continue; + } + deploymentIds.set(stateKey(key), row.id); + + const groups = groupRows.filter(group => group.deploymentId === row.id); + for (const group of groups) { + groupIds.set(`${row.id}/${group.gseq}`, group.id); + } + + states.set(stateKey(key), { + key, + denom: row.denom, + deposit: BigInt(row.deposit), + balance: decFromString(row.balance), + withdrawn: decFromString(row.withdrawnAmount), + lastWithdrawHeight: row.lastWithdrawHeight, + lastProcessedHeight: row.lastProcessedHeight, + createdHeight: row.createdHeight, + createdAt: row.createdAt, + closedHeight: row.closedHeight, + closedAt: row.closedAt, + closeReason: row.closeReason, + cpuUnits: row.cpuUnits, + gpuUnits: row.gpuUnits, + memoryBytes: row.memoryBytes, + ephemeralStorageBytes: row.ephemeralStorageBytes, + persistentStorageBytes: row.persistentStorageBytes, + groups: groups.map(group => ({ + gseq: group.gseq, + state: group.state as GroupStateValue, + closedHeight: group.closedHeight, + resources: resourceRows + .filter(entry => entry.deploymentId === row.id && entry.gseq === group.gseq) + .sort((a, b) => a.resource.idx - b.resource.idx) + .map(entry => toNormalizedResource(entry.resource)) + })), + bids: bidRows + .filter(bid => bid.deploymentId === row.id) + .map(bid => ({ + gseq: bid.gseq, + oseq: bid.oseq, + bseq: bid.bseq, + provider: this.#requireAddress(providerAddressById, bid.providerAccountId), + price: decFromString(bid.price), + denom: bid.denom, + state: bid.state as BidStateValue, + createdHeight: bid.createdHeight, + closedHeight: bid.closedHeight + })), + leases: leaseRows + .filter(lease => lease.deploymentId === row.id) + .map(lease => ({ + gseq: lease.gseq, + oseq: lease.oseq, + bseq: lease.bseq, + provider: this.#requireAddress(providerAddressById, lease.providerAccountId), + price: decFromString(lease.price), + denom: lease.denom, + balance: decFromString(lease.balance), + withdrawn: decFromString(lease.withdrawnAmount), + predictedClosedHeight: BigInt(lease.predictedClosedHeight), + createdHeight: lease.createdHeight, + createdAt: lease.createdAt, + closedHeight: lease.closedHeight, + closedAt: lease.closedAt, + cpuUnits: lease.cpuUnits, + gpuUnits: lease.gpuUnits, + memoryBytes: lease.memoryBytes, + ephemeralStorageBytes: lease.ephemeralStorageBytes, + persistentStorageBytes: lease.persistentStorageBytes + })), + events: [], + isNew: false, + touched: false + }); + } + + return { states, deploymentIds, groupIds, loadedAddressIds }; + } + + async #selectDeploymentsForUpdate(tx: ChainTransaction, keyed: KeyedDeployment[]) { + const filters = keyed.map(entry => and(eq(Deployments.ownerAccountId, entry.ownerAccountId), eq(Deployments.dseq, entry.key.dseq))); + return tx + .select() + .from(Deployments) + .where(or(...filters)) + .orderBy(Deployments.ownerAccountId, Deployments.dseq) + .for("update"); + } + + /** Bid and lease provider addresses are only stored as account ids; the reducer keys leases by address, so resolve them back. */ + async #providerAddresses(tx: ChainTransaction, providerAccountIds: number[]): Promise> { + const unique = [...new Set(providerAccountIds)]; + if (unique.length === 0) { + return new Map(); + } + const rows = await tx.select({ id: Accounts.id, address: Accounts.address }).from(Accounts).where(inArray(Accounts.id, unique)); + return new Map(rows.map(row => [row.id, row.address])); + } + + async #flushDeployments( + tx: ChainTransaction, + touched: DeploymentAggState[], + accountIds: Map, + deploymentIds: Map + ): Promise { + const rows = touched.map(state => ({ + ownerAccountId: this.#requireId(accountIds, state.key.owner), + dseq: state.key.dseq, + denom: state.denom, + deposit: state.deposit.toString(), + balance: decToString(state.balance), + withdrawnAmount: decToString(state.withdrawn), + blockRate: decToString(state.leases.filter(lease => lease.closedHeight === null).reduce((sum, lease) => sum + lease.price, 0n)), + lastWithdrawHeight: state.lastWithdrawHeight, + lastProcessedHeight: state.lastProcessedHeight, + createdHeight: state.createdHeight, + createdAt: state.createdAt, + closedHeight: state.closedHeight, + closedAt: state.closedAt, + closeReason: state.closeReason, + cpuUnits: state.cpuUnits, + gpuUnits: state.gpuUnits, + memoryBytes: state.memoryBytes, + ephemeralStorageBytes: state.ephemeralStorageBytes, + persistentStorageBytes: state.persistentStorageBytes + })); + + const inserted = await tx + .insert(Deployments) + .values(rows) + .onConflictDoUpdate({ + target: [Deployments.ownerAccountId, Deployments.dseq], + set: { + denom: sqlExcluded("denom"), + deposit: sqlExcluded("deposit"), + balance: sqlExcluded("balance"), + withdrawnAmount: sqlExcluded("withdrawn_amount"), + blockRate: sqlExcluded("block_rate"), + lastWithdrawHeight: sqlExcluded("last_withdraw_height"), + lastProcessedHeight: sqlExcluded("last_processed_height"), + closedHeight: sqlExcluded("closed_height"), + closedAt: sqlExcluded("closed_at"), + closeReason: sqlExcluded("close_reason") + }, + setWhere: sql`excluded.last_processed_height >= ${Deployments.lastProcessedHeight}` + }) + .returning({ id: Deployments.id, ownerAccountId: Deployments.ownerAccountId, dseq: Deployments.dseq }); + + const idByOwnerDseq = new Map(inserted.map(row => [`${row.ownerAccountId}/${normalizeDseq(row.dseq)}`, row.id])); + for (const state of touched) { + const id = idByOwnerDseq.get(`${this.#requireId(accountIds, state.key.owner)}/${normalizeDseq(state.key.dseq)}`); + if (id !== undefined) { + deploymentIds.set(stateKey(state.key), id); + } + } + + const missing = touched.filter(state => !deploymentIds.has(stateKey(state.key))); + if (missing.length > 0) { + const rowsForMissing = await this.#selectDeploymentsForUpdate( + tx, + missing.map(state => ({ key: state.key, ownerAccountId: this.#requireId(accountIds, state.key.owner) })) + ); + const keyByOwnerDseq = new Map(missing.map(state => [`${this.#requireId(accountIds, state.key.owner)}/${normalizeDseq(state.key.dseq)}`, state.key])); + for (const row of rowsForMissing) { + const key = keyByOwnerDseq.get(`${row.ownerAccountId}/${normalizeDseq(row.dseq)}`); + if (key) { + deploymentIds.set(stateKey(key), row.id); + } + } + } + } + + async #flushGroups(tx: ChainTransaction, touched: DeploymentAggState[], deploymentIds: Map, groupIds: Map): Promise { + const rows = touched.flatMap(state => { + const deploymentId = this.#requireDeploymentId(deploymentIds, state); + return state.groups.map(group => ({ deploymentId, gseq: group.gseq, state: group.state, closedHeight: group.closedHeight })); + }); + if (rows.length === 0) { + return; + } + + const affected = await tx + .insert(DeploymentGroups) + .values(rows) + .onConflictDoUpdate({ + target: [DeploymentGroups.deploymentId, DeploymentGroups.gseq], + set: { state: sqlExcluded("state"), closedHeight: sqlExcluded("closed_height") } + }) + .returning({ id: DeploymentGroups.id, deploymentId: DeploymentGroups.deploymentId, gseq: DeploymentGroups.gseq }); + + for (const row of affected) { + groupIds.set(`${row.deploymentId}/${row.gseq}`, row.id); + } + } + + async #flushGroupResources( + tx: ChainTransaction, + touched: DeploymentAggState[], + deploymentIds: Map, + groupIds: Map + ): Promise { + const rows = touched + .filter(state => state.isNew) + .flatMap(state => + state.groups.flatMap(group => + group.resources.map((resource, idx) => ({ + deploymentGroupId: this.#requireGroupId(groupIds, this.#requireDeploymentId(deploymentIds, state), group.gseq), + idx, + count: resource.count, + cpuUnits: resource.cpuUnits, + gpuUnits: resource.gpuUnits, + gpuVendor: resource.gpuVendor, + gpuModel: resource.gpuModel, + memoryBytes: resource.memoryBytes, + ephemeralStorageBytes: resource.ephemeralStorageBytes, + persistentStorageBytes: resource.persistentStorageBytes, + price: resource.price, + priceDenom: resource.priceDenom + })) + ) + ); + await insertChunked(tx, DeploymentGroupResources, rows); + } + + async #flushBids(tx: ChainTransaction, touched: DeploymentAggState[], accountIds: Map, deploymentIds: Map): Promise { + const rows = touched.flatMap(state => { + const deploymentId = this.#requireDeploymentId(deploymentIds, state); + return state.bids.map(bid => ({ + deploymentId, + gseq: bid.gseq, + oseq: bid.oseq, + bseq: bid.bseq, + providerAccountId: this.#requireId(accountIds, bid.provider), + price: decToString(bid.price), + denom: bid.denom, + state: bid.state, + createdHeight: bid.createdHeight, + closedHeight: bid.closedHeight + })); + }); + if (rows.length === 0) { + return; + } + + await tx + .insert(Bids) + .values(rows) + .onConflictDoUpdate({ + target: [Bids.deploymentId, Bids.gseq, Bids.oseq, Bids.bseq, Bids.providerAccountId], + set: { + price: sqlExcluded("price"), + denom: sqlExcluded("denom"), + state: sqlExcluded("state"), + createdHeight: sqlExcluded("created_height"), + closedHeight: sqlExcluded("closed_height") + } + }); + } + + async #flushLeases( + tx: ChainTransaction, + touched: DeploymentAggState[], + accountIds: Map, + deploymentIds: Map, + groupIds: Map + ): Promise { + const rows = touched.flatMap(state => { + const deploymentId = this.#requireDeploymentId(deploymentIds, state); + return state.leases.map(lease => ({ + deploymentId, + deploymentGroupId: this.#requireGroupId(groupIds, deploymentId, lease.gseq), + gseq: lease.gseq, + oseq: lease.oseq, + bseq: lease.bseq, + providerAccountId: this.#requireId(accountIds, lease.provider), + price: decToString(lease.price), + denom: lease.denom, + balance: decToString(lease.balance), + withdrawnAmount: decToString(lease.withdrawn), + predictedClosedHeight: lease.predictedClosedHeight.toString(), + createdHeight: lease.createdHeight, + createdAt: lease.createdAt, + closedHeight: lease.closedHeight, + closedAt: lease.closedAt, + cpuUnits: lease.cpuUnits, + gpuUnits: lease.gpuUnits, + memoryBytes: lease.memoryBytes, + ephemeralStorageBytes: lease.ephemeralStorageBytes, + persistentStorageBytes: lease.persistentStorageBytes + })); + }); + if (rows.length === 0) { + return; + } + + await tx + .insert(Leases) + .values(rows) + .onConflictDoUpdate({ + target: [Leases.deploymentId, Leases.gseq, Leases.oseq, Leases.bseq, Leases.providerAccountId], + set: { + balance: sqlExcluded("balance"), + withdrawnAmount: sqlExcluded("withdrawn_amount"), + predictedClosedHeight: sqlExcluded("predicted_closed_height"), + closedHeight: sqlExcluded("closed_height"), + closedAt: sqlExcluded("closed_at") + } + }); + } + + async #flushEvents(tx: ChainTransaction, touched: DeploymentAggState[], deploymentIds: Map): Promise { + const rows = touched.flatMap(state => { + const deploymentId = this.#requireDeploymentId(deploymentIds, state); + return state.events.map(event => ({ + deploymentId, + height: event.height, + ordinal: event.ordinal, + txIndex: event.txIndex, + msgIndex: event.msgIndex, + type: event.type, + details: event.details + })); + }); + await insertChunked(tx, DeploymentEvents, rows); + } + + #logWarnings(warnings: ReducerWarning[]): void { + if (warnings.length === 0) { + return; + } + const byCode = new Map(); + for (const warning of warnings) { + byCode.set(warning.code, [...(byCode.get(warning.code) ?? []), warning]); + } + for (const [code, group] of byCode) { + this.#logger.warn({ event: code, count: group.length, samples: group.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; + } + + #requireAddress(addressesById: Map, accountId: number): string { + const address = addressesById.get(accountId); + if (address === undefined) { + throw new Error(`No account row for id ${accountId}`); + } + return address; + } + + #requireDeploymentId(deploymentIds: Map, state: DeploymentAggState): number { + const id = deploymentIds.get(stateKey(state.key)); + if (id === undefined) { + throw new Error(`No deployment id for ${stateKey(state.key)}`); + } + return id; + } + + #requireGroupId(groupIds: Map, deploymentId: number, gseq: number): number { + const id = groupIds.get(`${deploymentId}/${gseq}`); + if (id === undefined) { + throw new Error(`No deployment group id for deployment ${deploymentId} gseq ${gseq}`); + } + return id; + } +} + +function toNormalizedResource(resource: typeof DeploymentGroupResources.$inferSelect): NormalizedResource { + return { + count: resource.count, + cpuUnits: resource.cpuUnits, + gpuUnits: resource.gpuUnits, + gpuVendor: resource.gpuVendor, + gpuModel: resource.gpuModel, + memoryBytes: resource.memoryBytes, + ephemeralStorageBytes: resource.ephemeralStorageBytes, + persistentStorageBytes: resource.persistentStorageBytes, + price: resource.price, + priceDenom: resource.priceDenom + }; +} + +/** Postgres normalizes numeric literals (e.g. strips leading zeros), so dseq comparisons go through one canonical form. */ +function normalizeDseq(dseq: string): string { + return BigInt(dseq).toString(); +} + +function compareDseq(a: string, b: string): number { + const left = BigInt(a); + const right = BigInt(b); + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/apps/chain-indexer/src/akash/dec.spec.ts b/apps/chain-indexer/src/akash/dec.spec.ts new file mode 100644 index 0000000000..53d86c9569 --- /dev/null +++ b/apps/chain-indexer/src/akash/dec.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { DEC_ONE, decCeilInt, decFromInt, decFromString, decMul, decMulInt, decQuo, decToString, decTruncateInt, minBigInt } from "@src/akash/dec"; + +describe("dec", () => { + describe("decFromString", () => { + it("parses integer coin amounts", () => { + expect(decFromString("1000")).toBe(1000n * DEC_ONE); + }); + + it("parses fractional DecCoin amounts", () => { + expect(decFromString("1.5")).toBe(1_500_000_000_000_000_000n); + }); + + it("parses postgres numeric(38,18) output with full fractional padding", () => { + expect(decFromString("2.500000000000000000")).toBe(2_500_000_000_000_000_000n); + }); + + it("parses negative values", () => { + expect(decFromString("-0.5")).toBe(-500_000_000_000_000_000n); + }); + + it("rejects malformed strings", () => { + expect(() => decFromString("1e5")).toThrow("Invalid decimal string"); + expect(() => decFromString("")).toThrow("Invalid decimal string"); + }); + + it("rejects more than 18 fractional digits", () => { + expect(() => decFromString("1.0000000000000000001")).toThrow("18 fractional digits"); + }); + }); + + describe("decToString", () => { + it("round-trips integers and fractions", () => { + expect(decToString(decFromString("1000"))).toBe("1000"); + expect(decToString(decFromString("1.5"))).toBe("1.5"); + expect(decToString(decFromString("-0.5"))).toBe("-0.5"); + }); + + it("keeps full 18-digit precision", () => { + expect(decToString(1n)).toBe("0.000000000000000001"); + }); + }); + + describe("decQuo", () => { + it("rounds half away from zero at the 18th decimal like LegacyDec", () => { + expect(decQuo(decFromInt(1), decFromInt(3))).toBe(333_333_333_333_333_333n); + expect(decQuo(decFromInt(2), decFromInt(3))).toBe(666_666_666_666_666_667n); + }); + + it("divides exactly when no remainder exists", () => { + expect(decQuo(decFromInt(10), decFromInt(4))).toBe(decFromString("2.5")); + }); + + it("throws on division by zero", () => { + expect(() => decQuo(DEC_ONE, 0n)).toThrow("Division by zero"); + }); + }); + + describe("decMul", () => { + it("multiplies with rounding at the 18th decimal", () => { + const oneThird = decQuo(decFromInt(1), decFromInt(3)); + expect(decMul(oneThird, decFromInt(3))).toBe(999_999_999_999_999_999n); + }); + + it("multiplies exact values without loss", () => { + expect(decMul(decFromString("1.5"), decFromString("2"))).toBe(decFromString("3")); + }); + }); + + describe("decMulInt", () => { + it("is exact for integer multipliers", () => { + expect(decMulInt(decFromString("0.000000000000000001"), 1_000_000_000_000_000_000n)).toBe(DEC_ONE); + }); + }); + + describe("decTruncateInt", () => { + it("truncates toward zero", () => { + expect(decTruncateInt(decFromString("2.9"))).toBe(2n); + expect(decTruncateInt(decFromString("2"))).toBe(2n); + }); + }); + + describe("decCeilInt", () => { + it("rounds up any fractional part", () => { + expect(decCeilInt(decFromString("2.000000000000000001"))).toBe(3n); + expect(decCeilInt(decFromString("2"))).toBe(2n); + }); + }); + + describe("minBigInt", () => { + it("returns the smaller value", () => { + expect(minBigInt(3n, 5n)).toBe(3n); + expect(minBigInt(5n, 3n)).toBe(3n); + }); + }); +}); diff --git a/apps/chain-indexer/src/akash/dec.ts b/apps/chain-indexer/src/akash/dec.ts new file mode 100644 index 0000000000..923c3ac317 --- /dev/null +++ b/apps/chain-indexer/src/akash/dec.ts @@ -0,0 +1,75 @@ +/** + * Fixed-point decimal math mirroring cosmos-sdk's LegacyDec: values are bigint atomics at 10^-18 + * scale. Escrow settlement must reproduce the chain's arithmetic exactly, which JS floats cannot + * (the legacy indexer's DOUBLE drift is the bug being fixed) and no decimal library replicates + * LegacyDec's two-step truncate-then-round quotient, so the four operations the keeper uses are + * implemented here directly. + */ +export const DEC_ONE = 10n ** 18n; + +const SQUARED_PRECISION = DEC_ONE * DEC_ONE; + +export function decFromString(value: string): bigint { + const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(value.trim()); + if (!match) throw new Error(`Invalid decimal string: ${value}`); + const [, sign, integerPart, fractionalPart = ""] = match; + if (fractionalPart.length > 18) throw new Error(`Decimal exceeds 18 fractional digits: ${value}`); + const atomics = BigInt(integerPart) * DEC_ONE + BigInt(fractionalPart.padEnd(18, "0")); + return sign === "-" ? -atomics : atomics; +} + +export function decFromInt(value: bigint | number): bigint { + return BigInt(value) * DEC_ONE; +} + +export function decToString(atomics: bigint): string { + const sign = atomics < 0n ? "-" : ""; + const abs = atomics < 0n ? -atomics : atomics; + const integerPart = abs / DEC_ONE; + const fractionalPart = (abs % DEC_ONE).toString().padStart(18, "0").replace(/0+$/, ""); + return `${sign}${integerPart}${fractionalPart ? `.${fractionalPart}` : ""}`; +} + +/** LegacyDec chopPrecisionAndRound: divide by 10^18 rounding half away from zero. */ +function chopPrecisionAndRound(value: bigint): bigint { + const negative = value < 0n; + const abs = negative ? -value : value; + const quotient = abs / DEC_ONE; + const remainder = abs % DEC_ONE; + const rounded = remainder * 2n >= DEC_ONE ? quotient + 1n : quotient; + return negative ? -rounded : rounded; +} + +export function decMul(a: bigint, b: bigint): bigint { + return chopPrecisionAndRound(a * b); +} + +export function decMulInt(a: bigint, b: bigint): bigint { + return a * b; +} + +/** + * LegacyDec Quo: scale the numerator by 10^36, truncate-divide by the denominator, then chop back + * one precision with rounding. The intermediate truncation is part of the chain's semantics, so the + * two steps are kept distinct instead of rounding a single 10^18-scaled quotient. + */ +export function decQuo(a: bigint, b: bigint): bigint { + if (b === 0n) throw new Error("Division by zero"); + return chopPrecisionAndRound((a * SQUARED_PRECISION) / b); +} + +/** Truncate toward zero to a whole integer (not atomics), matching LegacyDec TruncateInt. */ +export function decTruncateInt(atomics: bigint): bigint { + return atomics / DEC_ONE; +} + +/** Smallest integer (not atomics) greater than or equal to the value, matching LegacyDec Ceil for positive values. */ +export function decCeilInt(atomics: bigint): bigint { + const quotient = atomics / DEC_ONE; + const remainder = atomics % DEC_ONE; + return remainder > 0n ? quotient + 1n : quotient; +} + +export function minBigInt(a: bigint, b: bigint): bigint { + return a < b ? a : b; +} diff --git a/apps/chain-indexer/src/akash/denom.ts b/apps/chain-indexer/src/akash/denom.ts new file mode 100644 index 0000000000..c18879df4b --- /dev/null +++ b/apps/chain-indexer/src/akash/denom.ts @@ -0,0 +1,16 @@ +/** The IBC denoms deployments are funded with, mapped to their base denom (mirrors the legacy indexer's mapping). */ +const DENOM_MAPPING: Record = { + uakt: "uakt", + uact: "uact", + "ibc/028CD1864059EEFB48A6048376165318E3E82C234390AE5A6D7B22001725B06E": "uusdc", + "ibc/170C677610AC31DF0904FFE09CD3B5C657492170E7E52372E48756B71E56F2F1": "uusdc" +}; + +/** + * Unknown denoms are stored raw instead of throwing (the legacy indexer aborts the block), so a new + * funding denom degrades to an unmapped row rather than halting ingestion. + */ +export function normalizeDenom(denom: string): { denom: string; known: boolean } { + const mapped = DENOM_MAPPING[denom]; + return mapped ? { denom: mapped, known: true } : { denom, known: false }; +} diff --git a/apps/chain-indexer/src/akash/deployment-reducer.spec.ts b/apps/chain-indexer/src/akash/deployment-reducer.spec.ts new file mode 100644 index 0000000000..dbc10083a5 --- /dev/null +++ b/apps/chain-indexer/src/akash/deployment-reducer.spec.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from "vitest"; + +import type { AkashBlockChanges, AkashChangeBody, NormalizedGroup } from "@src/akash/akash-changes"; +import { decFromInt } from "@src/akash/dec"; +import type { DeploymentAggState } from "@src/akash/deployment-reducer"; +import { applyBlockChanges, stateKey } from "@src/akash/deployment-reducer"; + +const OWNER = "akash1owner"; +const PROVIDER = "akash1prov"; +const KEY = { owner: OWNER, dseq: "42" }; +const LEASE_KEY = { ...KEY, gseq: 1, oseq: 1, bseq: 0, provider: PROVIDER }; +const BLOCK_TIME = new Date("2026-08-13T00:00:00Z"); + +describe("applyBlockChanges", () => { + it("creates a deployment whose totals are the sum of its group resources", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({ groups: twoGroups() })])); + + const state = get(states); + expect(state.deposit).toBe(5000000n); + expect(state.balance).toBe(decFromInt(5000000)); + expect(state.denom).toBe("uakt"); + expect(state.cpuUnits).toBe(1000 * 2 + 500 * 3); + expect(state.gpuUnits).toBe(0 * 2 + 1 * 3); + expect(state.memoryBytes).toBe(1024 * 2 + 2048 * 3); + expect(state.ephemeralStorageBytes).toBe(100 * 2 + 200 * 3); + expect(state.persistentStorageBytes).toBe(50 * 2 + 0 * 3); + expect(state.groups).toHaveLength(2); + expect(state.events).toEqual([{ height: 100, ordinal: 0, txIndex: 0, msgIndex: 0, type: "created", details: { deposit: "5000000", denom: "uakt" } }]); + }); + + it("maps an ibc funding denom and warns on an unknown one instead of throwing", () => { + const { states } = setup(); + + const warnings = applyBlockChanges( + states, + block(100, [ + create({ denom: "ibc/170C677610AC31DF0904FFE09CD3B5C657492170E7E52372E48756B71E56F2F1" }), + { ...create({ denom: "ibc/deadbeef" }), key: { owner: OWNER, dseq: "43" } } + ]) + ); + + expect(get(states).denom).toBe("uusdc"); + expect(states.get(`${OWNER}/43`)?.denom).toBe("ibc/deadbeef"); + expect(warnings).toEqual([{ code: "AKASH_UNKNOWN_DENOM", kind: "deploymentCreated", owner: OWNER, dseq: "43", height: 100 }]); + }); + + it("runs the full lifecycle: create, bid, lease, withdraw, close", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({}), bidCreated("10")])); + applyBlockChanges(states, block(110, [leaseCreated()])); + applyBlockChanges(states, block(150, [{ kind: "leaseWithdrawn", key: LEASE_KEY }])); + applyBlockChanges(states, block(200, [{ kind: "deploymentClosed", key: KEY }])); + + const state = get(states); + const lease = state.leases[0]; + expect(lease.price).toBe(decFromInt(10)); + expect(lease.cpuUnits).toBe(2000); + expect(lease.withdrawn).toBe(decFromInt(90 * 10)); + expect(lease.closedHeight).toBe(200); + expect(lease.closedAt).toBe(BLOCK_TIME); + expect(state.withdrawn).toBe(decFromInt(900)); + expect(state.balance).toBe(decFromInt(5000000 - 900)); + expect(state.lastWithdrawHeight).toBe(200); + expect(state.closedHeight).toBe(200); + expect(state.closeReason).toBe("close_message"); + expect(state.bids[0].state).toBe("closed"); + expect(state.events.map(event => event.type)).toEqual(["created", "bid_created", "lease_created", "lease_withdrawn", "closed"]); + }); + + it("computes the lease predicted close height from the bid price and re-predicts on deposit", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({ deposit: "1000" }), bidCreated("10")])); + applyBlockChanges(states, block(110, [leaseCreated()])); + + expect(get(states).leases[0].predictedClosedHeight).toBe(110n + 100n); + + applyBlockChanges(states, block(120, [{ kind: "deploymentDeposited", key: KEY, amount: "1000", depositor: "akash1grantee" }])); + + const state = get(states); + expect(state.deposit).toBe(2000n); + expect(state.balance).toBe(decFromInt(2000)); + expect(state.leases[0].predictedClosedHeight).toBe(110n + 200n); + expect(state.events.at(-1)).toMatchObject({ type: "deposited", details: { amount: "1000", denom: "uakt", depositor: "akash1grantee" } }); + }); + + it("closes everything with reason overdrawn when a settlement exhausts the balance", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({ deposit: "1000" }), bidCreated("10")])); + applyBlockChanges(states, block(110, [leaseCreated()])); + applyBlockChanges(states, block(300, [{ kind: "leaseWithdrawn", key: LEASE_KEY }])); + + const state = get(states); + expect(state.balance).toBe(0n); + expect(state.withdrawn).toBe(decFromInt(1000)); + expect(state.closedHeight).toBe(300); + expect(state.closeReason).toBe("overdrawn"); + expect(state.leases[0].closedHeight).toBe(300); + expect(state.leases[0].withdrawn).toBe(decFromInt(1000)); + expect(state.bids[0].state).toBe("closed"); + expect(state.events.map(event => event.type)).toEqual(["created", "bid_created", "lease_created", "closed", "lease_withdrawn"]); + }); + + it("keeps the deployment open when leases close and re-predicts the remaining ones", () => { + const { states } = setup(); + const secondLease = { ...LEASE_KEY, gseq: 2 }; + + applyBlockChanges(states, block(100, [create({ groups: twoGroups() }), bidCreated("10"), { ...bidCreated("30"), key: secondLease }])); + applyBlockChanges(states, block(110, [leaseCreated(), { kind: "leaseCreated", key: secondLease }])); + applyBlockChanges(states, block(120, [{ kind: "leaseClosed", key: secondLease }])); + + let state = get(states); + expect(state.closedHeight).toBeNull(); + expect(state.leases.find(lease => lease.gseq === 2)).toMatchObject({ closedHeight: 120, withdrawn: decFromInt(300), balance: 0n }); + expect(state.balance).toBe(decFromInt(5000000 - 400)); + expect(state.leases[0].predictedClosedHeight).toBe(120n + 499960n); + + applyBlockChanges(states, block(130, [{ kind: "leaseClosed", key: LEASE_KEY }])); + + state = get(states); + expect(state.closedHeight).toBeNull(); + expect(state.leases[0].closedHeight).toBe(130); + expect(state.withdrawn).toBe(decFromInt(300 + 200)); + }); + + it("truncates payouts to whole units and refunds the fraction to the deployment on lease close", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({ deposit: "500000" }), bidCreated("2.349334")])); + applyBlockChanges(states, block(110, [leaseCreated()])); + applyBlockChanges(states, block(122, [{ kind: "leaseClosed", key: LEASE_KEY }])); + + const state = get(states); + expect(state.leases[0].withdrawn).toBe(decFromInt(28)); + expect(state.leases[0].balance).toBe(0n); + expect(state.withdrawn).toBe(decFromInt(28)); + expect(state.balance).toBe(decFromInt(500000 - 28)); + }); + + it("skips a duplicate block per deployment via the watermark but applies later blocks", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({}), bidCreated("10")])); + applyBlockChanges(states, block(110, [leaseCreated()])); + const snapshot = JSON.stringify(get(states), stringifyBigInt); + + applyBlockChanges(states, block(110, [leaseCreated()])); + + expect(JSON.stringify(get(states), stringifyBigInt)).toBe(snapshot); + expect(get(states).leases).toHaveLength(1); + + applyBlockChanges(states, block(150, [{ kind: "leaseWithdrawn", key: LEASE_KEY }])); + expect(get(states).lastWithdrawHeight).toBe(150); + }); + + it("reports orphan references without mutating state", () => { + const { states } = setup(); + + const warnings = applyBlockChanges(states, block(100, [{ kind: "deploymentDeposited", key: KEY, amount: "5", depositor: null }])); + + expect(states.size).toBe(0); + expect(warnings).toEqual([{ code: "AKASH_ORPHAN_REFERENCE", kind: "deploymentDeposited", owner: OWNER, dseq: "42", height: 100 }]); + }); + + it("applies close-event fallbacks with settlement, only when not already closed", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({ deposit: "10000" }), bidCreated("10")])); + applyBlockChanges(states, block(110, [leaseCreated()])); + applyBlockChanges(states, block(120, [{ kind: "leaseClosedEvent", key: KEY, gseq: 1, oseq: 1, bseq: null, provider: PROVIDER }])); + + let state = get(states); + expect(state.leases[0].closedHeight).toBe(120); + expect(state.leases[0].withdrawn).toBe(decFromInt(100)); + expect(state.closedHeight).toBeNull(); + + applyBlockChanges(states, block(130, [{ kind: "deploymentClosedEvent", key: KEY }])); + state = get(states); + expect(state.closedHeight).toBe(130); + expect(state.closeReason).toBe("close_event"); + + applyBlockChanges(states, block(140, [{ kind: "deploymentClosedEvent", key: KEY }])); + expect(get(states).closedHeight).toBe(130); + }); + + it("tracks group lifecycle transitions without reopening a closed group", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({})])); + applyBlockChanges(states, block(110, [{ kind: "groupPaused", key: KEY, gseq: 1 }])); + expect(get(states).groups[0].state).toBe("paused"); + + applyBlockChanges(states, block(120, [{ kind: "groupClosed", key: KEY, gseq: 1 }])); + expect(get(states).groups[0]).toMatchObject({ state: "closed", closedHeight: 120 }); + + applyBlockChanges(states, block(130, [{ kind: "groupStarted", key: KEY, gseq: 1 }])); + expect(get(states).groups[0].state).toBe("closed"); + }); + + it("assigns sequential event ordinals within a block", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({}), { kind: "deploymentUpdated", key: KEY }, bidCreated("10")])); + + expect(get(states).events.map(event => [event.type, event.ordinal])).toEqual([ + ["created", 0], + ["updated", 1], + ["bid_created", 2] + ]); + }); + + function setup() { + return { states: new Map() }; + } + + function get(states: Map): DeploymentAggState { + const state = states.get(stateKey(KEY)); + if (!state) { + throw new Error("deployment state missing"); + } + return state; + } + + function block(height: number, bodies: AkashChangeBody[]): AkashBlockChanges { + return { + height, + datetime: BLOCK_TIME, + changes: bodies.map((body, index) => ({ ...body, txIndex: 0, msgIndex: index })) + }; + } + + function create(input: { deposit?: string; denom?: string; groups?: NormalizedGroup[] }): AkashChangeBody { + return { + kind: "deploymentCreated", + key: KEY, + denom: input.denom ?? "uakt", + deposit: input.deposit ?? "5000000", + depositor: null, + groups: input.groups ?? [group(1, { cpuUnits: 1000, count: 2 })] + }; + } + + function bidCreated(price: string): AkashChangeBody { + return { kind: "bidCreated", key: LEASE_KEY, price, priceDenom: "uakt" }; + } + + function leaseCreated(): AkashChangeBody { + return { kind: "leaseCreated", key: LEASE_KEY }; + } + + function twoGroups(): NormalizedGroup[] { + return [ + group(1, { cpuUnits: 1000, gpuUnits: 0, memoryBytes: 1024, ephemeralStorageBytes: 100, persistentStorageBytes: 50, count: 2 }), + group(2, { cpuUnits: 500, gpuUnits: 1, memoryBytes: 2048, ephemeralStorageBytes: 200, persistentStorageBytes: 0, count: 3 }) + ]; + } + + function group(gseq: number, resource: Partial): NormalizedGroup { + return { + gseq, + resources: [ + { + count: 1, + cpuUnits: 0, + gpuUnits: 0, + gpuVendor: null, + gpuModel: null, + memoryBytes: 0, + ephemeralStorageBytes: 0, + persistentStorageBytes: 0, + price: "1", + priceDenom: "uakt", + ...resource + } + ] + }; + } + + function stringifyBigInt(_: string, value: unknown): unknown { + return typeof value === "bigint" ? value.toString() : value; + } +}); diff --git a/apps/chain-indexer/src/akash/deployment-reducer.ts b/apps/chain-indexer/src/akash/deployment-reducer.ts new file mode 100644 index 0000000000..e032849687 --- /dev/null +++ b/apps/chain-indexer/src/akash/deployment-reducer.ts @@ -0,0 +1,575 @@ +import type { AkashBlockChanges, AkashChange, DeploymentKey, NormalizedGroup, NormalizedResource } from "@src/akash/akash-changes"; +import { decCeilInt, decFromInt, decFromString, decQuo, decToString, decTruncateInt } from "@src/akash/dec"; +import { normalizeDenom } from "@src/akash/denom"; +import { settle } from "@src/akash/settlement"; +import type { bidState, deploymentCloseReason, deploymentEventType, groupState } from "@src/db/schema"; + +export type DeploymentCloseReason = (typeof deploymentCloseReason.enumValues)[number]; +export type DeploymentEventType = (typeof deploymentEventType.enumValues)[number]; +export type GroupStateValue = (typeof groupState.enumValues)[number]; +export type BidStateValue = (typeof bidState.enumValues)[number]; + +export interface ResourceTotals { + cpuUnits: number; + gpuUnits: number; + memoryBytes: number; + ephemeralStorageBytes: number; + persistentStorageBytes: number; +} + +export interface GroupAggState { + gseq: number; + state: GroupStateValue; + closedHeight: number | null; + resources: NormalizedResource[]; +} + +export interface BidAggState { + gseq: number; + oseq: number; + bseq: number; + provider: string; + price: bigint; + denom: string; + state: BidStateValue; + createdHeight: number; + closedHeight: number | null; +} + +export interface LeaseAggState extends ResourceTotals { + gseq: number; + oseq: number; + bseq: number; + provider: string; + price: bigint; + denom: string; + /** Accrued-but-unwithdrawn earnings, mirroring the on-chain payment balance. */ + balance: bigint; + /** Paid-out total; the chain truncates every payout to whole units, so this is always integral. */ + withdrawn: bigint; + predictedClosedHeight: bigint; + createdHeight: number; + createdAt: Date; + closedHeight: number | null; + closedAt: Date | null; +} + +export interface DeploymentEventDraft { + height: number; + ordinal: number; + txIndex: number | null; + msgIndex: number | null; + type: DeploymentEventType; + details: Record | null; +} + +export interface DeploymentAggState extends ResourceTotals { + key: DeploymentKey; + denom: string; + deposit: bigint; + balance: bigint; + withdrawn: bigint; + lastWithdrawHeight: number | null; + lastProcessedHeight: number; + createdHeight: number; + createdAt: Date; + closedHeight: number | null; + closedAt: Date | null; + closeReason: DeploymentCloseReason | null; + groups: GroupAggState[]; + bids: BidAggState[]; + leases: LeaseAggState[]; + events: DeploymentEventDraft[]; + isNew: boolean; + touched: boolean; +} + +export interface ReducerWarning { + code: "AKASH_ORPHAN_REFERENCE" | "AKASH_UNKNOWN_DENOM"; + kind: AkashChange["kind"]; + owner: string; + dseq: string; + height: number; +} + +export function stateKey(key: DeploymentKey): string { + return `${key.owner}/${key.dseq}`; +} + +/** + * Applies one block's derived changes to the in-memory deployment states, porting the legacy + * indexer's handler semantics onto the current keeper's exact escrow math. Blocks must be applied in + * ascending height order. A block at or below a deployment's `lastProcessedHeight` watermark is a + * duplicate commit (replay or an overlapping writer) and is skipped for that deployment, which keeps + * the read-modify-write escrow state idempotent; both runners commit strictly in order, so an + * older-than-watermark block can never carry unseen changes. + */ +export function applyBlockChanges(states: Map, block: AkashBlockChanges): ReducerWarning[] { + const warnings: ReducerWarning[] = []; + const decided = new Map(); + + for (const change of block.changes) { + const key = stateKey(change.key); + + if (change.kind === "deploymentCreated") { + if (shouldApply(decided, states.get(key), block.height, key)) { + createDeployment(states, change, block, warnings); + } + continue; + } + + const state = states.get(key); + if (!state) { + warnings.push({ code: "AKASH_ORPHAN_REFERENCE", kind: change.kind, owner: change.key.owner, dseq: change.key.dseq, height: block.height }); + continue; + } + if (!shouldApply(decided, state, block.height, key)) { + continue; + } + + applyChange(state, change, block, warnings); + } + + for (const [key, applied] of decided) { + const state = states.get(key); + if (state && applied) { + state.lastProcessedHeight = block.height; + state.touched = true; + } + } + + return warnings; +} + +/** The skip decision is made once per deployment per block, so a deployment created earlier in the same block still receives its later changes. */ +function shouldApply(decided: Map, state: DeploymentAggState | undefined, height: number, key: string): boolean { + const existing = decided.get(key); + if (existing !== undefined) { + return existing; + } + const applies = !state || height > state.lastProcessedHeight; + decided.set(key, applies); + return applies; +} + +function applyChange(state: DeploymentAggState, change: AkashChange, block: AkashBlockChanges, warnings: ReducerWarning[]): void { + switch (change.kind) { + case "deploymentDeposited": + return applyDeposit(state, change, block); + case "deploymentUpdated": + return addEvent(state, block, change, "updated", null); + case "deploymentClosed": + return applyDeploymentClose(state, change, block, "close_message"); + case "deploymentClosedEvent": + return applyDeploymentCloseEvent(state, change, block); + case "groupClosed": + return applyGroupChange(state, change, block, "closed", "group_closed"); + case "groupPaused": + return applyGroupChange(state, change, block, "paused", "group_paused"); + case "groupStarted": + return applyGroupChange(state, change, block, "open", "group_started"); + case "bidCreated": + return applyBidCreated(state, change, block); + case "bidClosed": + return applyBidClosed(state, change, block); + case "leaseCreated": + return applyLeaseCreated(state, change, block, warnings); + case "leaseClosed": + return applyLeaseClosed(state, change, block); + case "leaseWithdrawn": + return applyLeaseWithdrawn(state, change, block, warnings); + case "leaseClosedEvent": + return applyLeaseClosedEvent(state, change, block); + } +} + +function createDeployment( + states: Map, + change: Extract, + block: AkashBlockChanges, + warnings: ReducerWarning[] +): void { + const { denom, known } = normalizeDenom(change.denom); + if (!known) { + warnings.push({ code: "AKASH_UNKNOWN_DENOM", kind: change.kind, owner: change.key.owner, dseq: change.key.dseq, height: block.height }); + } + + const state: DeploymentAggState = { + key: change.key, + denom, + deposit: BigInt(change.deposit), + balance: decFromString(change.deposit), + withdrawn: 0n, + lastWithdrawHeight: null, + lastProcessedHeight: 0, + createdHeight: block.height, + createdAt: block.datetime, + closedHeight: null, + closedAt: null, + closeReason: null, + ...sumGroupTotals(change.groups), + groups: change.groups.map(group => ({ gseq: group.gseq, state: "open", closedHeight: null, resources: group.resources })), + bids: [], + leases: [], + events: [], + isNew: true, + touched: true + }; + + states.set(stateKey(change.key), state); + addEvent(state, block, change, "created", { deposit: change.deposit, denom, ...(change.depositor ? { depositor: change.depositor } : {}) }); +} + +function applyDeposit(state: DeploymentAggState, change: Extract, block: AkashBlockChanges): void { + state.deposit += BigInt(change.amount); + state.balance += decFromString(change.amount); + + const openLeases = state.leases.filter(lease => lease.closedHeight === null); + const blockRate = openLeases.reduce((sum, lease) => sum + lease.price, 0n); + for (const lease of openLeases) { + lease.predictedClosedHeight = predictClosedHeight(state.lastWithdrawHeight ?? lease.createdHeight, state.balance, blockRate); + } + + addEvent(state, block, change, "deposited", { amount: change.amount, denom: state.denom, ...(change.depositor ? { depositor: change.depositor } : {}) }); +} + +function applyDeploymentClose(state: DeploymentAggState, change: AkashChange, block: AkashBlockChanges, reason: DeploymentCloseReason): void { + if (state.closedHeight !== null) { + return; + } + settleState(state, block, change); + if (state.closedHeight !== null) { + return; + } + closeDeployment(state, block, reason); + addEvent(state, block, change, "closed", { reason }); +} + +/** + * Side-effect closes (group close, authz revoke) arrive as chain events rather than messages. The + * chain settles the escrow account when it closes, so the fallback settles too — a deliberate fix + * over the legacy indexer, which only stamped the height and let balances drift. + */ +function applyDeploymentCloseEvent(state: DeploymentAggState, change: AkashChange, block: AkashBlockChanges): void { + if (state.closedHeight !== null) { + return; + } + applyDeploymentClose(state, change, block, "close_event"); +} + +function applyGroupChange( + state: DeploymentAggState, + change: Extract, + block: AkashBlockChanges, + groupStateValue: GroupStateValue, + eventType: DeploymentEventType +): void { + const group = state.groups.find(candidate => candidate.gseq === change.gseq); + if (!group || group.state === "closed") { + return; + } + group.state = groupStateValue; + if (groupStateValue === "closed") { + group.closedHeight = block.height; + } + addEvent(state, block, change, eventType, { gseq: change.gseq }); +} + +function applyBidCreated(state: DeploymentAggState, change: Extract, block: AkashBlockChanges): void { + const bid: BidAggState = { + gseq: change.key.gseq, + oseq: change.key.oseq, + bseq: change.key.bseq, + provider: change.key.provider, + price: parsePrice(change.price), + denom: change.priceDenom, + state: "open", + createdHeight: block.height, + closedHeight: null + }; + + const existingIndex = state.bids.findIndex(candidate => sameLeaseKey(candidate, bid)); + if (existingIndex >= 0) { + state.bids[existingIndex] = bid; + } else { + state.bids.push(bid); + } + + addEvent(state, block, change, "bid_created", bidEventDetails(change.key, change.price, change.priceDenom)); +} + +function applyBidClosed(state: DeploymentAggState, change: Extract, block: AkashBlockChanges): void { + const lease = findOpenLease(state, change.key); + if (lease) { + closeLease(state, lease, change, block); + } + + const bid = state.bids.find(candidate => sameLeaseKey(candidate, change.key)); + if (bid && bid.state !== "closed") { + bid.state = "closed"; + bid.closedHeight = block.height; + } + addEvent(state, block, change, "bid_closed", bidEventDetails(change.key)); +} + +function applyLeaseCreated( + state: DeploymentAggState, + change: Extract, + block: AkashBlockChanges, + warnings: ReducerWarning[] +): void { + const bid = state.bids.find(candidate => sameLeaseKey(candidate, change.key)); + const group = state.groups.find(candidate => candidate.gseq === change.key.gseq); + if (!bid || !group) { + warnings.push({ code: "AKASH_ORPHAN_REFERENCE", kind: change.kind, owner: change.key.owner, dseq: change.key.dseq, height: block.height }); + } + + const { blockRate } = settleState(state, block, change); + const price = bid?.price ?? 0n; + const predicted = predictClosedHeight(block.height, state.balance, blockRate + price); + + const lease: LeaseAggState = { + gseq: change.key.gseq, + oseq: change.key.oseq, + bseq: change.key.bseq, + provider: change.key.provider, + price, + denom: state.denom, + balance: 0n, + withdrawn: 0n, + predictedClosedHeight: predicted, + createdHeight: block.height, + createdAt: block.datetime, + closedHeight: null, + closedAt: null, + ...sumResourceTotals(group?.resources ?? []) + }; + state.leases.push(lease); + + for (const openLease of state.leases.filter(candidate => candidate.closedHeight === null)) { + openLease.predictedClosedHeight = predicted; + } + + if (bid) { + bid.state = "active"; + } + + addEvent(state, block, change, "lease_created", bidEventDetails(change.key, bid ? decToString(bid.price) : undefined, bid?.denom)); +} + +function applyLeaseClosed(state: DeploymentAggState, change: Extract, block: AkashBlockChanges): void { + const lease = findOpenLease(state, change.key); + if (!lease) { + return; + } + closeLease(state, lease, change, block); + addEvent(state, block, change, "lease_closed", bidEventDetails(change.key)); +} + +function applyLeaseWithdrawn( + state: DeploymentAggState, + change: Extract, + block: AkashBlockChanges, + warnings: ReducerWarning[] +): void { + const lease = state.leases.find(candidate => sameLeaseKey(candidate, change.key)); + if (!lease) { + warnings.push({ code: "AKASH_ORPHAN_REFERENCE", kind: change.kind, owner: change.key.owner, dseq: change.key.dseq, height: block.height }); + return; + } + settleState(state, block, change); + withdrawFromLease(state, lease); + addEvent(state, block, change, "lease_withdrawn", bidEventDetails(change.key)); +} + +/** A lease closed by the chain without a direct message (its group closed, the order was revoked). Skipped when the close was already applied by the triggering message. */ +function applyLeaseClosedEvent(state: DeploymentAggState, change: Extract, block: AkashBlockChanges): void { + const lease = state.leases.find( + candidate => + candidate.closedHeight === null && + candidate.gseq === change.gseq && + candidate.oseq === change.oseq && + candidate.provider === change.provider && + (change.bseq === null || candidate.bseq === change.bseq) + ); + if (!lease) { + return; + } + closeLease(state, lease, change, block); + addEvent(state, block, change, "lease_closed", { + gseq: change.gseq, + oseq: change.oseq, + bseq: change.bseq ?? lease.bseq, + provider: change.provider + }); +} + +/** + * Shared close path for lease-terminating changes: settle first, pay out and close the lease, then + * re-predict the remaining leases at the reduced block rate. Unlike the legacy indexer, the + * deployment stays open when its last lease closes — the chain keeps the escrow account alive, and + * actual deployment closes always arrive as a message, an overdraw, or a close event. + */ +function closeLease(state: DeploymentAggState, lease: LeaseAggState, change: AkashChange, block: AkashBlockChanges): void { + const { blockRate } = settleState(state, block, change); + + if (lease.closedHeight === null) { + lease.closedHeight = block.height; + lease.closedAt = block.datetime; + payOutClosedLease(state, lease); + } + + if (state.closedHeight !== null) { + return; + } + + const remainingRate = blockRate - lease.price; + for (const openLease of state.leases.filter(candidate => candidate.closedHeight === null)) { + openLease.predictedClosedHeight = predictClosedHeight(state.lastWithdrawHeight ?? openLease.createdHeight, state.balance, remainingRate); + } +} + +/** Runs the escrow settlement and, when it overdraws, records the chain's forced close of the deployment and every open lease. */ +function settleState(state: DeploymentAggState, block: AkashBlockChanges, change: AkashChange): { blockRate: bigint } { + const openLeases = state.leases.filter(lease => lease.closedHeight === null); + const { blockRate, overdrawn } = settle(state, openLeases, block.height); + + if (overdrawn) { + for (const lease of openLeases) { + lease.closedAt = block.datetime; + payOutClosedLease(state, lease); + } + state.closedAt = block.datetime; + state.closeReason = "overdrawn"; + closeOpenBids(state, block.height); + addEvent(state, block, change, "closed", { reason: "overdrawn" }); + } + + return { blockRate }; +} + +/** + * Mirrors the keeper's payout on withdraw: the whole-unit part of the accrued balance moves to the + * lease's withdrawn total, the fraction stays accrued until the lease closes. + */ +function withdrawFromLease(state: DeploymentAggState, lease: LeaseAggState): void { + const paid = decFromInt(decTruncateInt(lease.balance)); + lease.balance -= paid; + lease.withdrawn += paid; + state.withdrawn += paid; +} + +/** On lease close the keeper pays out the whole units and refunds the fractional remainder to the account funds. */ +function payOutClosedLease(state: DeploymentAggState, lease: LeaseAggState): void { + withdrawFromLease(state, lease); + state.balance += lease.balance; + lease.balance = 0n; +} + +function closeDeployment(state: DeploymentAggState, block: AkashBlockChanges, reason: DeploymentCloseReason): void { + for (const lease of state.leases) { + if (lease.closedHeight === null) { + lease.closedHeight = block.height; + lease.closedAt = block.datetime; + payOutClosedLease(state, lease); + } + } + closeOpenBids(state, block.height); + state.closedHeight = block.height; + state.closedAt = block.datetime; + state.closeReason = reason; +} + +/** The chain closes a deployment's open bids with it; the legacy indexer deleted bid rows instead, so this is state the rewrite adds. */ +function closeOpenBids(state: DeploymentAggState, height: number): void { + for (const bid of state.bids) { + if (bid.state !== "closed") { + bid.state = "closed"; + bid.closedHeight = height; + } + } +} + +/** + * The legacy predicted-close formula, `base + ceil(balance / rate)`, on exact math. A zero rate means + * the balance never depletes; the prediction is pinned to the base height so draining queries treat + * the lease as expired rather than dividing by zero. + */ +function predictClosedHeight(baseHeight: number, balance: bigint, blockRate: bigint): bigint { + if (blockRate <= 0n) { + return BigInt(baseHeight); + } + return BigInt(baseHeight) + decCeilInt(decQuo(balance, blockRate)); +} + +function addEvent( + state: DeploymentAggState, + block: AkashBlockChanges, + change: AkashChange, + type: DeploymentEventType, + details: Record | null +): void { + const ordinal = state.events.filter(event => event.height === block.height).length; + state.events.push({ height: block.height, ordinal, txIndex: change.txIndex, msgIndex: change.msgIndex, type, details }); +} + +function bidEventDetails(key: { gseq: number; oseq: number; bseq: number; provider: string }, price?: string, denom?: string): Record { + return { + gseq: key.gseq, + oseq: key.oseq, + bseq: key.bseq, + provider: key.provider, + ...(price !== undefined ? { price } : {}), + ...(denom !== undefined && denom !== "" ? { denom } : {}) + }; +} + +function findOpenLease(state: DeploymentAggState, key: { gseq: number; oseq: number; bseq: number; provider: string }): LeaseAggState | undefined { + return state.leases.find(candidate => candidate.closedHeight === null && sameLeaseKey(candidate, key)); +} + +function sameLeaseKey( + a: { gseq: number; oseq: number; bseq: number; provider: string }, + b: { gseq: number; oseq: number; bseq: number; provider: string } +): boolean { + return a.gseq === b.gseq && a.oseq === b.oseq && a.bseq === b.bseq && a.provider === b.provider; +} + +function sumGroupTotals(groups: NormalizedGroup[]): ResourceTotals { + return groups.map(group => sumResourceTotals(group.resources)).reduce(addTotals, emptyTotals()); +} + +function sumResourceTotals(resources: NormalizedResource[]): ResourceTotals { + return resources + .map(resource => ({ + cpuUnits: resource.cpuUnits * resource.count, + gpuUnits: resource.gpuUnits * resource.count, + memoryBytes: resource.memoryBytes * resource.count, + ephemeralStorageBytes: resource.ephemeralStorageBytes * resource.count, + persistentStorageBytes: resource.persistentStorageBytes * resource.count + })) + .reduce(addTotals, emptyTotals()); +} + +function addTotals(a: ResourceTotals, b: ResourceTotals): ResourceTotals { + return { + cpuUnits: a.cpuUnits + b.cpuUnits, + gpuUnits: a.gpuUnits + b.gpuUnits, + memoryBytes: a.memoryBytes + b.memoryBytes, + ephemeralStorageBytes: a.ephemeralStorageBytes + b.ephemeralStorageBytes, + persistentStorageBytes: a.persistentStorageBytes + b.persistentStorageBytes + }; +} + +function emptyTotals(): ResourceTotals { + return { cpuUnits: 0, gpuUnits: 0, memoryBytes: 0, ephemeralStorageBytes: 0, persistentStorageBytes: 0 }; +} + +/** Bid prices are integer coins through v1beta2 and DecCoin decimal strings from v1beta3; a malformed price degrades to zero like the legacy `?? 0`. */ +function parsePrice(price: string): bigint { + try { + return decFromString(price); + } catch { + return 0n; + } +} diff --git a/apps/chain-indexer/src/akash/json.ts b/apps/chain-indexer/src/akash/json.ts new file mode 100644 index 0000000000..23067d5240 --- /dev/null +++ b/apps/chain-indexer/src/akash/json.ts @@ -0,0 +1,17 @@ +export function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : null; +} + +export function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +export function asInteger(value: unknown): number | null { + if (typeof value === "number" && Number.isInteger(value)) { + return value; + } + if (typeof value === "string" && /^\d+$/.test(value)) { + return Number(value); + } + return null; +} diff --git a/apps/chain-indexer/src/akash/normalize-deployment.spec.ts b/apps/chain-indexer/src/akash/normalize-deployment.spec.ts new file mode 100644 index 0000000000..fa12a8a28c --- /dev/null +++ b/apps/chain-indexer/src/akash/normalize-deployment.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeDeploymentMessage } from "@src/akash/normalize-deployment"; + +describe("normalizeDeploymentMessage", () => { + it("normalizes a legacy v1beta1 create with a Long dseq and a bare deposit coin", () => { + const change = normalizeDeploymentMessage("/akash.deployment.v1beta1.MsgCreateDeployment", { + id: { owner: "akash1owner", dseq: { low: 12345, high: 0, unsigned: true } }, + groups: [], + deposit: { denom: "uakt", amount: "5000000" } + }); + + expect(change).toEqual({ + kind: "deploymentCreated", + key: { owner: "akash1owner", dseq: "12345" }, + denom: "uakt", + deposit: "5000000", + depositor: null, + groups: [] + }); + }); + + it("normalizes a v1beta4 create whose deposit coin is wrapped in a Deposit message", () => { + const change = normalizeDeploymentMessage("/akash.deployment.v1beta4.MsgCreateDeployment", { + id: { owner: "akash1owner", dseq: "12345" }, + groups: [], + deposit: { amount: { denom: "ibc/170C677610AC31DF0904FFE09CD3B5C657492170E7E52372E48756B71E56F2F1", amount: "5000000" }, sources: [1] } + }) as { deposit: string; denom: string }; + + expect(change.deposit).toBe("5000000"); + expect(change.denom).toBe("ibc/170C677610AC31DF0904FFE09CD3B5C657492170E7E52372E48756B71E56F2F1"); + }); + + it("keeps the v1beta3 depositor on both create and deposit", () => { + const create = normalizeDeploymentMessage("/akash.deployment.v1beta3.MsgCreateDeployment", { + id: { owner: "akash1owner", dseq: "1" }, + groups: [], + deposit: { denom: "uakt", amount: "1" }, + depositor: "akash1other" + }) as { depositor: string }; + const deposit = normalizeDeploymentMessage("/akash.deployment.v1beta3.MsgDepositDeployment", { + id: { owner: "akash1owner", dseq: "1" }, + amount: { denom: "uakt", amount: "777" }, + depositor: "akash1other" + }); + + expect(create.depositor).toBe("akash1other"); + expect(deposit).toEqual({ + kind: "deploymentDeposited", + key: { owner: "akash1owner", dseq: "1" }, + amount: "777", + depositor: "akash1other" + }); + }); + + it("normalizes a v1 escrow deposit from its scoped xid", () => { + const change = normalizeDeploymentMessage("/akash.escrow.v1.MsgAccountDeposit", { + signer: "akash1depositor", + id: { scope: 1, xid: "akash1owner/12345" }, + deposit: { amount: { denom: "uakt", amount: "777" }, sources: [1] } + }); + + expect(change).toEqual({ + kind: "deploymentDeposited", + key: { owner: "akash1owner", dseq: "12345" }, + amount: "777", + depositor: "akash1depositor" + }); + }); + + it("ignores escrow deposits outside the deployment scope", () => { + const change = normalizeDeploymentMessage("/akash.escrow.v1.MsgAccountDeposit", { + signer: "akash1depositor", + id: { scope: 2, xid: "akash1owner/12345/1/1/akash1prov" }, + deposit: { amount: { denom: "uakt", amount: "777" }, sources: [1] } + }); + + expect(change).toBeNull(); + }); + + it("normalizes close, update and group lifecycle messages", () => { + const key = { owner: "akash1owner", dseq: "9" }; + + expect(normalizeDeploymentMessage("/akash.deployment.v1beta2.MsgCloseDeployment", { id: key })).toEqual({ kind: "deploymentClosed", key }); + expect(normalizeDeploymentMessage("/akash.deployment.v1beta4.MsgUpdateDeployment", { id: key })).toEqual({ kind: "deploymentUpdated", key }); + expect(normalizeDeploymentMessage("/akash.deployment.v1beta4.MsgCloseGroup", { id: { ...key, gseq: 2 } })).toEqual({ kind: "groupClosed", key, gseq: 2 }); + expect(normalizeDeploymentMessage("/akash.deployment.v1beta3.MsgPauseGroup", { id: { ...key, gseq: 1 } })).toEqual({ kind: "groupPaused", key, gseq: 1 }); + expect(normalizeDeploymentMessage("/akash.deployment.v1beta2.MsgStartGroup", { id: { ...key, gseq: 1 } })).toEqual({ kind: "groupStarted", key, gseq: 1 }); + }); + + it("returns null for unknown types and malformed bodies", () => { + expect(normalizeDeploymentMessage("/akash.deployment.v1beta1.MsgSomethingElse", {})).toBeNull(); + expect(normalizeDeploymentMessage("/akash.deployment.v1beta1.MsgCreateDeployment", { id: { owner: "" } })).toBeNull(); + expect(normalizeDeploymentMessage("/akash.deployment.v1beta1.MsgDepositDeployment", { id: { owner: "a", dseq: "1" } })).toBeNull(); + }); +}); diff --git a/apps/chain-indexer/src/akash/normalize-deployment.ts b/apps/chain-indexer/src/akash/normalize-deployment.ts new file mode 100644 index 0000000000..e601fe2f21 --- /dev/null +++ b/apps/chain-indexer/src/akash/normalize-deployment.ts @@ -0,0 +1,130 @@ +import type { AkashChangeBody, DeploymentKey } from "@src/akash/akash-changes"; +import { asInteger, asRecord, asString } from "@src/akash/json"; +import { normalizeGroups } from "@src/akash/resources"; +import { asUint64String } from "@src/akash/uint64"; + +type NormalizedChange = AkashChangeBody; + +const DEPLOYMENT_VERSIONS = ["v1beta1", "v1beta2", "v1beta3", "v1beta4"] as const; + +const CREATE_DEPLOYMENT = typeUrlSet("MsgCreateDeployment"); +const CLOSE_DEPLOYMENT = typeUrlSet("MsgCloseDeployment"); +const UPDATE_DEPLOYMENT = typeUrlSet("MsgUpdateDeployment"); +const DEPOSIT_DEPLOYMENT = typeUrlSet("MsgDepositDeployment", ["v1beta1", "v1beta2", "v1beta3"]); +const CLOSE_GROUP = typeUrlSet("MsgCloseGroup"); +const PAUSE_GROUP = typeUrlSet("MsgPauseGroup"); +const START_GROUP = typeUrlSet("MsgStartGroup"); +const ACCOUNT_DEPOSIT = "/akash.escrow.v1.MsgAccountDeposit"; + +function typeUrlSet(name: string, versions: readonly string[] = DEPLOYMENT_VERSIONS): Set { + return new Set(versions.map(version => `/akash.deployment.${version}.${name}`)); +} + +export function isDeploymentTypeUrl(typeUrl: string): boolean { + return ( + CREATE_DEPLOYMENT.has(typeUrl) || + CLOSE_DEPLOYMENT.has(typeUrl) || + UPDATE_DEPLOYMENT.has(typeUrl) || + DEPOSIT_DEPLOYMENT.has(typeUrl) || + CLOSE_GROUP.has(typeUrl) || + PAUSE_GROUP.has(typeUrl) || + START_GROUP.has(typeUrl) || + typeUrl === ACCOUNT_DEPOSIT + ); +} + +export function normalizeDeploymentMessage(typeUrl: string, body: Record): NormalizedChange | null { + if (CREATE_DEPLOYMENT.has(typeUrl)) { + return normalizeCreate(body); + } + if (CLOSE_DEPLOYMENT.has(typeUrl)) { + const key = deploymentKey(body.id); + return key ? { kind: "deploymentClosed", key } : null; + } + if (UPDATE_DEPLOYMENT.has(typeUrl)) { + const key = deploymentKey(body.id); + return key ? { kind: "deploymentUpdated", key } : null; + } + if (DEPOSIT_DEPLOYMENT.has(typeUrl)) { + return normalizeDeposit(body); + } + if (typeUrl === ACCOUNT_DEPOSIT) { + return normalizeAccountDeposit(body); + } + if (CLOSE_GROUP.has(typeUrl)) { + return normalizeGroupChange("groupClosed", body); + } + if (PAUSE_GROUP.has(typeUrl)) { + return normalizeGroupChange("groupPaused", body); + } + if (START_GROUP.has(typeUrl)) { + return normalizeGroupChange("groupStarted", body); + } + return null; +} + +function normalizeCreate(body: Record): NormalizedChange | null { + const key = deploymentKey(body.id); + if (!key) { + return null; + } + const coin = depositCoin(body.deposit); + return { + kind: "deploymentCreated", + key, + denom: coin?.denom ?? "uakt", + deposit: coin?.amount ?? "0", + depositor: asString(body.depositor), + groups: normalizeGroups(body.groups) + }; +} + +function normalizeDeposit(body: Record): NormalizedChange | null { + const key = deploymentKey(body.id); + const amount = asString(asRecord(body.amount)?.amount); + if (!key || !amount) { + return null; + } + return { kind: "deploymentDeposited", key, amount, depositor: asString(body.depositor) }; +} + +/** v1-era deposits target a generic escrow account: scope must be `deployment` (1) and `xid` is "owner/dseq". */ +function normalizeAccountDeposit(body: Record): NormalizedChange | null { + const id = asRecord(body.id); + const scope = id?.scope; + if (scope !== 1 && scope !== "deployment") { + return null; + } + const [owner, dseq] = asString(id?.xid)?.split("/") ?? []; + const amount = asString(asRecord(asRecord(body.deposit)?.amount)?.amount); + if (!owner || !dseq || !amount) { + return null; + } + return { kind: "deploymentDeposited", key: { owner, dseq }, amount, depositor: asString(body.signer) }; +} + +function normalizeGroupChange(kind: "groupClosed" | "groupPaused" | "groupStarted", body: Record): NormalizedChange | null { + const id = asRecord(body.id); + const key = deploymentKey(id); + const gseq = asInteger(id?.gseq); + return key && gseq !== null ? { kind, key, gseq } : null; +} + +/** v1beta4 wraps the deposit coin in a Deposit message (`deposit.amount`); earlier versions carry the coin directly. */ +function depositCoin(deposit: unknown): { denom: string; amount: string } | null { + const record = asRecord(deposit); + if (!record) { + return null; + } + const coin = asRecord(record.amount) ?? record; + const denom = asString(coin.denom); + const amount = asString(coin.amount); + return denom && amount ? { denom, amount } : null; +} + +export function deploymentKey(id: unknown): DeploymentKey | null { + const record = asRecord(id); + const owner = asString(record?.owner); + const dseq = asUint64String(record?.dseq); + return owner && dseq ? { owner, dseq } : null; +} diff --git a/apps/chain-indexer/src/akash/normalize-market.spec.ts b/apps/chain-indexer/src/akash/normalize-market.spec.ts new file mode 100644 index 0000000000..070ac0cdd9 --- /dev/null +++ b/apps/chain-indexer/src/akash/normalize-market.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeMarketMessage } from "@src/akash/normalize-market"; + +describe("normalizeMarketMessage", () => { + it("normalizes a legacy create bid from its order id and provider field with bseq 0", () => { + const change = normalizeMarketMessage("/akash.market.v1beta2.MsgCreateBid", { + order: { owner: "akash1owner", dseq: { low: 42, high: 0, unsigned: true }, gseq: 1, oseq: 1 }, + provider: "akash1prov", + price: { denom: "uakt", amount: "50" } + }); + + expect(change).toEqual({ + kind: "bidCreated", + key: { owner: "akash1owner", dseq: "42", gseq: 1, oseq: 1, bseq: 0, provider: "akash1prov" }, + price: "50", + priceDenom: "uakt" + }); + }); + + it("normalizes a v1beta5 create bid from its full BidID with bseq and a DecCoin price", () => { + const change = normalizeMarketMessage("/akash.market.v1beta5.MsgCreateBid", { + id: { owner: "akash1owner", dseq: "42", gseq: 1, oseq: 1, bseq: 2, provider: "akash1prov" }, + price: { denom: "uakt", amount: "3.25" } + }); + + expect(change).toEqual({ + kind: "bidCreated", + key: { owner: "akash1owner", dseq: "42", gseq: 1, oseq: 1, bseq: 2, provider: "akash1prov" }, + price: "3.25", + priceDenom: "uakt" + }); + }); + + it("normalizes close bid, lease lifecycle and withdraw across id field names", () => { + const key = { owner: "akash1owner", dseq: "42", gseq: 1, oseq: 1, bseq: 0, provider: "akash1prov" }; + const legacyId = { owner: "akash1owner", dseq: "42", gseq: 1, oseq: 1, provider: "akash1prov" }; + + expect(normalizeMarketMessage("/akash.market.v1beta3.MsgCloseBid", { bidId: legacyId })).toEqual({ kind: "bidClosed", key }); + expect(normalizeMarketMessage("/akash.market.v1beta5.MsgCloseBid", { id: { ...legacyId, bseq: 1 } })).toEqual({ + kind: "bidClosed", + key: { ...key, bseq: 1 } + }); + expect(normalizeMarketMessage("/akash.market.v1beta4.MsgCreateLease", { bidId: legacyId })).toEqual({ kind: "leaseCreated", key }); + expect(normalizeMarketMessage("/akash.market.v1beta1.MsgCloseLease", { leaseId: legacyId })).toEqual({ kind: "leaseClosed", key }); + expect(normalizeMarketMessage("/akash.market.v1beta5.MsgCloseLease", { id: legacyId })).toEqual({ kind: "leaseClosed", key }); + expect(normalizeMarketMessage("/akash.market.v1beta2.MsgWithdrawLease", { bidId: legacyId })).toEqual({ kind: "leaseWithdrawn", key }); + expect(normalizeMarketMessage("/akash.market.v1beta5.MsgWithdrawLease", { id: legacyId })).toEqual({ kind: "leaseWithdrawn", key }); + }); + + it("returns null for unknown types and incomplete ids", () => { + expect(normalizeMarketMessage("/akash.market.v1beta5.MsgLeaseStartReclaim", { id: {} })).toBeNull(); + expect(normalizeMarketMessage("/akash.market.v1beta1.MsgCreateBid", { order: { owner: "a" }, provider: "p" })).toBeNull(); + }); +}); diff --git a/apps/chain-indexer/src/akash/normalize-market.ts b/apps/chain-indexer/src/akash/normalize-market.ts new file mode 100644 index 0000000000..40b10d8979 --- /dev/null +++ b/apps/chain-indexer/src/akash/normalize-market.ts @@ -0,0 +1,71 @@ +import type { AkashChangeBody, LeaseKey } from "@src/akash/akash-changes"; +import { asInteger, asRecord, asString } from "@src/akash/json"; +import { deploymentKey } from "@src/akash/normalize-deployment"; + +type NormalizedChange = AkashChangeBody; + +const MARKET_VERSIONS = ["v1beta1", "v1beta2", "v1beta3", "v1beta4", "v1beta5"] as const; + +const CREATE_BID = typeUrlSet("MsgCreateBid"); +const CLOSE_BID = typeUrlSet("MsgCloseBid"); +const CREATE_LEASE = typeUrlSet("MsgCreateLease"); +const CLOSE_LEASE = typeUrlSet("MsgCloseLease"); +const WITHDRAW_LEASE = typeUrlSet("MsgWithdrawLease"); + +function typeUrlSet(name: string): Set { + return new Set(MARKET_VERSIONS.map(version => `/akash.market.${version}.${name}`)); +} + +export function isMarketTypeUrl(typeUrl: string): boolean { + return CREATE_BID.has(typeUrl) || CLOSE_BID.has(typeUrl) || CREATE_LEASE.has(typeUrl) || CLOSE_LEASE.has(typeUrl) || WITHDRAW_LEASE.has(typeUrl); +} + +export function normalizeMarketMessage(typeUrl: string, body: Record): NormalizedChange | null { + if (CREATE_BID.has(typeUrl)) { + return normalizeCreateBid(body); + } + if (CLOSE_BID.has(typeUrl)) { + const key = leaseKey(body.id ?? body.bidId); + return key ? { kind: "bidClosed", key } : null; + } + if (CREATE_LEASE.has(typeUrl)) { + const key = leaseKey(body.bidId); + return key ? { kind: "leaseCreated", key } : null; + } + if (CLOSE_LEASE.has(typeUrl)) { + const key = leaseKey(body.id ?? body.leaseId); + return key ? { kind: "leaseClosed", key } : null; + } + if (WITHDRAW_LEASE.has(typeUrl)) { + const key = leaseKey(body.id ?? body.bidId); + return key ? { kind: "leaseWithdrawn", key } : null; + } + return null; +} + +/** v1beta1–4 identify the bid by OrderID + a separate provider field; v1beta5 by a full BidID with bseq. */ +function normalizeCreateBid(body: Record): NormalizedChange | null { + const key = leaseKey(body.id) ?? leaseKey(body.order, asString(body.provider)); + if (!key) { + return null; + } + const price = asRecord(body.price); + return { + kind: "bidCreated", + key, + price: asString(price?.amount) ?? "0", + priceDenom: asString(price?.denom) ?? "" + }; +} + +function leaseKey(id: unknown, providerOverride?: string | null): LeaseKey | null { + const record = asRecord(id); + const base = deploymentKey(record); + const gseq = asInteger(record?.gseq); + const oseq = asInteger(record?.oseq); + const provider = providerOverride ?? asString(record?.provider); + if (!base || gseq === null || oseq === null || !provider) { + return null; + } + return { ...base, gseq, oseq, bseq: asInteger(record?.bseq) ?? 0, provider }; +} diff --git a/apps/chain-indexer/src/akash/resources.spec.ts b/apps/chain-indexer/src/akash/resources.spec.ts new file mode 100644 index 0000000000..56b716b105 --- /dev/null +++ b/apps/chain-indexer/src/akash/resources.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeGroups } from "@src/akash/resources"; + +const base64Digits = (digits: string) => Buffer.from(digits, "ascii").toString("base64"); + +describe("normalizeGroups", () => { + it("normalizes v1beta1 groups with base64 values and a single storage object", () => { + const groups = normalizeGroups([ + { + name: "g1", + resources: [ + { + resources: { + cpu: { units: { val: base64Digits("1000") }, attributes: [] }, + memory: { quantity: { val: base64Digits("536870912") } }, + storage: { quantity: { val: base64Digits("268435456") }, attributes: [] } + }, + count: 2, + price: { denom: "uakt", amount: "50" } + } + ] + } + ]); + + expect(groups).toEqual([ + { + gseq: 1, + resources: [ + { + count: 2, + cpuUnits: 1000, + gpuUnits: 0, + gpuVendor: null, + gpuModel: null, + memoryBytes: 536870912, + ephemeralStorageBytes: 268435456, + persistentStorageBytes: 0, + price: "50", + priceDenom: "uakt" + } + ] + } + ]); + }); + + it("splits v1beta2+ storage arrays into ephemeral and persistent by attribute", () => { + const groups = normalizeGroups([ + { + resources: [ + { + resources: { + cpu: { units: { val: base64Digits("100") } }, + memory: { quantity: { val: base64Digits("1024") } }, + storage: [ + { quantity: { val: base64Digits("100") }, attributes: [] }, + { quantity: { val: base64Digits("200") }, attributes: [{ key: "persistent", value: "true" }] }, + { quantity: { val: base64Digits("50") }, attributes: [{ key: "persistent", value: "false" }] } + ] + }, + count: 1, + price: { denom: "uakt", amount: "1" } + } + ] + } + ]); + + expect(groups[0].resources[0].ephemeralStorageBytes).toBe(150); + expect(groups[0].resources[0].persistentStorageBytes).toBe(200); + }); + + it("normalizes chain-sdk groups with digit-string values, gpu attributes and the `resource` key", () => { + const groups = normalizeGroups([ + { + resources: [ + { + resource: { + cpu: { units: { val: "1000" } }, + gpu: { units: { val: "1" }, attributes: [{ key: "vendor/nvidia/model/a100", value: "true" }] }, + memory: { quantity: { val: "536870912" } }, + storage: [{ quantity: { val: "268435456" }, attributes: [] }] + }, + count: 3, + price: { denom: "uakt", amount: "50.5" } + } + ] + } + ]); + + expect(groups[0].resources[0]).toEqual({ + count: 3, + cpuUnits: 1000, + gpuUnits: 1, + gpuVendor: "nvidia", + gpuModel: "a100", + memoryBytes: 536870912, + ephemeralStorageBytes: 268435456, + persistentStorageBytes: 0, + price: "50.5", + priceDenom: "uakt" + }); + }); + + it("treats a wildcard gpu model as any model", () => { + const groups = normalizeGroups([ + { + resources: [ + { + resource: { gpu: { units: { val: "2" }, attributes: [{ key: "vendor/nvidia/model/*", value: "true" }] } }, + count: 1, + price: { denom: "uakt", amount: "1" } + } + ] + } + ]); + + expect(groups[0].resources[0].gpuVendor).toBe("nvidia"); + expect(groups[0].resources[0].gpuModel).toBeNull(); + }); + + it("assigns gseq by position and tolerates malformed groups", () => { + const groups = normalizeGroups([{ resources: [] }, {}, null]); + + expect(groups.map(group => group.gseq)).toEqual([1, 2, 3]); + expect(groups.every(group => group.resources.length === 0)).toBe(true); + }); + + it("returns no groups for a non-array input", () => { + expect(normalizeGroups(undefined)).toEqual([]); + }); +}); diff --git a/apps/chain-indexer/src/akash/resources.ts b/apps/chain-indexer/src/akash/resources.ts new file mode 100644 index 0000000000..b9f813c882 --- /dev/null +++ b/apps/chain-indexer/src/akash/resources.ts @@ -0,0 +1,96 @@ +import type { NormalizedGroup, NormalizedResource } from "@src/akash/akash-changes"; +import { asRecord } from "@src/akash/json"; + +/** + * Normalizes the GroupSpec list of any deployment proto era to one shape. The differences are + * structural rather than semantic, so detection is shape-driven instead of version-driven: + * v1beta1/2 nest quantities under `resources`, v1beta3+ under `resource`; v1beta1 has a single + * storage object, later versions an array; the chain SDK decodes `ResourceValue.val` to a digit + * string while the legacy package leaves a Uint8Array of ASCII digits that canonical JSON stores + * as base64. GPU exists from v1beta3 and carries vendor/model as a `vendor//model/` attribute. + */ +export function normalizeGroups(groups: unknown): NormalizedGroup[] { + if (!Array.isArray(groups)) { + return []; + } + return groups.map((group, index) => ({ + gseq: index + 1, + resources: normalizeResources(asRecord(group)?.resources) + })); +} + +function normalizeResources(units: unknown): NormalizedResource[] { + if (!Array.isArray(units)) { + return []; + } + return units.map(unit => { + const unitRecord = asRecord(unit) ?? {}; + const quantities = asRecord(unitRecord.resource) ?? asRecord(unitRecord.resources) ?? {}; + const gpu = asRecord(quantities.gpu); + const { vendor, model } = gpuAttributes(gpu); + const price = asRecord(unitRecord.price); + const storage = storageEntries(quantities.storage); + + return { + count: typeof unitRecord.count === "number" ? unitRecord.count : 0, + cpuUnits: resourceValue(asRecord(quantities.cpu)?.units), + gpuUnits: resourceValue(gpu?.units), + gpuVendor: vendor, + gpuModel: model, + memoryBytes: resourceValue(asRecord(quantities.memory)?.quantity), + ephemeralStorageBytes: sumStorage(storage, entry => !isPersistentStorage(entry)), + persistentStorageBytes: sumStorage(storage, isPersistentStorage), + price: typeof price?.amount === "string" && price.amount.length > 0 ? price.amount : "0", + priceDenom: typeof price?.denom === "string" ? price.denom : "" + }; + }); +} + +function storageEntries(storage: unknown): Array> { + if (Array.isArray(storage)) { + return storage.map(entry => asRecord(entry) ?? {}); + } + const single = asRecord(storage); + return single ? [single] : []; +} + +function sumStorage(entries: Array>, predicate: (entry: Record) => boolean): number { + return entries.filter(predicate).reduce((sum, entry) => sum + resourceValue(asRecord(entry.quantity)), 0); +} + +function isPersistentStorage(storage: Record): boolean { + return attributeList(storage.attributes).some(attribute => attribute.key === "persistent" && attribute.value === "true"); +} + +/** GPU vendor/model come from a single `vendor//model/` attribute; a `*` model means any (mirrors the legacy parser). */ +function gpuAttributes(gpu: Record | null): { vendor: string | null; model: string | null } { + const attributes = attributeList(gpu?.attributes); + if (attributes.length !== 1 || attributes[0].value !== "true") { + return { vendor: null, model: null }; + } + const match = /^vendor\/(.*)\/model\/(.*)$/.exec(attributes[0].key); + if (!match) { + return { vendor: null, model: null }; + } + return { vendor: match[1], model: match[2] !== "*" ? match[2] : null }; +} + +function attributeList(attributes: unknown): Array<{ key: string; value: string }> { + if (!Array.isArray(attributes)) { + return []; + } + return attributes.flatMap(attribute => { + const record = asRecord(attribute); + return typeof record?.key === "string" && typeof record?.value === "string" ? [{ key: record.key, value: record.value }] : []; + }); +} + +function resourceValue(container: unknown): number { + const val = asRecord(container)?.val; + if (typeof val !== "string" || val.length === 0) { + return 0; + } + const digits = /^\d+$/.test(val) ? val : Buffer.from(val, "base64").toString("ascii"); + const parsed = Number(digits); + return Number.isFinite(parsed) ? parsed : 0; +} diff --git a/apps/chain-indexer/src/akash/settlement.spec.ts b/apps/chain-indexer/src/akash/settlement.spec.ts new file mode 100644 index 0000000000..dca7945119 --- /dev/null +++ b/apps/chain-indexer/src/akash/settlement.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { decFromInt, decFromString } from "@src/akash/dec"; +import type { SettlementDeployment, SettlementLease } from "@src/akash/settlement"; +import { settle } from "@src/akash/settlement"; + +describe("settle", () => { + it("accrues a single lease's earnings for the full height delta", () => { + const { deployment, leases } = setup({ balance: "1000000", leasePrices: ["10"], lastWithdrawHeight: 100 }); + + const result = settle(deployment, leases, 150); + + expect(result).toEqual({ blockRate: decFromInt(10), overdrawn: false }); + expect(deployment.balance).toBe(decFromInt(1000000 - 500)); + expect(deployment.lastWithdrawHeight).toBe(150); + expect(leases[0].balance).toBe(decFromInt(500)); + expect(leases[0].closedHeight).toBeNull(); + }); + + it("is a no-op when already settled at this height", () => { + const { deployment, leases } = setup({ balance: "1000", leasePrices: ["10"], lastWithdrawHeight: 150 }); + + const result = settle(deployment, leases, 150); + + expect(result).toEqual({ blockRate: decFromInt(10), overdrawn: false }); + expect(deployment.balance).toBe(decFromInt(1000)); + expect(leases[0].balance).toBe(0n); + }); + + it("only stamps the settlement height when no leases are open", () => { + const { deployment } = setup({ balance: "1000", leasePrices: [], lastWithdrawHeight: null }); + + const result = settle(deployment, [], 150); + + expect(result).toEqual({ blockRate: 0n, overdrawn: false }); + expect(deployment.lastWithdrawHeight).toBe(150); + expect(deployment.balance).toBe(decFromInt(1000)); + }); + + it("splits full-block accrual between leases at their own rates", () => { + const { deployment, leases } = setup({ balance: "10000", leasePrices: ["10", "30"], lastWithdrawHeight: 100 }); + + settle(deployment, leases, 110); + + expect(leases[0].balance).toBe(decFromInt(100)); + expect(leases[1].balance).toBe(decFromInt(300)); + expect(deployment.balance).toBe(decFromInt(10000 - 400)); + }); + + it("accrues fractional DecCoin rates exactly", () => { + const { deployment, leases } = setup({ balance: "1000", leasePrices: ["1.5"], lastWithdrawHeight: 0 }); + + settle(deployment, leases, 101); + + expect(leases[0].balance).toBe(decFromString("151.5")); + expect(deployment.balance).toBe(decFromString("848.5")); + }); + + it("distributes the remaining balance by rate weight and closes everything on overdraw", () => { + const { deployment, leases } = setup({ balance: "100", leasePrices: ["10", "30"], lastWithdrawHeight: 100 }); + + const result = settle(deployment, leases, 110); + + expect(result).toEqual({ blockRate: decFromInt(40), overdrawn: true }); + expect(leases[0].balance).toBe(decFromInt(20 + 5)); + expect(leases[1].balance).toBe(decFromInt(60 + 15)); + expect(deployment.balance).toBe(0n); + expect(deployment.closedHeight).toBe(110); + expect(deployment.lastWithdrawHeight).toBe(110); + expect(leases[0].closedHeight).toBe(110); + expect(leases[1].closedHeight).toBe(110); + }); + + it("leaves at most one unit of rounding dust on an overdraw with uneven weights", () => { + const { deployment, leases } = setup({ balance: "100", leasePrices: ["1", "1", "1"], lastWithdrawHeight: 0 }); + + const result = settle(deployment, leases, 1000); + + expect(result.overdrawn).toBe(true); + expect(deployment.balance).toBeGreaterThanOrEqual(0n); + expect(deployment.balance).toBeLessThanOrEqual(decFromInt(1)); + const totalAccrued = leases.reduce((sum, lease) => sum + lease.balance, 0n); + expect(totalAccrued + deployment.balance).toBe(decFromInt(100)); + }); + + it("matches a one-shot settlement when settled incrementally", () => { + const incremental = setup({ balance: "100000", leasePrices: ["7"], lastWithdrawHeight: 0 }); + const oneShot = setup({ balance: "100000", leasePrices: ["7"], lastWithdrawHeight: 0 }); + + settle(incremental.deployment, incremental.leases, 100); + settle(incremental.deployment, incremental.leases, 250); + settle(incremental.deployment, incremental.leases, 400); + settle(oneShot.deployment, oneShot.leases, 400); + + expect(incremental.deployment.balance).toBe(oneShot.deployment.balance); + expect(incremental.leases[0].balance).toBe(oneShot.leases[0].balance); + }); + + function setup(input: { balance: string; leasePrices: string[]; lastWithdrawHeight: number | null }) { + const deployment: SettlementDeployment = { + balance: decFromString(input.balance), + lastWithdrawHeight: input.lastWithdrawHeight, + closedHeight: null + }; + const leases: SettlementLease[] = input.leasePrices.map(price => ({ + price: decFromString(price), + balance: 0n, + closedHeight: null + })); + return { deployment, leases }; + } +}); diff --git a/apps/chain-indexer/src/akash/settlement.ts b/apps/chain-indexer/src/akash/settlement.ts new file mode 100644 index 0000000000..2875c7f773 --- /dev/null +++ b/apps/chain-indexer/src/akash/settlement.ts @@ -0,0 +1,78 @@ +import { decMul, decMulInt, decQuo, decTruncateInt, minBigInt } from "@src/akash/dec"; + +export interface SettlementDeployment { + /** Escrow funds in Dec atomics (10^-18 of the u-denom unit). */ + balance: bigint; + lastWithdrawHeight: number | null; + closedHeight: number | null; +} + +export interface SettlementLease { + /** Per-block rate in Dec atomics; fractional since v1beta3 bids price in DecCoin. */ + price: bigint; + /** Accrued-but-unwithdrawn earnings (the on-chain payment balance); payouts truncate from here. */ + balance: bigint; + closedHeight: number | null; +} + +export interface SettlementResult { + /** Sum of the open leases' per-block rates before any overdraw close, in Dec atomics. */ + blockRate: bigint; + overdrawn: boolean; +} + +/** Escrow accounts settle to at most 1 u-denom unit of rounding dust on an overdraw close. */ +const MAX_SETTLEMENT_DUST = 10n ** 18n; + +/** + * Port of akash-node x/escrow account settlement (x/escrow/keeper, accountSettle) on exact LegacyDec + * math. Mutates the passed state objects: moves the exact Dec accrual since the last settlement from + * the account funds into each open lease's unwithdrawn balance, and on overdraw distributes the + * remaining funds by rate weight and closes the deployment with all open leases. Payouts (which + * truncate to whole units, with the fraction refunded on lease close) are the caller's concern — + * settlement only accrues. + */ +export function settle(deployment: SettlementDeployment, openLeases: SettlementLease[], height: number): SettlementResult { + const blockRate = openLeases.reduce((sum, lease) => sum + lease.price, 0n); + + if (height === deployment.lastWithdrawHeight) return { blockRate, overdrawn: false }; + + const heightDelta = BigInt(height - (deployment.lastWithdrawHeight ?? 0)); + deployment.lastWithdrawHeight = height; + + if (openLeases.length === 0) return { blockRate: 0n, overdrawn: false }; + + const numFullBlocks = minBigInt(decTruncateInt(decQuo(deployment.balance, blockRate)), heightDelta); + + for (const lease of openLeases) { + lease.balance += decMulInt(lease.price, numFullBlocks); + } + deployment.balance -= decMulInt(blockRate, numFullBlocks); + + if (numFullBlocks === heightDelta) return { blockRate, overdrawn: false }; + + distributeWeighted(deployment, openLeases, blockRate, height); + return { blockRate, overdrawn: true }; +} + +function distributeWeighted(deployment: SettlementDeployment, openLeases: SettlementLease[], blockRate: bigint, height: number): void { + const remaining = deployment.balance; + let transferred = 0n; + + for (const lease of openLeases) { + const amount = decQuo(decMul(remaining, lease.price), blockRate); + lease.balance += amount; + transferred += amount; + } + + deployment.balance -= transferred; + + if (deployment.balance > MAX_SETTLEMENT_DUST) { + throw new Error(`Invalid settlement: ${deployment.balance} atomics remain after weighted distribution`); + } + + deployment.closedHeight = height; + for (const lease of openLeases) { + lease.closedHeight = height; + } +} diff --git a/apps/chain-indexer/src/akash/uint64.spec.ts b/apps/chain-indexer/src/akash/uint64.spec.ts new file mode 100644 index 0000000000..ba02df8089 --- /dev/null +++ b/apps/chain-indexer/src/akash/uint64.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { asUint64String } from "@src/akash/uint64"; + +describe("asUint64String", () => { + it("passes through digit strings from the chain SDK codegen", () => { + expect(asUint64String("12345")).toBe("12345"); + }); + + it("accepts plain numbers", () => { + expect(asUint64String(12345)).toBe("12345"); + }); + + it("recombines legacy protobufjs Long objects", () => { + expect(asUint64String({ low: 12345, high: 0, unsigned: true })).toBe("12345"); + expect(asUint64String({ low: -1, high: 0, unsigned: true })).toBe("4294967295"); + expect(asUint64String({ low: 0, high: 1, unsigned: true })).toBe("4294967296"); + }); + + it("rejects everything else", () => { + expect(asUint64String("12.5")).toBeNull(); + expect(asUint64String(null)).toBeNull(); + expect(asUint64String(undefined)).toBeNull(); + expect(asUint64String({})).toBeNull(); + }); +}); diff --git a/apps/chain-indexer/src/akash/uint64.ts b/apps/chain-indexer/src/akash/uint64.ts new file mode 100644 index 0000000000..e08f100a6d --- /dev/null +++ b/apps/chain-indexer/src/akash/uint64.ts @@ -0,0 +1,22 @@ +/** + * Uint64 fields reach canonical JSON in three shapes depending on the proto era: the chain SDK's + * patched codegen decodes them to bigint (serialized as a digit string), plain ts-proto uses + * number, and the frozen legacy @akashnetwork/akash-api decodes to a protobufjs Long, which + * JSON-serializes as its internal `{ low, high, unsigned }` fields. + */ +export function asUint64String(value: unknown): string | null { + if (typeof value === "string" && /^\d+$/.test(value)) { + return value; + } + if (typeof value === "number" && Number.isInteger(value) && value >= 0) { + return String(value); + } + if (isLongObject(value)) { + return ((BigInt(value.high >>> 0) << 32n) | BigInt(value.low >>> 0)).toString(); + } + return null; +} + +function isLongObject(value: unknown): value is { low: number; high: number } { + return typeof value === "object" && value !== null && "low" in value && "high" in value && typeof value.low === "number" && typeof value.high === "number"; +} diff --git a/apps/chain-indexer/src/db/schema.spec.ts b/apps/chain-indexer/src/db/schema.spec.ts index 562511bb2d..ef9abcd1a9 100644 --- a/apps/chain-indexer/src/db/schema.spec.ts +++ b/apps/chain-indexer/src/db/schema.spec.ts @@ -6,7 +6,13 @@ import { Accounts, AccountTxs, BalanceChanges, + Bids, Delegations, + DeploymentEvents, + DeploymentGroupResources, + DeploymentGroups, + Deployments, + Leases, MessageDeadLetters, ProposalDeposits, Proposals, @@ -120,3 +126,61 @@ describe("cosmos genesis schema", () => { expect(config.foreignKeys[0].reference().foreignColumns[0].name).toBe("id"); }); }); + +describe("akash deployment schema", () => { + it("keys deployments naturally by owner and dseq with denormalized resource totals", () => { + const config = getTableConfig(Deployments); + + expect(config.name).toBe("deployments"); + const ownerDseq = config.indexes.find(index => index.config.name === "deployments_owner_dseq_idx"); + expect(ownerDseq?.config.unique).toBe(true); + expect(config.columns.map(column => column.name)).toEqual( + expect.arrayContaining(["cpu_units", "gpu_units", "memory_bytes", "ephemeral_storage_bytes", "persistent_storage_bytes"]) + ); + }); + + it("tracks escrow state and the replay watermark on the deployment row", () => { + const config = getTableConfig(Deployments); + + expect(config.columns.map(column => column.name)).toEqual( + expect.arrayContaining(["deposit", "balance", "withdrawn_amount", "block_rate", "last_withdraw_height", "last_processed_height", "close_reason"]) + ); + }); + + it("keys groups by deployment and gseq", () => { + const config = getTableConfig(DeploymentGroups); + + const deploymentGseq = config.indexes.find(index => index.config.name === "deployment_groups_deployment_gseq_idx"); + expect(deploymentGseq?.config.unique).toBe(true); + expect(config.foreignKeys[0].reference().foreignColumns[0].name).toBe("id"); + }); + + it("keys group resources by group and position in the spec", () => { + const config = getTableConfig(DeploymentGroupResources); + + expect(config.primaryKeys[0].columns.map(column => column.name)).toEqual(["deployment_group_id", "idx"]); + }); + + it("keys bids by the full on-chain bid id and keeps them on close via state", () => { + const config = getTableConfig(Bids); + + expect(config.primaryKeys[0].columns.map(column => column.name)).toEqual(["deployment_id", "gseq", "oseq", "bseq", "provider_account_id"]); + expect(config.columns.map(column => column.name)).toContain("state"); + }); + + it("keys leases like bids and carries denormalized resource totals", () => { + const config = getTableConfig(Leases); + + expect(config.primaryKeys[0].columns.map(column => column.name)).toEqual(["deployment_id", "gseq", "oseq", "bseq", "provider_account_id"]); + expect(config.columns.map(column => column.name)).toEqual( + expect.arrayContaining(["predicted_closed_height", "withdrawn_amount", "cpu_units", "gpu_units", "memory_bytes"]) + ); + }); + + it("keys the timeline by deployment, height and ordinal so re-commits conflict instead of duplicating", () => { + const config = getTableConfig(DeploymentEvents); + + expect(config.primaryKeys[0].columns.map(column => column.name)).toEqual(["deployment_id", "height", "ordinal"]); + expect(config.columns.find(column => column.name === "tx_index")?.notNull).toBe(false); + }); +}); diff --git a/apps/chain-indexer/src/db/schema.ts b/apps/chain-indexer/src/db/schema.ts index 0ee2652e62..428259054a 100644 --- a/apps/chain-indexer/src/db/schema.ts +++ b/apps/chain-indexer/src/db/schema.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { bigint, bigserial, @@ -308,3 +309,199 @@ export const ProposalDeposits = cosmosSchema.table( }, t => [primaryKey({ columns: [t.proposalId, t.depositorAccountId, t.height] })] ); + +export const akashSchema = pgSchema("akash"); + +export const deploymentCloseReason = akashSchema.enum("deployment_close_reason", ["close_message", "overdrawn", "close_event"]); + +export const groupState = akashSchema.enum("group_state", ["open", "paused", "closed"]); + +/** `active` means the bid was accepted and became a lease; bids are kept on close (unlike the legacy indexer) so the deployment timeline can tell winning bids from losing ones. */ +export const bidState = akashSchema.enum("bid_state", ["open", "active", "closed"]); + +export const deploymentEventType = akashSchema.enum("deployment_event_type", [ + "created", + "deposited", + "updated", + "closed", + "group_closed", + "group_paused", + "group_started", + "bid_created", + "bid_closed", + "lease_created", + "lease_closed", + "lease_withdrawn" +]); + +/** + * Deployments carry denormalized resource totals (the sum over group resources of quantity × count) + * and the escrow account state, so list endpoints read one row instead of joining three levels deep. + * `balance`/`withdrawn_amount` are 18-decimal Dec values mirroring the on-chain escrow account; + * `last_withdraw_height` is the on-chain settlement checkpoint. `last_processed_height` is the + * indexer's replay watermark: blocks at or below it are duplicates and must not be re-applied, which + * — like the balance ledger — makes correctness depend on indexing a deployment's messages in height + * order from its creation (backfill from genesis, then sync). + */ +export const Deployments = akashSchema.table( + "deployments", + { + id: serial("id").primaryKey(), + ownerAccountId: integer("owner_account_id") + .notNull() + .references(() => Accounts.id), + dseq: numeric("dseq", { precision: 20, scale: 0 }).notNull(), + denom: text("denom").notNull(), + deposit: numeric("deposit", { precision: 38, scale: 0 }).notNull(), + balance: numeric("balance", { precision: 38, scale: 18 }).notNull(), + withdrawnAmount: numeric("withdrawn_amount", { precision: 38, scale: 18 }).notNull(), + blockRate: numeric("block_rate", { precision: 38, scale: 18 }).notNull().default("0"), + lastWithdrawHeight: bigint("last_withdraw_height", { mode: "number" }), + lastProcessedHeight: bigint("last_processed_height", { mode: "number" }).notNull(), + createdHeight: bigint("created_height", { mode: "number" }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + closedHeight: bigint("closed_height", { mode: "number" }), + closedAt: timestamp("closed_at", { withTimezone: true }), + closeReason: deploymentCloseReason("close_reason"), + cpuUnits: bigint("cpu_units", { mode: "number" }).notNull(), + gpuUnits: bigint("gpu_units", { mode: "number" }).notNull(), + memoryBytes: bigint("memory_bytes", { mode: "number" }).notNull(), + ephemeralStorageBytes: bigint("ephemeral_storage_bytes", { mode: "number" }).notNull(), + persistentStorageBytes: bigint("persistent_storage_bytes", { mode: "number" }).notNull() + }, + t => [ + uniqueIndex("deployments_owner_dseq_idx").on(t.ownerAccountId, t.dseq), + index("deployments_owner_created_idx").on(t.ownerAccountId, t.createdHeight), + index("deployments_open_idx") + .on(t.createdHeight) + .where(sql`${t.closedHeight} IS NULL`) + ] +); + +export const DeploymentGroups = akashSchema.table( + "deployment_groups", + { + id: serial("id").primaryKey(), + deploymentId: integer("deployment_id") + .notNull() + .references(() => Deployments.id), + gseq: integer("gseq").notNull(), + state: groupState("state").notNull().default("open"), + closedHeight: bigint("closed_height", { mode: "number" }) + }, + t => [uniqueIndex("deployment_groups_deployment_gseq_idx").on(t.deploymentId, t.gseq)] +); + +/** Immutable spec rows; `idx` is the resource's position in the on-chain GroupSpec resources array. */ +export const DeploymentGroupResources = akashSchema.table( + "deployment_group_resources", + { + deploymentGroupId: integer("deployment_group_id") + .notNull() + .references(() => DeploymentGroups.id), + idx: integer("idx").notNull(), + count: integer("count").notNull(), + cpuUnits: bigint("cpu_units", { mode: "number" }).notNull(), + gpuUnits: bigint("gpu_units", { mode: "number" }).notNull(), + gpuVendor: text("gpu_vendor"), + gpuModel: text("gpu_model"), + memoryBytes: bigint("memory_bytes", { mode: "number" }).notNull(), + ephemeralStorageBytes: bigint("ephemeral_storage_bytes", { mode: "number" }).notNull(), + persistentStorageBytes: bigint("persistent_storage_bytes", { mode: "number" }).notNull(), + price: numeric("price", { precision: 38, scale: 18 }).notNull(), + priceDenom: text("price_denom").notNull() + }, + t => [primaryKey({ columns: [t.deploymentGroupId, t.idx] })] +); + +export const Bids = akashSchema.table( + "bids", + { + deploymentId: integer("deployment_id") + .notNull() + .references(() => Deployments.id), + gseq: integer("gseq").notNull(), + oseq: integer("oseq").notNull(), + bseq: integer("bseq").notNull().default(0), + providerAccountId: integer("provider_account_id") + .notNull() + .references(() => Accounts.id), + price: numeric("price", { precision: 38, scale: 18 }).notNull(), + denom: text("denom").notNull(), + state: bidState("state").notNull().default("open"), + createdHeight: bigint("created_height", { mode: "number" }).notNull(), + closedHeight: bigint("closed_height", { mode: "number" }) + }, + t => [primaryKey({ columns: [t.deploymentId, t.gseq, t.oseq, t.bseq, t.providerAccountId] })] +); + +/** + * Leases carry the same denormalized resource totals as deployments (their group's quantity × count sums) + * so the per-block active-resource aggregation never joins group resources. `balance` is the accrued-but- + * unwithdrawn earnings mirroring the on-chain payment balance (payouts truncate to whole units and the + * fraction is refunded to the deployment on close); `predicted_closed_height` is the height at which the + * escrow balance runs out at the current block rate, recomputed on every balance- or rate-changing + * message, mirroring the legacy indexer's formulas. + */ +export const Leases = akashSchema.table( + "leases", + { + deploymentId: integer("deployment_id") + .notNull() + .references(() => Deployments.id), + deploymentGroupId: integer("deployment_group_id") + .notNull() + .references(() => DeploymentGroups.id), + gseq: integer("gseq").notNull(), + oseq: integer("oseq").notNull(), + bseq: integer("bseq").notNull().default(0), + providerAccountId: integer("provider_account_id") + .notNull() + .references(() => Accounts.id), + price: numeric("price", { precision: 38, scale: 18 }).notNull(), + denom: text("denom").notNull(), + balance: numeric("balance", { precision: 38, scale: 18 }).notNull().default("0"), + withdrawnAmount: numeric("withdrawn_amount", { precision: 38, scale: 18 }).notNull().default("0"), + predictedClosedHeight: numeric("predicted_closed_height", { precision: 30, scale: 0 }).notNull(), + createdHeight: bigint("created_height", { mode: "number" }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + closedHeight: bigint("closed_height", { mode: "number" }), + closedAt: timestamp("closed_at", { withTimezone: true }), + cpuUnits: bigint("cpu_units", { mode: "number" }).notNull(), + gpuUnits: bigint("gpu_units", { mode: "number" }).notNull(), + memoryBytes: bigint("memory_bytes", { mode: "number" }).notNull(), + ephemeralStorageBytes: bigint("ephemeral_storage_bytes", { mode: "number" }).notNull(), + persistentStorageBytes: bigint("persistent_storage_bytes", { mode: "number" }).notNull() + }, + t => [ + primaryKey({ columns: [t.deploymentId, t.gseq, t.oseq, t.bseq, t.providerAccountId] }), + index("leases_provider_idx").on(t.providerAccountId, t.closedHeight, t.createdHeight), + index("leases_open_idx") + .on(t.deploymentId) + .where(sql`${t.closedHeight} IS NULL`) + ] +); + +/** + * Typed per-deployment timeline replacing the legacy relatedMessages join. Every lifecycle change is + * stored, including withdrawals and losing bids — the legacy history view is a read-side filter, not a + * write-side decision. `ordinal` is the deterministic position of the deployment's events within the + * block, so re-committing a block conflicts instead of duplicating. `tx_index`/`msg_index` are null for + * events derived outside a message (e.g. close-event fallbacks); the tx hash comes from joining + * `cosmos.transactions`. + */ +export const DeploymentEvents = akashSchema.table( + "deployment_events", + { + deploymentId: integer("deployment_id") + .notNull() + .references(() => Deployments.id), + height: bigint("height", { mode: "number" }).notNull(), + ordinal: integer("ordinal").notNull(), + txIndex: integer("tx_index"), + msgIndex: integer("msg_index"), + type: deploymentEventType("type").notNull(), + details: jsonb("details") + }, + t => [primaryKey({ columns: [t.deploymentId, t.height, t.ordinal] })] +); 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 edf0407cbd..b1e32e3c35 100644 --- a/apps/chain-indexer/src/pipeline/block-committer.service.spec.ts +++ b/apps/chain-indexer/src/pipeline/block-committer.service.spec.ts @@ -3,6 +3,7 @@ import { PgDialect } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; import { mock } from "vitest-mock-extended"; +import type { AkashWriter } from "@src/akash/akash-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"; @@ -236,6 +237,24 @@ describe(BlockCommitterService.name, () => { const order = insertedRows.map(call => call.table); expect(order.indexOf(AccountTxs)).toBeLessThan(order.indexOf(IndexerState)); }); + + it("hands the akash writer the derived changes and interns the addresses they reference", async () => { + const { committer, akashWriter } = 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(akashWriter.write).toHaveBeenCalledWith( + expect.anything(), + [expect.objectContaining({ height: 10, changes: [expect.objectContaining({ kind: "deploymentClosedEvent" })] })], + expect.any(Map) + ); + const accountIds = akashWriter.write.mock.calls[0][2]; + expect(accountIds.get("akash1owner")).toBeDefined(); + }); }); function setup(input?: { @@ -287,9 +306,10 @@ describe(BlockCommitterService.name, () => { }); const govWriter = mock(); + const akashWriter = mock(); const logger = mock(); - const committer = new BlockCommitterService(dbFake as unknown as ChainDatabase, interner, balanceWriter, govWriter, logger); - return { committer, insertedRows, conflictUpdates, deletions, interner, balanceWriter, govWriter, logger }; + const committer = new BlockCommitterService(dbFake as unknown as ChainDatabase, interner, balanceWriter, govWriter, akashWriter, logger); + return { committer, insertedRows, conflictUpdates, deletions, interner, balanceWriter, govWriter, akashWriter, 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 4f438abf4e..d9568d28a8 100644 --- a/apps/chain-indexer/src/pipeline/block-committer.service.ts +++ b/apps/chain-indexer/src/pipeline/block-committer.service.ts @@ -2,6 +2,10 @@ import { and, between, inArray, isNull, sql } from "drizzle-orm"; import chunk from "lodash/chunk"; import { inject, singleton } from "tsyringe"; +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 { 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"; @@ -42,6 +46,7 @@ export class BlockCommitterService { readonly #interner: AccountInterner; readonly #balanceWriter: BalanceWriter; readonly #govWriter: GovWriter; + readonly #akashWriter: AkashWriter; readonly #logger: LoggerService; readonly #moduleRegistry = buildModuleAddressRegistry(); readonly #typeIds = new Map(); @@ -51,12 +56,14 @@ export class BlockCommitterService { @inject(AccountInterner) interner: AccountInterner, @inject(BalanceWriter) balanceWriter: BalanceWriter, @inject(GovWriter) govWriter: GovWriter, + @inject(AkashWriter) akashWriter: AkashWriter, @inject(LoggerService) logger: LoggerService ) { this.#db = db; this.#interner = interner; this.#balanceWriter = balanceWriter; this.#govWriter = govWriter; + this.#akashWriter = akashWriter; this.#logger = logger; this.#logger.setContext("COMMITTER"); } @@ -124,7 +131,8 @@ export class BlockCommitterService { const balanceChanges = blocks.flatMap(block => deriveBalanceChanges(block, this.#moduleRegistry)); const accountTxs = blocks.flatMap(block => deriveAccountTxs(block)); - const accountIds = await this.#internAccounts(balanceChanges, accountTxs); + const akashChanges = blocks.map(block => deriveAkashChanges(block)); + const accountIds = await this.#internAccounts(balanceChanges, accountTxs, akashChanges); const balanceIntents = this.#resolveBalanceChanges(balanceChanges, accountIds); const accountTxRows = this.#resolveAccountTxs(accountTxs, accountIds); @@ -139,6 +147,7 @@ export class BlockCommitterService { await this.#balanceWriter.write(tx, balanceIntents); await insertChunked(tx, AccountTxs, accountTxRows); await this.#govWriter.writeForBlocks(tx, blocks, accountIds); + await this.#akashWriter.write(tx, akashChanges, accountIds); await tx .insert(IndexerState) @@ -242,11 +251,15 @@ export class BlockCommitterService { } /** - * Interns every address the batch touches — spenders, receivers, correlated counterparties and tx signers — - * on the base connection before the commit transaction, so the ledger and activity rows can reference their - * account ids by foreign key. + * Interns every address the batch touches — spenders, receivers, correlated counterparties, tx signers, + * and the deployment owners/providers/depositors of the akash changes — on the base connection before + * the commit transaction, so the derived rows can reference their account ids by foreign key. */ - async #internAccounts(balanceChanges: DerivedBalanceChange[], accountTxs: DerivedAccountTx[]): Promise> { + async #internAccounts( + balanceChanges: DerivedBalanceChange[], + accountTxs: DerivedAccountTx[], + akashChanges: AkashBlockChanges[] + ): Promise> { const addresses = new Set(); for (const change of balanceChanges) { @@ -258,6 +271,9 @@ export class BlockCommitterService { for (const row of accountTxs) { addresses.add(row.address); } + for (const address of collectAkashAddresses(akashChanges)) { + addresses.add(address); + } return this.#interner.resolve(addresses); } diff --git a/apps/chain-indexer/src/pipeline/block-decoder.service.spec.ts b/apps/chain-indexer/src/pipeline/block-decoder.service.spec.ts index bb47b9234f..ce62247cae 100644 --- a/apps/chain-indexer/src/pipeline/block-decoder.service.spec.ts +++ b/apps/chain-indexer/src/pipeline/block-decoder.service.spec.ts @@ -1,5 +1,6 @@ import { Registry } from "@cosmjs/proto-signing"; import { defaultRegistryTypes } from "@cosmjs/stargate"; +import { MsgExec } from "cosmjs-types/cosmos/authz/v1beta1/tx"; import { MsgSend } from "cosmjs-types/cosmos/bank/v1beta1/tx"; import { AuthInfo, TxBody, TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; import { createHash } from "node:crypto"; @@ -166,6 +167,59 @@ describe(BlockDecoderService.name, () => { expect(tx.events.map(e => e.type)).toEqual(["coin_spent", "coin_received"]); }); + it("captures akash close events for the deployment handler", () => { + const { decoder } = setup(); + const rawTx = buildMsgSendTx(); + const txResult: RpcTxResult = { + code: 0, + gas_used: "0", + gas_wanted: "0", + events: [ + event("akash.v1", { action: "deployment-closed", owner: "akash1owner", dseq: "3" }), + event("akash.deployment.v1.EventDeploymentClosed", { id: '{"owner":"akash1owner","dseq":"3"}' }), + event("akash.market.v1.EventLeaseClosed", { id: '{"owner":"akash1owner","dseq":"3","gseq":1,"oseq":1,"provider":"akash1prov"}' }) + ] + }; + + const [tx] = decoder.decode(buildBlock({ txs: [rawTx.toString("base64")] }), buildBlockResults([txResult])).transactions; + + expect(tx.events.map(e => e.type)).toEqual(["akash.v1", "akash.deployment.v1.EventDeploymentClosed", "akash.market.v1.EventLeaseClosed"]); + }); + + it("additively decodes MsgExec inner messages, recursing into nested execs", () => { + const { decoder } = setup(); + const innerExec = MsgExec.encode( + MsgExec.fromPartial({ grantee: "akash1inner", msgs: [{ typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: encodeMsgSendValue() }] }) + ).finish(); + const outerExec = MsgExec.encode( + MsgExec.fromPartial({ grantee: "akash1outer", msgs: [{ typeUrl: "/cosmos.authz.v1beta1.MsgExec", value: innerExec }] }) + ).finish(); + const rawTx = buildMsgSendTx("/cosmos.authz.v1beta1.MsgExec", outerExec); + + const decoded = decoder.decode(buildBlock({ txs: [rawTx.toString("base64")] }), buildBlockResults([{ code: 0, gas_used: "0", gas_wanted: "0" }])); + + const body = decoded.transactions[0].messages[0].body as { + msgs: Array<{ typeUrl: string; value: string; decoded: { msgs: Array<{ decoded: unknown }> } }>; + }; + expect(body.msgs[0].typeUrl).toBe("/cosmos.authz.v1beta1.MsgExec"); + expect(body.msgs[0].value).toBe(Buffer.from(innerExec).toString("base64")); + expect(body.msgs[0].decoded.msgs[0].decoded).toMatchObject({ fromAddress: "akash1from", toAddress: "akash1to" }); + }); + + it("marks undecodable MsgExec inner messages with a null decoded field without failing the exec", () => { + const { decoder } = setup(); + const exec = MsgExec.encode( + MsgExec.fromPartial({ grantee: "akash1outer", msgs: [{ typeUrl: "/akash.unknown.v1.MsgMystery", value: new Uint8Array([1, 2, 3]) }] }) + ).finish(); + const rawTx = buildMsgSendTx("/cosmos.authz.v1beta1.MsgExec", exec); + + const decoded = decoder.decode(buildBlock({ txs: [rawTx.toString("base64")] }), buildBlockResults([{ code: 0, gas_used: "0", gas_wanted: "0" }])); + + const [message] = decoded.transactions[0].messages; + expect(message.decodeFailure).toBeUndefined(); + expect((message.body as { msgs: Array<{ decoded: unknown }> }).msgs[0].decoded).toBeNull(); + }); + it("prefers finalize_block_events for block-level events", () => { const { decoder } = setup(); diff --git a/apps/chain-indexer/src/pipeline/block-decoder.service.ts b/apps/chain-indexer/src/pipeline/block-decoder.service.ts index 16fc23c5bd..af747d1bd4 100644 --- a/apps/chain-indexer/src/pipeline/block-decoder.service.ts +++ b/apps/chain-indexer/src/pipeline/block-decoder.service.ts @@ -16,8 +16,10 @@ import type { RpcBlockResult, RpcBlockResultsResult, RpcEvent, RpcTxResult } fro /** * The ledger derives balances and reasons from the coin/transfer/mint/burn/slash events; the gov events carry - * proposal ids and lifecycle transitions for the governance handler. Capturing the rest would waste memory - * across backfill batches. + * proposal ids and lifecycle transitions for the governance handler; the akash events catch deployment and + * lease closes that happen as side effects of other messages (group close, overdraw) — `akash.v1` is the + * legacy string-attribute event of early mainnet, the typed events are the current chain's. Capturing the + * rest would waste memory across backfill batches. */ const RELEVANT_EVENT_TYPES = new Set([ "coin_spent", @@ -28,11 +30,19 @@ const RELEVANT_EVENT_TYPES = new Set([ "slash", "submit_proposal", "active_proposal", - "inactive_proposal" + "inactive_proposal", + "akash.v1", + "akash.deployment.v1.EventDeploymentClosed", + "akash.market.v1.EventLeaseClosed" ]); const MSG_INDEX_ATTRIBUTE = "msg_index"; +const MSG_EXEC_TYPE_URL = "/cosmos.authz.v1beta1.MsgExec"; + +/** MsgExec nested in MsgExec is legal on-chain; two levels covers every observed use without unbounded recursion. */ +const MAX_EXEC_DECODE_DEPTH = 2; + @singleton() export class BlockDecoderService { readonly #registry: Registry; @@ -125,9 +135,37 @@ export class BlockDecoderService { } try { - return { body: toCanonicalJson(this.#registry.decode(message), this.#maxBodyBytes) }; + const decoded = this.#registry.decode(message); + const enriched = message.typeUrl === MSG_EXEC_TYPE_URL ? this.#decodeExecMessages(decoded, 1) : decoded; + return { body: toCanonicalJson(enriched, this.#maxBodyBytes) }; } catch (error) { return { body: null, failure: { raw: message.value, error: error instanceof Error ? error.message : String(error) } }; } } + + /** + * MsgExec carries its inner messages as Any, which canonical JSON would keep as opaque base64. Each + * inner message additionally gets a `decoded` field so handlers can read authz-wrapped messages + * (every managed-wallet deployment arrives this way). The raw `typeUrl`/`value` pair is kept, and an + * inner message that fails to decode gets `decoded: null` rather than dead-lettering the whole exec. + */ + #decodeExecMessages(decoded: unknown, depth: number): unknown { + const record = decoded as { msgs?: Array<{ typeUrl: string; value: Uint8Array }> }; + if (!Array.isArray(record.msgs)) { + return decoded; + } + + const msgs = record.msgs.map(inner => { + try { + const innerDecoded = isIgnoredTypeUrl(inner.typeUrl) ? null : this.#registry.decode(inner); + const enriched = + inner.typeUrl === MSG_EXEC_TYPE_URL && depth < MAX_EXEC_DECODE_DEPTH ? this.#decodeExecMessages(innerDecoded, depth + 1) : innerDecoded; + return { ...inner, decoded: enriched }; + } catch { + return { ...inner, decoded: null }; + } + }); + + return { ...record, msgs }; + } } From 69a159abd5579570a1555956bb37f7307759f018 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:29:03 +0530 Subject: [PATCH 2/4] fix(indexer): harden akash settlement and identity parsing from review Address the CodeRabbit review on the deployment/market handlers: - settlement: guard a zero total block rate before dividing, so a degraded or orphan zero-price lease no longer aborts the block commit - settlement: bound the post-distribution dust check by absolute value and include the height, so a small negative residual can't slip past - json: asInteger now rejects negative and non-safe integers - uint64: asUint64String validates the uint64 range, rejects unsafe numbers and malformed Long halves, and normalizes digit strings - denom: look up the mapping through a Map so a denom like "constructor" degrades to unmapped instead of resolving a prototype member - schema: widen deployments.id and deployment_groups.id to bigserial with bigint foreign keys, so the guarded upserts cannot exhaust an int4 sequence; migration 0006 regenerated - writer: group child rows by deployment once in loadStates instead of re-scanning every child table per deployment Adds unit coverage for the zero-rate, orphan-lease, negative-decimal, uint64-validation, escrow string-scope, and empty-returning re-select paths. --- ...red_turbo.sql => 0006_next_earthquake.sql} | 16 ++-- .../drizzle/meta/0006_snapshot.json | 18 ++--- apps/chain-indexer/drizzle/meta/_journal.json | 4 +- .../src/akash/akash-writer.service.spec.ts | 22 +++++- .../src/akash/akash-writer.service.ts | 77 ++++++++++--------- apps/chain-indexer/src/akash/dec.spec.ts | 10 +++ apps/chain-indexer/src/akash/denom.ts | 14 ++-- .../src/akash/deployment-reducer.spec.ts | 26 +++++++ apps/chain-indexer/src/akash/json.spec.ts | 28 +++++++ apps/chain-indexer/src/akash/json.ts | 7 +- .../src/akash/normalize-deployment.spec.ts | 15 ++++ .../src/akash/settlement.spec.ts | 12 +++ apps/chain-indexer/src/akash/settlement.ts | 7 +- apps/chain-indexer/src/akash/uint64.spec.ts | 20 +++++ apps/chain-indexer/src/akash/uint64.ts | 14 +++- apps/chain-indexer/src/db/schema.ts | 16 ++-- 16 files changed, 225 insertions(+), 81 deletions(-) rename apps/chain-indexer/drizzle/{0006_tired_turbo.sql => 0006_next_earthquake.sql} (95%) create mode 100644 apps/chain-indexer/src/akash/json.spec.ts diff --git a/apps/chain-indexer/drizzle/0006_tired_turbo.sql b/apps/chain-indexer/drizzle/0006_next_earthquake.sql similarity index 95% rename from apps/chain-indexer/drizzle/0006_tired_turbo.sql rename to apps/chain-indexer/drizzle/0006_next_earthquake.sql index 4d2b14d699..4a978882db 100644 --- a/apps/chain-indexer/drizzle/0006_tired_turbo.sql +++ b/apps/chain-indexer/drizzle/0006_next_earthquake.sql @@ -5,7 +5,7 @@ CREATE TYPE "akash"."deployment_close_reason" AS ENUM('close_message', 'overdraw CREATE TYPE "akash"."deployment_event_type" AS ENUM('created', 'deposited', 'updated', 'closed', 'group_closed', 'group_paused', 'group_started', 'bid_created', 'bid_closed', 'lease_created', 'lease_closed', 'lease_withdrawn');--> statement-breakpoint CREATE TYPE "akash"."group_state" AS ENUM('open', 'paused', 'closed');--> statement-breakpoint CREATE TABLE "akash"."bids" ( - "deployment_id" integer NOT NULL, + "deployment_id" bigint NOT NULL, "gseq" integer NOT NULL, "oseq" integer NOT NULL, "bseq" integer DEFAULT 0 NOT NULL, @@ -19,7 +19,7 @@ CREATE TABLE "akash"."bids" ( ); --> statement-breakpoint CREATE TABLE "akash"."deployment_events" ( - "deployment_id" integer NOT NULL, + "deployment_id" bigint NOT NULL, "height" bigint NOT NULL, "ordinal" integer NOT NULL, "tx_index" integer, @@ -30,7 +30,7 @@ CREATE TABLE "akash"."deployment_events" ( ); --> statement-breakpoint CREATE TABLE "akash"."deployment_group_resources" ( - "deployment_group_id" integer NOT NULL, + "deployment_group_id" bigint NOT NULL, "idx" integer NOT NULL, "count" integer NOT NULL, "cpu_units" bigint NOT NULL, @@ -46,15 +46,15 @@ CREATE TABLE "akash"."deployment_group_resources" ( ); --> statement-breakpoint CREATE TABLE "akash"."deployment_groups" ( - "id" serial PRIMARY KEY NOT NULL, - "deployment_id" integer NOT NULL, + "id" bigserial PRIMARY KEY NOT NULL, + "deployment_id" bigint NOT NULL, "gseq" integer NOT NULL, "state" "akash"."group_state" DEFAULT 'open' NOT NULL, "closed_height" bigint ); --> statement-breakpoint CREATE TABLE "akash"."deployments" ( - "id" serial PRIMARY KEY NOT NULL, + "id" bigserial PRIMARY KEY NOT NULL, "owner_account_id" integer NOT NULL, "dseq" numeric(20, 0) NOT NULL, "denom" text NOT NULL, @@ -77,8 +77,8 @@ CREATE TABLE "akash"."deployments" ( ); --> statement-breakpoint CREATE TABLE "akash"."leases" ( - "deployment_id" integer NOT NULL, - "deployment_group_id" integer NOT NULL, + "deployment_id" bigint NOT NULL, + "deployment_group_id" bigint NOT NULL, "gseq" integer NOT NULL, "oseq" integer NOT NULL, "bseq" integer DEFAULT 0 NOT NULL, diff --git a/apps/chain-indexer/drizzle/meta/0006_snapshot.json b/apps/chain-indexer/drizzle/meta/0006_snapshot.json index eb3295617f..564f8d90b4 100644 --- a/apps/chain-indexer/drizzle/meta/0006_snapshot.json +++ b/apps/chain-indexer/drizzle/meta/0006_snapshot.json @@ -1,5 +1,5 @@ { - "id": "c46fb3b3-a7c9-40e6-aee5-060eb257da81", + "id": "7703e3c3-9988-452e-ae30-44e28b863168", "prevId": "4cf348d1-418b-4014-bbd3-4ffed0fa4e97", "version": "7", "dialect": "postgresql", @@ -339,7 +339,7 @@ "columns": { "deployment_id": { "name": "deployment_id", - "type": "integer", + "type": "bigint", "primaryKey": false, "notNull": true }, @@ -558,7 +558,7 @@ "columns": { "deployment_id": { "name": "deployment_id", - "type": "integer", + "type": "bigint", "primaryKey": false, "notNull": true }, @@ -638,7 +638,7 @@ "columns": { "deployment_group_id": { "name": "deployment_group_id", - "type": "integer", + "type": "bigint", "primaryKey": false, "notNull": true }, @@ -746,13 +746,13 @@ "columns": { "id": { "name": "id", - "type": "serial", + "type": "bigserial", "primaryKey": true, "notNull": true }, "deployment_id": { "name": "deployment_id", - "type": "integer", + "type": "bigint", "primaryKey": false, "notNull": true }, @@ -828,7 +828,7 @@ "columns": { "id": { "name": "id", - "type": "serial", + "type": "bigserial", "primaryKey": true, "notNull": true }, @@ -1068,13 +1068,13 @@ "columns": { "deployment_id": { "name": "deployment_id", - "type": "integer", + "type": "bigint", "primaryKey": false, "notNull": true }, "deployment_group_id": { "name": "deployment_group_id", - "type": "integer", + "type": "bigint", "primaryKey": false, "notNull": true }, diff --git a/apps/chain-indexer/drizzle/meta/_journal.json b/apps/chain-indexer/drizzle/meta/_journal.json index 477b602fc4..eda4b50e95 100644 --- a/apps/chain-indexer/drizzle/meta/_journal.json +++ b/apps/chain-indexer/drizzle/meta/_journal.json @@ -47,8 +47,8 @@ { "idx": 6, "version": "7", - "when": 1786866393419, - "tag": "0006_tired_turbo", + "when": 1786877216729, + "tag": "0006_next_earthquake", "breakpoints": true } ] 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 fbdc26d3a7..2f332f3321 100644 --- a/apps/chain-indexer/src/akash/akash-writer.service.spec.ts +++ b/apps/chain-indexer/src/akash/akash-writer.service.spec.ts @@ -118,16 +118,27 @@ describe(AkashWriter.name, () => { expect(lease).toMatchObject({ withdrawnAmount: "400" }); }); + it("re-selects the deployment id when a guarded upsert returns no row", async () => { + const { writer, tx, inserts } = setup({ returningEmpty: true }); + + await writer.write(tx, [block(100, [create(), bidCreated("10")]), block(110, [{ kind: "leaseCreated", key: LEASE_KEY }])], ACCOUNT_IDS); + + const [lease] = rowsFor(inserts, Leases); + expect(lease).toMatchObject({ deploymentId: 1 }); + }); + function setup(input?: { deployments?: Record[]; groups?: Record[]; bids?: Record[]; leases?: Record[]; + returningEmpty?: boolean; }) { const inserts: { table: unknown; rows: Record[] }[] = []; const upserts: { table: unknown; config: Record }[] = []; const selects: unknown[] = []; let nextId = 1; + let deploymentSelects = 0; const deployments = input?.deployments ?? []; const rowsByTable = new Map[]>([ @@ -138,6 +149,15 @@ describe(AkashWriter.name, () => { ]); const selectChain = (table: unknown) => { + if (table === Deployments) { + deploymentSelects++; + if (input?.returningEmpty && deploymentSelects === 2) { + rowsByTable.set( + Deployments, + rowsFor(inserts, Deployments).map((row, index) => ({ id: index + 1, ...row })) + ); + } + } const rows = rowsByTable.get(table) ?? providerAccountRows(table); const chain = { where: () => chain, @@ -162,7 +182,7 @@ describe(AkashWriter.name, () => { values: (rows: Record | Record[]) => { const rowArray = Array.isArray(rows) ? rows : [rows]; inserts.push({ table, rows: rowArray }); - const returning = () => Promise.resolve(rowArray.map(row => ({ id: nextId++, ...row }))); + const returning = () => Promise.resolve(input?.returningEmpty && table === Deployments ? [] : rowArray.map(row => ({ id: nextId++, ...row }))); return Object.assign(Promise.resolve(), { returning, onConflictDoNothing: () => Object.assign(Promise.resolve(), { returning }), diff --git a/apps/chain-indexer/src/akash/akash-writer.service.ts b/apps/chain-indexer/src/akash/akash-writer.service.ts index a6da791652..2b183c9483 100644 --- a/apps/chain-indexer/src/akash/akash-writer.service.ts +++ b/apps/chain-indexer/src/akash/akash-writer.service.ts @@ -1,4 +1,5 @@ import { and, eq, inArray, or, sql } from "drizzle-orm"; +import groupBy from "lodash/groupBy"; import { inject, singleton } from "tsyringe"; import type { AkashBlockChanges, DeploymentKey, NormalizedResource } from "@src/akash/akash-changes"; @@ -115,6 +116,11 @@ export class AkashWriter { loadedAddressIds.set(address, id); } + const groupsByDeployment = groupBy(groupRows, row => row.deploymentId); + const resourcesByGroup = groupBy(resourceRows, entry => `${entry.deploymentId}/${entry.gseq}`); + const bidsByDeployment = groupBy(bidRows, row => row.deploymentId); + const leasesByDeployment = groupBy(leaseRows, row => row.deploymentId); + for (const row of deploymentRows) { const key = keyByOwnerDseq.get(`${row.ownerAccountId}/${normalizeDseq(row.dseq)}`); if (!key) { @@ -122,7 +128,7 @@ export class AkashWriter { } deploymentIds.set(stateKey(key), row.id); - const groups = groupRows.filter(group => group.deploymentId === row.id); + const groups = groupsByDeployment[row.id] ?? []; for (const group of groups) { groupIds.set(`${row.id}/${group.gseq}`, group.id); } @@ -149,46 +155,41 @@ export class AkashWriter { gseq: group.gseq, state: group.state as GroupStateValue, closedHeight: group.closedHeight, - resources: resourceRows - .filter(entry => entry.deploymentId === row.id && entry.gseq === group.gseq) + resources: (resourcesByGroup[`${row.id}/${group.gseq}`] ?? []) .sort((a, b) => a.resource.idx - b.resource.idx) .map(entry => toNormalizedResource(entry.resource)) })), - bids: bidRows - .filter(bid => bid.deploymentId === row.id) - .map(bid => ({ - gseq: bid.gseq, - oseq: bid.oseq, - bseq: bid.bseq, - provider: this.#requireAddress(providerAddressById, bid.providerAccountId), - price: decFromString(bid.price), - denom: bid.denom, - state: bid.state as BidStateValue, - createdHeight: bid.createdHeight, - closedHeight: bid.closedHeight - })), - leases: leaseRows - .filter(lease => lease.deploymentId === row.id) - .map(lease => ({ - gseq: lease.gseq, - oseq: lease.oseq, - bseq: lease.bseq, - provider: this.#requireAddress(providerAddressById, lease.providerAccountId), - price: decFromString(lease.price), - denom: lease.denom, - balance: decFromString(lease.balance), - withdrawn: decFromString(lease.withdrawnAmount), - predictedClosedHeight: BigInt(lease.predictedClosedHeight), - createdHeight: lease.createdHeight, - createdAt: lease.createdAt, - closedHeight: lease.closedHeight, - closedAt: lease.closedAt, - cpuUnits: lease.cpuUnits, - gpuUnits: lease.gpuUnits, - memoryBytes: lease.memoryBytes, - ephemeralStorageBytes: lease.ephemeralStorageBytes, - persistentStorageBytes: lease.persistentStorageBytes - })), + bids: (bidsByDeployment[row.id] ?? []).map(bid => ({ + gseq: bid.gseq, + oseq: bid.oseq, + bseq: bid.bseq, + provider: this.#requireAddress(providerAddressById, bid.providerAccountId), + price: decFromString(bid.price), + denom: bid.denom, + state: bid.state as BidStateValue, + createdHeight: bid.createdHeight, + closedHeight: bid.closedHeight + })), + leases: (leasesByDeployment[row.id] ?? []).map(lease => ({ + gseq: lease.gseq, + oseq: lease.oseq, + bseq: lease.bseq, + provider: this.#requireAddress(providerAddressById, lease.providerAccountId), + price: decFromString(lease.price), + denom: lease.denom, + balance: decFromString(lease.balance), + withdrawn: decFromString(lease.withdrawnAmount), + predictedClosedHeight: BigInt(lease.predictedClosedHeight), + createdHeight: lease.createdHeight, + createdAt: lease.createdAt, + closedHeight: lease.closedHeight, + closedAt: lease.closedAt, + cpuUnits: lease.cpuUnits, + gpuUnits: lease.gpuUnits, + memoryBytes: lease.memoryBytes, + ephemeralStorageBytes: lease.ephemeralStorageBytes, + persistentStorageBytes: lease.persistentStorageBytes + })), events: [], isNew: false, touched: false diff --git a/apps/chain-indexer/src/akash/dec.spec.ts b/apps/chain-indexer/src/akash/dec.spec.ts index 53d86c9569..81fa65abcb 100644 --- a/apps/chain-indexer/src/akash/dec.spec.ts +++ b/apps/chain-indexer/src/akash/dec.spec.ts @@ -48,6 +48,10 @@ describe("dec", () => { expect(decQuo(decFromInt(2), decFromInt(3))).toBe(666_666_666_666_666_667n); }); + it("rounds negative quotients half away from zero", () => { + expect(decQuo(decFromInt(-2), decFromInt(3))).toBe(-666_666_666_666_666_667n); + }); + it("divides exactly when no remainder exists", () => { expect(decQuo(decFromInt(10), decFromInt(4))).toBe(decFromString("2.5")); }); @@ -78,6 +82,7 @@ describe("dec", () => { it("truncates toward zero", () => { expect(decTruncateInt(decFromString("2.9"))).toBe(2n); expect(decTruncateInt(decFromString("2"))).toBe(2n); + expect(decTruncateInt(decFromString("-2.9"))).toBe(-2n); }); }); @@ -86,6 +91,11 @@ describe("dec", () => { expect(decCeilInt(decFromString("2.000000000000000001"))).toBe(3n); expect(decCeilInt(decFromString("2"))).toBe(2n); }); + + it("ceils negatives toward positive infinity", () => { + expect(decCeilInt(decFromString("-2.5"))).toBe(-2n); + expect(decCeilInt(decFromString("-2"))).toBe(-2n); + }); }); describe("minBigInt", () => { diff --git a/apps/chain-indexer/src/akash/denom.ts b/apps/chain-indexer/src/akash/denom.ts index c18879df4b..84227d9441 100644 --- a/apps/chain-indexer/src/akash/denom.ts +++ b/apps/chain-indexer/src/akash/denom.ts @@ -1,16 +1,16 @@ /** The IBC denoms deployments are funded with, mapped to their base denom (mirrors the legacy indexer's mapping). */ -const DENOM_MAPPING: Record = { - uakt: "uakt", - uact: "uact", - "ibc/028CD1864059EEFB48A6048376165318E3E82C234390AE5A6D7B22001725B06E": "uusdc", - "ibc/170C677610AC31DF0904FFE09CD3B5C657492170E7E52372E48756B71E56F2F1": "uusdc" -}; +const DENOM_MAPPING = new Map([ + ["uakt", "uakt"], + ["uact", "uact"], + ["ibc/028CD1864059EEFB48A6048376165318E3E82C234390AE5A6D7B22001725B06E", "uusdc"], + ["ibc/170C677610AC31DF0904FFE09CD3B5C657492170E7E52372E48756B71E56F2F1", "uusdc"] +]); /** * Unknown denoms are stored raw instead of throwing (the legacy indexer aborts the block), so a new * funding denom degrades to an unmapped row rather than halting ingestion. */ export function normalizeDenom(denom: string): { denom: string; known: boolean } { - const mapped = DENOM_MAPPING[denom]; + const mapped = DENOM_MAPPING.get(denom); return mapped ? { denom: mapped, known: true } : { denom, known: false }; } diff --git a/apps/chain-indexer/src/akash/deployment-reducer.spec.ts b/apps/chain-indexer/src/akash/deployment-reducer.spec.ts index dbc10083a5..e0cee90db1 100644 --- a/apps/chain-indexer/src/akash/deployment-reducer.spec.ts +++ b/apps/chain-indexer/src/akash/deployment-reducer.spec.ts @@ -213,6 +213,32 @@ describe("applyBlockChanges", () => { ]); }); + it("tolerates a zero-rate lease from an unparseable bid price without dividing by zero", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({ deposit: "1000" }), bidCreated("not-a-number")])); + applyBlockChanges(states, block(110, [leaseCreated()])); + + expect(() => applyBlockChanges(states, block(150, [{ kind: "leaseWithdrawn", key: LEASE_KEY }]))).not.toThrow(); + + const state = get(states); + expect(state.leases[0].price).toBe(0n); + expect(state.leases[0].withdrawn).toBe(0n); + expect(state.balance).toBe(decFromInt(1000)); + expect(state.closedHeight).toBeNull(); + }); + + it("warns and creates a zero-rate lease when the matching bid is missing", () => { + const { states } = setup(); + + applyBlockChanges(states, block(100, [create({})])); + const warnings = applyBlockChanges(states, block(110, [leaseCreated()])); + + expect(warnings).toEqual([{ code: "AKASH_ORPHAN_REFERENCE", kind: "leaseCreated", owner: OWNER, dseq: "42", height: 110 }]); + expect(get(states).leases).toHaveLength(1); + expect(get(states).leases[0].price).toBe(0n); + }); + function setup() { return { states: new Map() }; } diff --git a/apps/chain-indexer/src/akash/json.spec.ts b/apps/chain-indexer/src/akash/json.spec.ts new file mode 100644 index 0000000000..1ee49ef8f0 --- /dev/null +++ b/apps/chain-indexer/src/akash/json.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { asInteger } from "@src/akash/json"; + +describe("asInteger", () => { + it("accepts non-negative safe integers and unsigned digit strings", () => { + expect(asInteger(0)).toBe(0); + expect(asInteger(42)).toBe(42); + expect(asInteger("42")).toBe(42); + }); + + it("rejects negative numbers and negative strings", () => { + expect(asInteger(-1)).toBeNull(); + expect(asInteger("-1")).toBeNull(); + }); + + it("rejects unsafe integers and non-integers", () => { + expect(asInteger(2 ** 53)).toBeNull(); + expect(asInteger(1.5)).toBeNull(); + expect(asInteger("99999999999999999999")).toBeNull(); + }); + + it("rejects non-numeric values", () => { + expect(asInteger("abc")).toBeNull(); + expect(asInteger(null)).toBeNull(); + expect(asInteger(undefined)).toBeNull(); + }); +}); diff --git a/apps/chain-indexer/src/akash/json.ts b/apps/chain-indexer/src/akash/json.ts index 23067d5240..360239ed32 100644 --- a/apps/chain-indexer/src/akash/json.ts +++ b/apps/chain-indexer/src/akash/json.ts @@ -7,11 +7,12 @@ export function asString(value: unknown): string | null { } export function asInteger(value: unknown): number | null { - if (typeof value === "number" && Number.isInteger(value)) { - return value; + if (typeof value === "number") { + return Number.isSafeInteger(value) && value >= 0 ? value : null; } if (typeof value === "string" && /^\d+$/.test(value)) { - return Number(value); + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; } return null; } diff --git a/apps/chain-indexer/src/akash/normalize-deployment.spec.ts b/apps/chain-indexer/src/akash/normalize-deployment.spec.ts index fa12a8a28c..18aa2790d7 100644 --- a/apps/chain-indexer/src/akash/normalize-deployment.spec.ts +++ b/apps/chain-indexer/src/akash/normalize-deployment.spec.ts @@ -68,6 +68,21 @@ describe("normalizeDeploymentMessage", () => { }); }); + it("normalizes a v1 escrow deposit whose scope is the string form", () => { + const change = normalizeDeploymentMessage("/akash.escrow.v1.MsgAccountDeposit", { + signer: "akash1depositor", + id: { scope: "deployment", xid: "akash1owner/12345" }, + deposit: { amount: { denom: "uakt", amount: "777" }, sources: [1] } + }); + + expect(change).toEqual({ + kind: "deploymentDeposited", + key: { owner: "akash1owner", dseq: "12345" }, + amount: "777", + depositor: "akash1depositor" + }); + }); + it("ignores escrow deposits outside the deployment scope", () => { const change = normalizeDeploymentMessage("/akash.escrow.v1.MsgAccountDeposit", { signer: "akash1depositor", diff --git a/apps/chain-indexer/src/akash/settlement.spec.ts b/apps/chain-indexer/src/akash/settlement.spec.ts index dca7945119..de983fd958 100644 --- a/apps/chain-indexer/src/akash/settlement.spec.ts +++ b/apps/chain-indexer/src/akash/settlement.spec.ts @@ -83,6 +83,18 @@ describe("settle", () => { expect(totalAccrued + deployment.balance).toBe(decFromInt(100)); }); + it("accrues nothing and cannot overdraw when every open lease has a zero rate", () => { + const { deployment, leases } = setup({ balance: "1000", leasePrices: ["0"], lastWithdrawHeight: 100 }); + + const result = settle(deployment, leases, 150); + + expect(result).toEqual({ blockRate: 0n, overdrawn: false }); + expect(deployment.balance).toBe(decFromInt(1000)); + expect(deployment.lastWithdrawHeight).toBe(150); + expect(leases[0].balance).toBe(0n); + expect(deployment.closedHeight).toBeNull(); + }); + it("matches a one-shot settlement when settled incrementally", () => { const incremental = setup({ balance: "100000", leasePrices: ["7"], lastWithdrawHeight: 0 }); const oneShot = setup({ balance: "100000", leasePrices: ["7"], lastWithdrawHeight: 0 }); diff --git a/apps/chain-indexer/src/akash/settlement.ts b/apps/chain-indexer/src/akash/settlement.ts index 2875c7f773..d74333068f 100644 --- a/apps/chain-indexer/src/akash/settlement.ts +++ b/apps/chain-indexer/src/akash/settlement.ts @@ -42,6 +42,8 @@ export function settle(deployment: SettlementDeployment, openLeases: SettlementL if (openLeases.length === 0) return { blockRate: 0n, overdrawn: false }; + if (blockRate <= 0n) return { blockRate, overdrawn: false }; + const numFullBlocks = minBigInt(decTruncateInt(decQuo(deployment.balance, blockRate)), heightDelta); for (const lease of openLeases) { @@ -67,8 +69,9 @@ function distributeWeighted(deployment: SettlementDeployment, openLeases: Settle deployment.balance -= transferred; - if (deployment.balance > MAX_SETTLEMENT_DUST) { - throw new Error(`Invalid settlement: ${deployment.balance} atomics remain after weighted distribution`); + const dust = deployment.balance < 0n ? -deployment.balance : deployment.balance; + if (dust > MAX_SETTLEMENT_DUST) { + throw new Error(`Invalid settlement at height ${height}: ${deployment.balance} atomics remain after weighted distribution`); } deployment.closedHeight = height; diff --git a/apps/chain-indexer/src/akash/uint64.spec.ts b/apps/chain-indexer/src/akash/uint64.spec.ts index ba02df8089..e7907d6977 100644 --- a/apps/chain-indexer/src/akash/uint64.spec.ts +++ b/apps/chain-indexer/src/akash/uint64.spec.ts @@ -17,6 +17,26 @@ describe("asUint64String", () => { expect(asUint64String({ low: 0, high: 1, unsigned: true })).toBe("4294967296"); }); + it("normalizes digit strings through BigInt, stripping leading zeros", () => { + expect(asUint64String("007")).toBe("7"); + expect(asUint64String("18446744073709551615")).toBe("18446744073709551615"); + }); + + it("rejects strings above the uint64 range", () => { + expect(asUint64String("18446744073709551616")).toBeNull(); + }); + + it("rejects unsafe, negative and fractional numbers", () => { + expect(asUint64String(2 ** 53)).toBeNull(); + expect(asUint64String(-1)).toBeNull(); + expect(asUint64String(1.5)).toBeNull(); + }); + + it("rejects Long objects whose halves are non-integer or outside 32 bits", () => { + expect(asUint64String({ low: 1.5, high: 0, unsigned: true })).toBeNull(); + expect(asUint64String({ low: 5_000_000_000, high: 0, unsigned: true })).toBeNull(); + }); + it("rejects everything else", () => { expect(asUint64String("12.5")).toBeNull(); expect(asUint64String(null)).toBeNull(); diff --git a/apps/chain-indexer/src/akash/uint64.ts b/apps/chain-indexer/src/akash/uint64.ts index e08f100a6d..4e285cee3b 100644 --- a/apps/chain-indexer/src/akash/uint64.ts +++ b/apps/chain-indexer/src/akash/uint64.ts @@ -4,11 +4,14 @@ * number, and the frozen legacy @akashnetwork/akash-api decodes to a protobufjs Long, which * JSON-serializes as its internal `{ low, high, unsigned }` fields. */ +const UINT64_MAX = 2n ** 64n - 1n; + export function asUint64String(value: unknown): string | null { if (typeof value === "string" && /^\d+$/.test(value)) { - return value; + const parsed = BigInt(value); + return parsed <= UINT64_MAX ? parsed.toString() : null; } - if (typeof value === "number" && Number.isInteger(value) && value >= 0) { + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { return String(value); } if (isLongObject(value)) { @@ -17,6 +20,11 @@ export function asUint64String(value: unknown): string | null { return null; } +/** protobufjs stores each half as a signed 32-bit int, so accept the full 32-bit range; anything wider would silently truncate under `>>> 0`. */ +function isInt32Half(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= -2_147_483_648 && value <= 4_294_967_295; +} + function isLongObject(value: unknown): value is { low: number; high: number } { - return typeof value === "object" && value !== null && "low" in value && "high" in value && typeof value.low === "number" && typeof value.high === "number"; + return typeof value === "object" && value !== null && "low" in value && "high" in value && isInt32Half(value.low) && isInt32Half(value.high); } diff --git a/apps/chain-indexer/src/db/schema.ts b/apps/chain-indexer/src/db/schema.ts index 428259054a..2290be387f 100644 --- a/apps/chain-indexer/src/db/schema.ts +++ b/apps/chain-indexer/src/db/schema.ts @@ -346,7 +346,7 @@ export const deploymentEventType = akashSchema.enum("deployment_event_type", [ export const Deployments = akashSchema.table( "deployments", { - id: serial("id").primaryKey(), + id: bigserial("id", { mode: "number" }).primaryKey(), ownerAccountId: integer("owner_account_id") .notNull() .references(() => Accounts.id), @@ -381,8 +381,8 @@ export const Deployments = akashSchema.table( export const DeploymentGroups = akashSchema.table( "deployment_groups", { - id: serial("id").primaryKey(), - deploymentId: integer("deployment_id") + id: bigserial("id", { mode: "number" }).primaryKey(), + deploymentId: bigint("deployment_id", { mode: "number" }) .notNull() .references(() => Deployments.id), gseq: integer("gseq").notNull(), @@ -396,7 +396,7 @@ export const DeploymentGroups = akashSchema.table( export const DeploymentGroupResources = akashSchema.table( "deployment_group_resources", { - deploymentGroupId: integer("deployment_group_id") + deploymentGroupId: bigint("deployment_group_id", { mode: "number" }) .notNull() .references(() => DeploymentGroups.id), idx: integer("idx").notNull(), @@ -417,7 +417,7 @@ export const DeploymentGroupResources = akashSchema.table( export const Bids = akashSchema.table( "bids", { - deploymentId: integer("deployment_id") + deploymentId: bigint("deployment_id", { mode: "number" }) .notNull() .references(() => Deployments.id), gseq: integer("gseq").notNull(), @@ -446,10 +446,10 @@ export const Bids = akashSchema.table( export const Leases = akashSchema.table( "leases", { - deploymentId: integer("deployment_id") + deploymentId: bigint("deployment_id", { mode: "number" }) .notNull() .references(() => Deployments.id), - deploymentGroupId: integer("deployment_group_id") + deploymentGroupId: bigint("deployment_group_id", { mode: "number" }) .notNull() .references(() => DeploymentGroups.id), gseq: integer("gseq").notNull(), @@ -493,7 +493,7 @@ export const Leases = akashSchema.table( export const DeploymentEvents = akashSchema.table( "deployment_events", { - deploymentId: integer("deployment_id") + deploymentId: bigint("deployment_id", { mode: "number" }) .notNull() .references(() => Deployments.id), height: bigint("height", { mode: "number" }).notNull(), From 195ec8ac541682f3bdf408672e502737bee1d555 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:33:41 +0530 Subject: [PATCH 3/4] fix(indexer): close losing bids on lease creation and tidy akash handlers From the two-axis code review of the deployment/market handlers. Behavioral fix: creating a lease matches and closes its order on-chain, so the order's other still-open bids are now closed at that height. Previously they stayed "open" until the whole deployment closed. This is bid-table accuracy only; the event timeline is unchanged. Behavior-preserving cleanups from the standards axis: - share MSG_EXEC_TYPE_URL and the exec-depth cap between the block decoder and the akash deriver (new src/pipeline/msg-exec.ts) - dispatch via normalizeDeployment ?? normalizeMarket and drop the now redundant isDeploymentTypeUrl / isMarketTypeUrl predicates - extract sumLeaseRate() for the repeated open-lease block-rate sum - extract ownerDseqKey() for the repeated owner/dseq map key in the writer - name the {gseq, oseq, bseq, provider} clump as LeaseSlot - share akashTypeUrlSet() across the deployment and market normalizers - drop the redundant NormalizedChange type alias --- apps/chain-indexer/src/akash/akash-changes.ts | 8 +++++ apps/chain-indexer/src/akash/akash-deriver.ts | 15 +++------- .../src/akash/akash-writer.service.ts | 20 ++++++++----- .../src/akash/deployment-reducer.spec.ts | 12 ++++++++ .../src/akash/deployment-reducer.ts | 27 +++++++++++------ .../src/akash/normalize-deployment.ts | 29 +++++-------------- .../src/akash/normalize-market.ts | 14 +++------ apps/chain-indexer/src/akash/settlement.ts | 7 ++++- .../src/pipeline/block-decoder.service.ts | 9 ++---- apps/chain-indexer/src/pipeline/msg-exec.ts | 5 ++++ 10 files changed, 79 insertions(+), 67 deletions(-) create mode 100644 apps/chain-indexer/src/pipeline/msg-exec.ts diff --git a/apps/chain-indexer/src/akash/akash-changes.ts b/apps/chain-indexer/src/akash/akash-changes.ts index 3730f9c802..ad34fe836c 100644 --- a/apps/chain-indexer/src/akash/akash-changes.ts +++ b/apps/chain-indexer/src/akash/akash-changes.ts @@ -10,6 +10,14 @@ export interface LeaseKey extends DeploymentKey { provider: string; } +/** A lease/bid's identity within its deployment: the order (gseq, oseq) and the specific bid (bseq, provider), without the owner/dseq. */ +export type LeaseSlot = Pick; + +/** The set of versioned type URLs for one akash message across proto eras, e.g. `/akash.market.v1beta5.MsgCreateBid`. */ +export function akashTypeUrlSet(module: string, name: string, versions: readonly string[]): Set { + return new Set(versions.map(version => `/akash.${module}.${version}.${name}`)); +} + export interface NormalizedResource { count: number; cpuUnits: number; diff --git a/apps/chain-indexer/src/akash/akash-deriver.ts b/apps/chain-indexer/src/akash/akash-deriver.ts index 9a8526d3f0..6f600ce495 100644 --- a/apps/chain-indexer/src/akash/akash-deriver.ts +++ b/apps/chain-indexer/src/akash/akash-deriver.ts @@ -1,13 +1,10 @@ import type { AkashBlockChanges, AkashChange, AkashChangeBody } from "@src/akash/akash-changes"; import { asInteger, asRecord, asString } from "@src/akash/json"; -import { isDeploymentTypeUrl, normalizeDeploymentMessage } from "@src/akash/normalize-deployment"; -import { isMarketTypeUrl, normalizeMarketMessage } from "@src/akash/normalize-market"; +import { normalizeDeploymentMessage } from "@src/akash/normalize-deployment"; +import { normalizeMarketMessage } from "@src/akash/normalize-market"; import { asUint64String } from "@src/akash/uint64"; import type { DecodedBlock, DecodedEvent } from "@src/pipeline/decoded-block"; - -const MSG_EXEC_TYPE_URL = "/cosmos.authz.v1beta1.MsgExec"; - -const MAX_EXEC_DEPTH = 2; +import { MAX_EXEC_DEPTH, MSG_EXEC_TYPE_URL } from "@src/pipeline/msg-exec"; const LEGACY_EVENT_TYPE = "akash.v1"; const DEPLOYMENT_CLOSED_EVENT_TYPE = "akash.deployment.v1.EventDeploymentClosed"; @@ -59,11 +56,7 @@ function addMessage(changes: AkashChange[], typeUrl: string, body: unknown, txIn return; } - const normalized = isDeploymentTypeUrl(typeUrl) - ? normalizeDeploymentMessage(typeUrl, record) - : isMarketTypeUrl(typeUrl) - ? normalizeMarketMessage(typeUrl, record) - : null; + const normalized = normalizeDeploymentMessage(typeUrl, record) ?? normalizeMarketMessage(typeUrl, record); if (normalized) { changes.push({ ...normalized, txIndex, msgIndex }); diff --git a/apps/chain-indexer/src/akash/akash-writer.service.ts b/apps/chain-indexer/src/akash/akash-writer.service.ts index 2b183c9483..1471145dd1 100644 --- a/apps/chain-indexer/src/akash/akash-writer.service.ts +++ b/apps/chain-indexer/src/akash/akash-writer.service.ts @@ -6,6 +6,7 @@ import type { AkashBlockChanges, DeploymentKey, NormalizedResource } from "@src/ 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"; +import { sumLeaseRate } from "@src/akash/settlement"; import { insertChunked } from "@src/db/insert-chunked"; import { Accounts, Bids, DeploymentEvents, DeploymentGroupResources, DeploymentGroups, Deployments, Leases } from "@src/db/schema"; import { sqlExcluded } from "@src/db/sql-excluded"; @@ -95,7 +96,7 @@ export class AkashWriter { return { states, deploymentIds, groupIds, loadedAddressIds }; } - const keyByOwnerDseq = new Map(keyed.map(entry => [`${entry.ownerAccountId}/${normalizeDseq(entry.key.dseq)}`, entry.key])); + const keyByOwnerDseq = new Map(keyed.map(entry => [ownerDseqKey(entry.ownerAccountId, entry.key.dseq), entry.key])); const ids = deploymentRows.map(row => row.id); const [groupRows, resourceRows, bidRows, leaseRows] = await Promise.all([ tx.select().from(DeploymentGroups).where(inArray(DeploymentGroups.deploymentId, ids)), @@ -122,7 +123,7 @@ export class AkashWriter { const leasesByDeployment = groupBy(leaseRows, row => row.deploymentId); for (const row of deploymentRows) { - const key = keyByOwnerDseq.get(`${row.ownerAccountId}/${normalizeDseq(row.dseq)}`); + const key = keyByOwnerDseq.get(ownerDseqKey(row.ownerAccountId, row.dseq)); if (!key) { continue; } @@ -232,7 +233,7 @@ export class AkashWriter { deposit: state.deposit.toString(), balance: decToString(state.balance), withdrawnAmount: decToString(state.withdrawn), - blockRate: decToString(state.leases.filter(lease => lease.closedHeight === null).reduce((sum, lease) => sum + lease.price, 0n)), + blockRate: decToString(sumLeaseRate(state.leases.filter(lease => lease.closedHeight === null))), lastWithdrawHeight: state.lastWithdrawHeight, lastProcessedHeight: state.lastProcessedHeight, createdHeight: state.createdHeight, @@ -268,9 +269,9 @@ export class AkashWriter { }) .returning({ id: Deployments.id, ownerAccountId: Deployments.ownerAccountId, dseq: Deployments.dseq }); - const idByOwnerDseq = new Map(inserted.map(row => [`${row.ownerAccountId}/${normalizeDseq(row.dseq)}`, row.id])); + const idByOwnerDseq = new Map(inserted.map(row => [ownerDseqKey(row.ownerAccountId, row.dseq), row.id])); for (const state of touched) { - const id = idByOwnerDseq.get(`${this.#requireId(accountIds, state.key.owner)}/${normalizeDseq(state.key.dseq)}`); + const id = idByOwnerDseq.get(ownerDseqKey(this.#requireId(accountIds, state.key.owner), state.key.dseq)); if (id !== undefined) { deploymentIds.set(stateKey(state.key), id); } @@ -282,9 +283,9 @@ export class AkashWriter { tx, missing.map(state => ({ key: state.key, ownerAccountId: this.#requireId(accountIds, state.key.owner) })) ); - const keyByOwnerDseq = new Map(missing.map(state => [`${this.#requireId(accountIds, state.key.owner)}/${normalizeDseq(state.key.dseq)}`, state.key])); + const keyByOwnerDseq = new Map(missing.map(state => [ownerDseqKey(this.#requireId(accountIds, state.key.owner), state.key.dseq), state.key])); for (const row of rowsForMissing) { - const key = keyByOwnerDseq.get(`${row.ownerAccountId}/${normalizeDseq(row.dseq)}`); + const key = keyByOwnerDseq.get(ownerDseqKey(row.ownerAccountId, row.dseq)); if (key) { deploymentIds.set(stateKey(key), row.id); } @@ -512,6 +513,11 @@ function normalizeDseq(dseq: string): string { return BigInt(dseq).toString(); } +/** The (interned owner account id, canonical dseq) pair that keys a deployment across the load and flush maps. */ +function ownerDseqKey(ownerAccountId: number, dseq: string): string { + return `${ownerAccountId}/${normalizeDseq(dseq)}`; +} + function compareDseq(a: string, b: string): number { const left = BigInt(a); const right = BigInt(b); diff --git a/apps/chain-indexer/src/akash/deployment-reducer.spec.ts b/apps/chain-indexer/src/akash/deployment-reducer.spec.ts index e0cee90db1..6ebc1dd3dd 100644 --- a/apps/chain-indexer/src/akash/deployment-reducer.spec.ts +++ b/apps/chain-indexer/src/akash/deployment-reducer.spec.ts @@ -239,6 +239,18 @@ describe("applyBlockChanges", () => { expect(get(states).leases[0].price).toBe(0n); }); + it("closes the order's other open bids when a lease is created", () => { + const { states } = setup(); + const rivalBid = { ...LEASE_KEY, provider: "akash1rival" }; + + applyBlockChanges(states, block(100, [create({}), bidCreated("10"), { kind: "bidCreated", key: rivalBid, price: "9", priceDenom: "uakt" }])); + applyBlockChanges(states, block(110, [leaseCreated()])); + + const state = get(states); + expect(state.bids.find(bid => bid.provider === PROVIDER)).toMatchObject({ state: "active" }); + expect(state.bids.find(bid => bid.provider === "akash1rival")).toMatchObject({ state: "closed", closedHeight: 110 }); + }); + function setup() { return { states: new Map() }; } diff --git a/apps/chain-indexer/src/akash/deployment-reducer.ts b/apps/chain-indexer/src/akash/deployment-reducer.ts index e032849687..59809b9385 100644 --- a/apps/chain-indexer/src/akash/deployment-reducer.ts +++ b/apps/chain-indexer/src/akash/deployment-reducer.ts @@ -1,7 +1,7 @@ -import type { AkashBlockChanges, AkashChange, DeploymentKey, NormalizedGroup, NormalizedResource } from "@src/akash/akash-changes"; +import type { AkashBlockChanges, AkashChange, DeploymentKey, LeaseSlot, NormalizedGroup, NormalizedResource } from "@src/akash/akash-changes"; import { decCeilInt, decFromInt, decFromString, decQuo, decToString, decTruncateInt } from "@src/akash/dec"; import { normalizeDenom } from "@src/akash/denom"; -import { settle } from "@src/akash/settlement"; +import { settle, sumLeaseRate } from "@src/akash/settlement"; import type { bidState, deploymentCloseReason, deploymentEventType, groupState } from "@src/db/schema"; export type DeploymentCloseReason = (typeof deploymentCloseReason.enumValues)[number]; @@ -225,7 +225,7 @@ function applyDeposit(state: DeploymentAggState, change: Extract lease.closedHeight === null); - const blockRate = openLeases.reduce((sum, lease) => sum + lease.price, 0n); + const blockRate = sumLeaseRate(openLeases); for (const lease of openLeases) { lease.predictedClosedHeight = predictClosedHeight(state.lastWithdrawHeight ?? lease.createdHeight, state.balance, blockRate); } @@ -354,6 +354,8 @@ function applyLeaseCreated( bid.state = "active"; } + closeLosingBids(state, change.key, block.height); + addEvent(state, block, change, "lease_created", bidEventDetails(change.key, bid ? decToString(bid.price) : undefined, bid?.denom)); } @@ -490,6 +492,16 @@ function closeOpenBids(state: DeploymentAggState, height: number): void { } } +/** Creating a lease matches and closes the order, so the chain closes every other still-open bid on the same (gseq, oseq). */ +function closeLosingBids(state: DeploymentAggState, winning: LeaseSlot, height: number): void { + for (const bid of state.bids) { + if (bid.state !== "closed" && bid.gseq === winning.gseq && bid.oseq === winning.oseq && !sameLeaseKey(bid, winning)) { + bid.state = "closed"; + bid.closedHeight = height; + } + } +} + /** * The legacy predicted-close formula, `base + ceil(balance / rate)`, on exact math. A zero rate means * the balance never depletes; the prediction is pinned to the base height so draining queries treat @@ -513,7 +525,7 @@ function addEvent( state.events.push({ height: block.height, ordinal, txIndex: change.txIndex, msgIndex: change.msgIndex, type, details }); } -function bidEventDetails(key: { gseq: number; oseq: number; bseq: number; provider: string }, price?: string, denom?: string): Record { +function bidEventDetails(key: LeaseSlot, price?: string, denom?: string): Record { return { gseq: key.gseq, oseq: key.oseq, @@ -524,14 +536,11 @@ function bidEventDetails(key: { gseq: number; oseq: number; bseq: number; provid }; } -function findOpenLease(state: DeploymentAggState, key: { gseq: number; oseq: number; bseq: number; provider: string }): LeaseAggState | undefined { +function findOpenLease(state: DeploymentAggState, key: LeaseSlot): LeaseAggState | undefined { return state.leases.find(candidate => candidate.closedHeight === null && sameLeaseKey(candidate, key)); } -function sameLeaseKey( - a: { gseq: number; oseq: number; bseq: number; provider: string }, - b: { gseq: number; oseq: number; bseq: number; provider: string } -): boolean { +function sameLeaseKey(a: LeaseSlot, b: LeaseSlot): boolean { return a.gseq === b.gseq && a.oseq === b.oseq && a.bseq === b.bseq && a.provider === b.provider; } diff --git a/apps/chain-indexer/src/akash/normalize-deployment.ts b/apps/chain-indexer/src/akash/normalize-deployment.ts index e601fe2f21..1e57ad4d92 100644 --- a/apps/chain-indexer/src/akash/normalize-deployment.ts +++ b/apps/chain-indexer/src/akash/normalize-deployment.ts @@ -1,10 +1,8 @@ -import type { AkashChangeBody, DeploymentKey } from "@src/akash/akash-changes"; +import { type AkashChangeBody, akashTypeUrlSet, type DeploymentKey } from "@src/akash/akash-changes"; import { asInteger, asRecord, asString } from "@src/akash/json"; import { normalizeGroups } from "@src/akash/resources"; import { asUint64String } from "@src/akash/uint64"; -type NormalizedChange = AkashChangeBody; - const DEPLOYMENT_VERSIONS = ["v1beta1", "v1beta2", "v1beta3", "v1beta4"] as const; const CREATE_DEPLOYMENT = typeUrlSet("MsgCreateDeployment"); @@ -17,23 +15,10 @@ const START_GROUP = typeUrlSet("MsgStartGroup"); const ACCOUNT_DEPOSIT = "/akash.escrow.v1.MsgAccountDeposit"; function typeUrlSet(name: string, versions: readonly string[] = DEPLOYMENT_VERSIONS): Set { - return new Set(versions.map(version => `/akash.deployment.${version}.${name}`)); -} - -export function isDeploymentTypeUrl(typeUrl: string): boolean { - return ( - CREATE_DEPLOYMENT.has(typeUrl) || - CLOSE_DEPLOYMENT.has(typeUrl) || - UPDATE_DEPLOYMENT.has(typeUrl) || - DEPOSIT_DEPLOYMENT.has(typeUrl) || - CLOSE_GROUP.has(typeUrl) || - PAUSE_GROUP.has(typeUrl) || - START_GROUP.has(typeUrl) || - typeUrl === ACCOUNT_DEPOSIT - ); + return akashTypeUrlSet("deployment", name, versions); } -export function normalizeDeploymentMessage(typeUrl: string, body: Record): NormalizedChange | null { +export function normalizeDeploymentMessage(typeUrl: string, body: Record): AkashChangeBody | null { if (CREATE_DEPLOYMENT.has(typeUrl)) { return normalizeCreate(body); } @@ -63,7 +48,7 @@ export function normalizeDeploymentMessage(typeUrl: string, body: Record): NormalizedChange | null { +function normalizeCreate(body: Record): AkashChangeBody | null { const key = deploymentKey(body.id); if (!key) { return null; @@ -79,7 +64,7 @@ function normalizeCreate(body: Record): NormalizedChange | null }; } -function normalizeDeposit(body: Record): NormalizedChange | null { +function normalizeDeposit(body: Record): AkashChangeBody | null { const key = deploymentKey(body.id); const amount = asString(asRecord(body.amount)?.amount); if (!key || !amount) { @@ -89,7 +74,7 @@ function normalizeDeposit(body: Record): NormalizedChange | nul } /** v1-era deposits target a generic escrow account: scope must be `deployment` (1) and `xid` is "owner/dseq". */ -function normalizeAccountDeposit(body: Record): NormalizedChange | null { +function normalizeAccountDeposit(body: Record): AkashChangeBody | null { const id = asRecord(body.id); const scope = id?.scope; if (scope !== 1 && scope !== "deployment") { @@ -103,7 +88,7 @@ function normalizeAccountDeposit(body: Record): NormalizedChang return { kind: "deploymentDeposited", key: { owner, dseq }, amount, depositor: asString(body.signer) }; } -function normalizeGroupChange(kind: "groupClosed" | "groupPaused" | "groupStarted", body: Record): NormalizedChange | null { +function normalizeGroupChange(kind: "groupClosed" | "groupPaused" | "groupStarted", body: Record): AkashChangeBody | null { const id = asRecord(body.id); const key = deploymentKey(id); const gseq = asInteger(id?.gseq); diff --git a/apps/chain-indexer/src/akash/normalize-market.ts b/apps/chain-indexer/src/akash/normalize-market.ts index 40b10d8979..74e5f424f6 100644 --- a/apps/chain-indexer/src/akash/normalize-market.ts +++ b/apps/chain-indexer/src/akash/normalize-market.ts @@ -1,9 +1,7 @@ -import type { AkashChangeBody, LeaseKey } from "@src/akash/akash-changes"; +import { type AkashChangeBody, akashTypeUrlSet, type LeaseKey } from "@src/akash/akash-changes"; import { asInteger, asRecord, asString } from "@src/akash/json"; import { deploymentKey } from "@src/akash/normalize-deployment"; -type NormalizedChange = AkashChangeBody; - const MARKET_VERSIONS = ["v1beta1", "v1beta2", "v1beta3", "v1beta4", "v1beta5"] as const; const CREATE_BID = typeUrlSet("MsgCreateBid"); @@ -13,14 +11,10 @@ const CLOSE_LEASE = typeUrlSet("MsgCloseLease"); const WITHDRAW_LEASE = typeUrlSet("MsgWithdrawLease"); function typeUrlSet(name: string): Set { - return new Set(MARKET_VERSIONS.map(version => `/akash.market.${version}.${name}`)); -} - -export function isMarketTypeUrl(typeUrl: string): boolean { - return CREATE_BID.has(typeUrl) || CLOSE_BID.has(typeUrl) || CREATE_LEASE.has(typeUrl) || CLOSE_LEASE.has(typeUrl) || WITHDRAW_LEASE.has(typeUrl); + return akashTypeUrlSet("market", name, MARKET_VERSIONS); } -export function normalizeMarketMessage(typeUrl: string, body: Record): NormalizedChange | null { +export function normalizeMarketMessage(typeUrl: string, body: Record): AkashChangeBody | null { if (CREATE_BID.has(typeUrl)) { return normalizeCreateBid(body); } @@ -44,7 +38,7 @@ export function normalizeMarketMessage(typeUrl: string, body: Record): NormalizedChange | null { +function normalizeCreateBid(body: Record): AkashChangeBody | null { const key = leaseKey(body.id) ?? leaseKey(body.order, asString(body.provider)); if (!key) { return null; diff --git a/apps/chain-indexer/src/akash/settlement.ts b/apps/chain-indexer/src/akash/settlement.ts index d74333068f..bd27c10bbc 100644 --- a/apps/chain-indexer/src/akash/settlement.ts +++ b/apps/chain-indexer/src/akash/settlement.ts @@ -24,6 +24,11 @@ export interface SettlementResult { /** Escrow accounts settle to at most 1 u-denom unit of rounding dust on an overdraw close. */ const MAX_SETTLEMENT_DUST = 10n ** 18n; +/** The block rate is the sum of the open leases' per-block prices; callers pass the leases they consider open. */ +export function sumLeaseRate(leases: T[]): bigint { + return leases.reduce((sum, lease) => sum + lease.price, 0n); +} + /** * Port of akash-node x/escrow account settlement (x/escrow/keeper, accountSettle) on exact LegacyDec * math. Mutates the passed state objects: moves the exact Dec accrual since the last settlement from @@ -33,7 +38,7 @@ const MAX_SETTLEMENT_DUST = 10n ** 18n; * settlement only accrues. */ export function settle(deployment: SettlementDeployment, openLeases: SettlementLease[], height: number): SettlementResult { - const blockRate = openLeases.reduce((sum, lease) => sum + lease.price, 0n); + const blockRate = sumLeaseRate(openLeases); if (height === deployment.lastWithdrawHeight) return { blockRate, overdrawn: false }; diff --git a/apps/chain-indexer/src/pipeline/block-decoder.service.ts b/apps/chain-indexer/src/pipeline/block-decoder.service.ts index af747d1bd4..08bade8bb2 100644 --- a/apps/chain-indexer/src/pipeline/block-decoder.service.ts +++ b/apps/chain-indexer/src/pipeline/block-decoder.service.ts @@ -7,6 +7,7 @@ import type { EnvConfig } from "@src/config/env.config"; import { toCanonicalJson } from "@src/pipeline/canonical-json"; import { decodeIfBase64 } from "@src/pipeline/decode-if-base64"; import type { DecodedBlock, DecodedEvent, DecodedMessage, DecodedTransaction, MessageDecodeFailure } from "@src/pipeline/decoded-block"; +import { MAX_EXEC_DEPTH, MSG_EXEC_TYPE_URL } from "@src/pipeline/msg-exec"; import { deriveSignerAddresses } from "@src/pipeline/signer-addresses"; import { isIgnoredTypeUrl } from "@src/proto/type-catalog"; import { APP_CONFIG } from "@src/providers/app-config.provider"; @@ -38,11 +39,6 @@ const RELEVANT_EVENT_TYPES = new Set([ const MSG_INDEX_ATTRIBUTE = "msg_index"; -const MSG_EXEC_TYPE_URL = "/cosmos.authz.v1beta1.MsgExec"; - -/** MsgExec nested in MsgExec is legal on-chain; two levels covers every observed use without unbounded recursion. */ -const MAX_EXEC_DECODE_DEPTH = 2; - @singleton() export class BlockDecoderService { readonly #registry: Registry; @@ -158,8 +154,7 @@ export class BlockDecoderService { const msgs = record.msgs.map(inner => { try { const innerDecoded = isIgnoredTypeUrl(inner.typeUrl) ? null : this.#registry.decode(inner); - const enriched = - inner.typeUrl === MSG_EXEC_TYPE_URL && depth < MAX_EXEC_DECODE_DEPTH ? this.#decodeExecMessages(innerDecoded, depth + 1) : innerDecoded; + const enriched = inner.typeUrl === MSG_EXEC_TYPE_URL && depth < MAX_EXEC_DEPTH ? this.#decodeExecMessages(innerDecoded, depth + 1) : innerDecoded; return { ...inner, decoded: enriched }; } catch { return { ...inner, decoded: null }; diff --git a/apps/chain-indexer/src/pipeline/msg-exec.ts b/apps/chain-indexer/src/pipeline/msg-exec.ts new file mode 100644 index 0000000000..8cef42f99d --- /dev/null +++ b/apps/chain-indexer/src/pipeline/msg-exec.ts @@ -0,0 +1,5 @@ +/** authz MsgExec type URL; managed-wallet deployments arrive wrapped in one (occasionally two) of these. */ +export const MSG_EXEC_TYPE_URL = "/cosmos.authz.v1beta1.MsgExec"; + +/** MsgExec nested in MsgExec is legal on-chain; two levels covers every observed use without unbounded recursion. The decoder enriches and the deriver walks to the same depth so the two passes stay in step. */ +export const MAX_EXEC_DEPTH = 2; From 2696bb8c2aa62423fbd0991d4e0830a6024755ec Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:37:17 +0530 Subject: [PATCH 4/4] fix(indexer): decode legacy DecCoin amounts exactly instead of via float64 Found by verifying the deployment/market handlers against mainnet history: @akashnetwork/akash-api patches DecCoin.decode with parseInt(atomics) / 1e18, which overflows float64 precision and corrupts every v1beta1-v1beta4 DecCoin at the ~15th significant digit (a real v1beta2 bid price of 117.73952 uakt/block decoded as 117.739519999999999). All legacy proto versions share one coin module instance, so re-patching its decode with exact string math (atomics string -> decimal string) fixes stored message bodies, bid/lease prices and settlement inputs in one place. Verified against akashnet-2 archival history: with the fix, indexed prices match on-chain bids exactly, and escrow refunds/payouts reconcile with the bank transfer events emitted at lease withdraw and deployment close. --- apps/chain-indexer/package.json | 1 + .../proto/legacy-dec-coin-precision.spec.ts | 28 +++++++++++ .../src/proto/legacy-dec-coin-precision.ts | 50 +++++++++++++++++++ apps/chain-indexer/src/proto/type-catalog.ts | 2 + package-lock.json | 2 +- 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 apps/chain-indexer/src/proto/legacy-dec-coin-precision.spec.ts create mode 100644 apps/chain-indexer/src/proto/legacy-dec-coin-precision.ts diff --git a/apps/chain-indexer/package.json b/apps/chain-indexer/package.json index f1f809fd74..dfb1a7fc76 100644 --- a/apps/chain-indexer/package.json +++ b/apps/chain-indexer/package.json @@ -40,6 +40,7 @@ "http-errors": "^2.0.0", "lodash": "^4.17.21", "postgres": "^3.4.4", + "protobufjs": "~6.11.2", "reflect-metadata": "^0.2.2", "tsyringe": "^4.10.0", "undici": "^7.22.0", diff --git a/apps/chain-indexer/src/proto/legacy-dec-coin-precision.spec.ts b/apps/chain-indexer/src/proto/legacy-dec-coin-precision.spec.ts new file mode 100644 index 0000000000..4d6a7d659b --- /dev/null +++ b/apps/chain-indexer/src/proto/legacy-dec-coin-precision.spec.ts @@ -0,0 +1,28 @@ +import "./legacy-dec-coin-precision"; + +import { MsgCreateBid } from "@akashnetwork/akash-api/akash/market/v1beta2"; +import { DecCoin } from "@akashnetwork/akash-api/cosmos/base/v1beta1"; +import { describe, expect, it } from "vitest"; + +describe("legacy DecCoin precision", () => { + it.each([ + ["117.73952", "117.73952"], + ["100", "100"], + ["0.5", "0.5"], + ["0", "0"], + ["1000000", "1000000"], + ["0.000000000000000001", "0.000000000000000001"], + ["123456789.123456789123456789", "123456789.123456789123456789"] + ])("round-trips %s exactly instead of float-approximating it", (amount, expected) => { + const bytes = DecCoin.encode({ $type: DecCoin.$type, denom: "uakt", amount }).finish(); + + expect(DecCoin.decode(bytes)).toEqual({ $type: DecCoin.$type, denom: "uakt", amount: expected }); + }); + + it("decodes DecCoins nested inside legacy messages exactly", () => { + const message = MsgCreateBid.fromPartial({ price: { denom: "uakt", amount: "117.73952" } }); + const bytes = MsgCreateBid.encode(message).finish(); + + expect(MsgCreateBid.decode(bytes).price?.amount).toBe("117.73952"); + }); +}); diff --git a/apps/chain-indexer/src/proto/legacy-dec-coin-precision.ts b/apps/chain-indexer/src/proto/legacy-dec-coin-precision.ts new file mode 100644 index 0000000000..4112a405d1 --- /dev/null +++ b/apps/chain-indexer/src/proto/legacy-dec-coin-precision.ts @@ -0,0 +1,50 @@ +import { DecCoin } from "@akashnetwork/akash-api/cosmos/base/v1beta1"; +import { Reader } from "protobufjs/minimal"; + +/** + * `@akashnetwork/akash-api` patches `DecCoin.decode` to convert the wire-format 1e18-scaled + * atomics string into a human decimal via `parseInt(amount) / 1e18` — float64 math that corrupts + * every legacy-era DecCoin at the ~15th significant digit (a v1beta2 bid price of `117.73952` + * decodes as `117.739519999999999`). All legacy proto versions share this one module instance, + * so replacing its `decode` with exact string math fixes v1beta1–v1beta4 decoding in one place. + * Imported for its side effect by the type catalog before any registry decoding happens. + */ +export function installExactLegacyDecCoinDecode(): void { + DecCoin.decode = decodeDecCoinExact; +} + +const DENOM_FIELD = 1; +const AMOUNT_FIELD = 2; + +function decodeDecCoinExact(input: Reader | Uint8Array, length?: number): DecCoin { + const reader = input instanceof Uint8Array ? Reader.create(input) : input; + const end = length === undefined ? reader.len : reader.pos + length; + const message: DecCoin = { $type: DecCoin.$type, denom: "", amount: "" }; + + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case DENOM_FIELD: + message.denom = reader.string(); + break; + case AMOUNT_FIELD: + message.amount = decimalStringFromAtomics(reader.string()); + break; + default: + reader.skipType(tag & 7); + break; + } + } + + return message; +} + +function decimalStringFromAtomics(atomics: string): string { + const negative = atomics.startsWith("-"); + const digits = (negative ? atomics.slice(1) : atomics).padStart(19, "0"); + const whole = digits.slice(0, -18); + const fraction = digits.slice(-18).replace(/0+$/, ""); + return `${negative ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}`; +} + +installExactLegacyDecCoinDecode(); diff --git a/apps/chain-indexer/src/proto/type-catalog.ts b/apps/chain-indexer/src/proto/type-catalog.ts index 0f98d88da4..711692e4ac 100644 --- a/apps/chain-indexer/src/proto/type-catalog.ts +++ b/apps/chain-indexer/src/proto/type-catalog.ts @@ -1,3 +1,5 @@ +import "./legacy-dec-coin-precision"; + import * as legacyV1beta1 from "@akashnetwork/akash-api/v1beta1"; import * as legacyV1beta2 from "@akashnetwork/akash-api/v1beta2"; import * as legacyV1beta3 from "@akashnetwork/akash-api/v1beta3"; diff --git a/package-lock.json b/package-lock.json index ce10e800f9..65acb31937 100644 --- a/package-lock.json +++ b/package-lock.json @@ -556,6 +556,7 @@ "http-errors": "^2.0.0", "lodash": "^4.17.21", "postgres": "^3.4.4", + "protobufjs": "~6.11.2", "reflect-metadata": "^0.2.2", "tsyringe": "^4.10.0", "undici": "^7.22.0", @@ -21711,7 +21712,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-8yQrvS6sMpSwIovhPOwfyNf2Wz6v/B62LFSVYQ85+Rq3tLsBIG7rP5geMxaijTUxSkrO6RzN/IRuIAADYQsleA==", "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*" }