diff --git a/apps/chain-indexer/README.md b/apps/chain-indexer/README.md index fdfe0533f5..1e2d450838 100644 --- a/apps/chain-indexer/README.md +++ b/apps/chain-indexer/README.md @@ -40,6 +40,12 @@ curl localhost:3092/v1/status The checkpoint height should advance as blocks land in `cosmos.blocks`, `cosmos.transactions`, and `cosmos.messages`. +## Genesis import + +Set `GENESIS_IMPORT=true` to seed genesis state before the first block: accounts, per-denom balances (as `genesis`-reason ledger entries), validators, and staking delegations, all in one transaction. Because balance history is only trustworthy from the network's genesis, a fresh `sync` with the flag on must begin at the genesis height, and a fresh start anywhere else is rejected with a clear error. On sandbox that height is 1 (`SYNC_START_HEIGHT=1`); the height is read from the genesis file itself, so a chain continued from an export uses its continuation height. + +The import runs once. A `genesis` checkpoint in `indexer_state` makes a restart skip it, and the seed commits in a single transaction, so an interrupted run rolls back and retries cleanly. Genesis is fetched over RPC `/genesis_chunked` from the same nodes sync uses, and its `chain_id` must match the chain being indexed. Leave the flag unset (the default) and sync tails blocks, transactions, and messages from any height exactly as before. + ## Backfill The backfill role fills the database over an explicit, inclusive height range and exits when done, so it fits a one-off K8s Job: diff --git a/apps/chain-indexer/drizzle/0001_long_mach_iv.sql b/apps/chain-indexer/drizzle/0001_long_mach_iv.sql new file mode 100644 index 0000000000..7e98956454 --- /dev/null +++ b/apps/chain-indexer/drizzle/0001_long_mach_iv.sql @@ -0,0 +1,55 @@ +CREATE TYPE "cosmos"."balance_change_reason" AS ENUM('genesis', 'transfer', 'fee', 'reward', 'commission', 'slash', 'gov', 'ibc', 'escrow', 'bme', 'mint', 'burn');--> statement-breakpoint +CREATE TABLE "cosmos"."account_balances" ( + "account_id" integer NOT NULL, + "denom" text NOT NULL, + "amount" numeric(38, 0) NOT NULL, + CONSTRAINT "account_balances_account_id_denom_pk" PRIMARY KEY("account_id","denom") +); +--> statement-breakpoint +CREATE TABLE "cosmos"."accounts" ( + "id" serial PRIMARY KEY NOT NULL, + "address" text NOT NULL, + "account_number" bigint, + "account_type" text, + "is_module_account" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE "cosmos"."balance_changes" ( + "id" bigserial PRIMARY KEY NOT NULL, + "account_id" integer NOT NULL, + "denom" text NOT NULL, + "delta" numeric(38, 0) NOT NULL, + "balance_after" numeric(38, 0) NOT NULL, + "reason" "cosmos"."balance_change_reason" NOT NULL, + "height" bigint NOT NULL, + "counterparty_account_id" integer +); +--> statement-breakpoint +CREATE TABLE "cosmos"."delegations" ( + "delegator_account_id" integer NOT NULL, + "validator_operator_address" text NOT NULL, + "shares" numeric(38, 18) NOT NULL, + CONSTRAINT "delegations_delegator_account_id_validator_operator_address_pk" PRIMARY KEY("delegator_account_id","validator_operator_address") +); +--> statement-breakpoint +CREATE TABLE "cosmos"."validators" ( + "operator_address" text PRIMARY KEY NOT NULL, + "account_address" text, + "hex_address" text, + "moniker" text, + "identity" text, + "website" text, + "details" text, + "security_contact" text, + "commission_rate" numeric(20, 18), + "commission_max_rate" numeric(20, 18), + "commission_max_change_rate" numeric(20, 18), + "min_self_delegation" numeric(38, 0) +); +--> statement-breakpoint +ALTER TABLE "cosmos"."account_balances" ADD CONSTRAINT "account_balances_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "cosmos"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cosmos"."balance_changes" ADD CONSTRAINT "balance_changes_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "cosmos"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cosmos"."balance_changes" ADD CONSTRAINT "balance_changes_counterparty_account_id_accounts_id_fk" FOREIGN KEY ("counterparty_account_id") REFERENCES "cosmos"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cosmos"."delegations" ADD CONSTRAINT "delegations_delegator_account_id_accounts_id_fk" FOREIGN KEY ("delegator_account_id") REFERENCES "cosmos"."accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "accounts_address_idx" ON "cosmos"."accounts" USING btree ("address");--> statement-breakpoint +CREATE INDEX "balance_changes_account_denom_height_idx" ON "cosmos"."balance_changes" USING btree ("account_id","denom","height"); \ No newline at end of file diff --git a/apps/chain-indexer/drizzle/meta/0001_snapshot.json b/apps/chain-indexer/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000000..78e2d1fe28 --- /dev/null +++ b/apps/chain-indexer/drizzle/meta/0001_snapshot.json @@ -0,0 +1,695 @@ +{ + "id": "05402ce9-8c3f-4514-8150-e7911053df30", + "prevId": "332f29a4-e784-4238-9a9e-7139a844e7e2", + "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.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 + }, + "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": {} + } + }, + "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 + }, + "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 + }, + "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 + }, + "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.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.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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "cosmos.balance_change_reason": { + "name": "balance_change_reason", + "schema": "cosmos", + "values": [ + "genesis", + "transfer", + "fee", + "reward", + "commission", + "slash", + "gov", + "ibc", + "escrow", + "bme", + "mint", + "burn" + ] + } + }, + "schemas": { + "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 e539d4c4af..d80e14e60d 100644 --- a/apps/chain-indexer/drizzle/meta/_journal.json +++ b/apps/chain-indexer/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1786452233786, "tag": "0000_remarkable_scourge", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1786548202033, + "tag": "0001_long_mach_iv", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/chain-indexer/env/.env.sample b/apps/chain-indexer/env/.env.sample index 126216bcde..2c48dac39e 100644 --- a/apps/chain-indexer/env/.env.sample +++ b/apps/chain-indexer/env/.env.sample @@ -4,6 +4,7 @@ POSTGRES_DB_URI=postgres://user:password@localhost:5432/chain-indexer RPC_NODE_ENDPOINTS= SYNC_START_HEIGHT= SYNC_POLL_INTERVAL_MS=3000 +GENESIS_IMPORT=false BACKFILL_FROM_HEIGHT= BACKFILL_TO_HEIGHT= ARCHIVE_BUCKET= diff --git a/apps/chain-indexer/src/config/env.config.ts b/apps/chain-indexer/src/config/env.config.ts index 4915911c95..6f3c849257 100644 --- a/apps/chain-indexer/src/config/env.config.ts +++ b/apps/chain-indexer/src/config/env.config.ts @@ -14,6 +14,13 @@ const rawEnvSchema = z.object({ /** First height to sync when the database has no checkpoint yet. Defaults to the current chain tip. */ SYNC_START_HEIGHT: z.preprocess(emptyStringAsUndefined, z.number({ coerce: true }).int().positive().optional()), SYNC_POLL_INTERVAL_MS: z.number({ coerce: true }).int().positive().default(3_000), + /** + * Enables the one-time genesis import (accounts, balances, validators, delegations) before the first block. + * When on, a fresh sync must start at the network's genesis height or it is rejected as a mid-chain start. + * Off preserves plain block/tx/message tailing from any height. Enum-transform rather than z.coerce.boolean(), + * which treats the string "false" as true. + */ + GENESIS_IMPORT: z.preprocess(emptyStringAsUndefined, z.enum(["true", "false"]).default("false")).transform(value => value === "true"), /** First height of the backfill range (inclusive). Required when INDEXER_ROLE is "backfill". */ BACKFILL_FROM_HEIGHT: z.preprocess(emptyStringAsUndefined, z.number({ coerce: true }).int().positive().optional()), /** Last height of the backfill range (inclusive). Required when INDEXER_ROLE is "backfill". */ diff --git a/apps/chain-indexer/src/db/insert-chunk-size.ts b/apps/chain-indexer/src/db/insert-chunk-size.ts new file mode 100644 index 0000000000..6a31966b56 --- /dev/null +++ b/apps/chain-indexer/src/db/insert-chunk-size.ts @@ -0,0 +1,2 @@ +/** Keeps multi-row inserts well under postgres.js's ~65k bind-parameter limit. */ +export const INSERT_CHUNK_SIZE = 2_000; diff --git a/apps/chain-indexer/src/db/insert-chunked.spec.ts b/apps/chain-indexer/src/db/insert-chunked.spec.ts new file mode 100644 index 0000000000..a28bfc4c5a --- /dev/null +++ b/apps/chain-indexer/src/db/insert-chunked.spec.ts @@ -0,0 +1,68 @@ +import type { PgTable } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; + +import { INSERT_CHUNK_SIZE } from "@src/db/insert-chunk-size"; +import { insertChunked } from "@src/db/insert-chunked"; +import { AccountBalances } from "@src/db/schema"; +import type { ChainTransaction } from "@src/providers/db.provider"; + +describe(insertChunked.name, () => { + it("splits rows into chunks that stay within the insert limit and preserves their order", async () => { + const { tx, inserts } = setup(); + const rows = buildBalanceRows(INSERT_CHUNK_SIZE + 500); + + await insertChunked(tx, AccountBalances, rows); + + expect(inserts.map(insert => insert.rows.length)).toEqual([INSERT_CHUNK_SIZE, 500]); + expect(inserts.flatMap(insert => insert.rows)).toEqual(rows); + }); + + it("ignores conflicts on every chunk by default", async () => { + const { tx, inserts } = setup(); + + await insertChunked(tx, AccountBalances, buildBalanceRows(3)); + + expect(inserts.every(insert => insert.onConflict)).toBe(true); + }); + + it("writes every chunk without a conflict target when conflict handling is disabled", async () => { + const { tx, inserts } = setup(); + + await insertChunked(tx, AccountBalances, buildBalanceRows(3), { onConflictDoNothing: false }); + + expect(inserts.every(insert => insert.onConflict)).toBe(false); + }); + + it("issues no insert for an empty row set", async () => { + const { tx, inserts } = setup(); + + await insertChunked(tx, AccountBalances, []); + + expect(inserts).toEqual([]); + }); + + function buildBalanceRows(count: number): (typeof AccountBalances.$inferInsert)[] { + return Array.from({ length: count }, (_, index) => ({ accountId: index, denom: "uakt", amount: String(index) })); + } + + function setup() { + const inserts: { rows: Record[]; onConflict: boolean }[] = []; + const tx = { + insert(_table: PgTable) { + return { + values(rows: Record[]) { + const record = { rows, onConflict: false }; + inserts.push(record); + return Object.assign(Promise.resolve(), { + onConflictDoNothing: () => { + record.onConflict = true; + return Promise.resolve(); + } + }); + } + }; + } + }; + return { tx: tx as unknown as ChainTransaction, inserts }; + } +}); diff --git a/apps/chain-indexer/src/db/insert-chunked.ts b/apps/chain-indexer/src/db/insert-chunked.ts new file mode 100644 index 0000000000..0e1fde796f --- /dev/null +++ b/apps/chain-indexer/src/db/insert-chunked.ts @@ -0,0 +1,22 @@ +import type { PgTable } from "drizzle-orm/pg-core"; +import chunk from "lodash/chunk"; + +import { INSERT_CHUNK_SIZE } from "@src/db/insert-chunk-size"; +import type { ChainTransaction } from "@src/providers/db.provider"; + +/** + * Inserts `rows` in chunks that stay under postgres.js's bind-parameter limit. Conflicts are ignored by + * default so a re-seed is idempotent; pass `onConflictDoNothing: false` where an outer guard already + * enforces single-writing and every row must land (e.g. the genesis balance-change ledger). + */ +export async function insertChunked( + tx: ChainTransaction, + table: TTable, + rows: TTable["$inferInsert"][], + { onConflictDoNothing = true }: { onConflictDoNothing?: boolean } = {} +): Promise { + for (const rowChunk of chunk(rows, INSERT_CHUNK_SIZE)) { + const insert = tx.insert(table).values(rowChunk); + await (onConflictDoNothing ? insert.onConflictDoNothing() : insert); + } +} diff --git a/apps/chain-indexer/src/db/schema.spec.ts b/apps/chain-indexer/src/db/schema.spec.ts new file mode 100644 index 0000000000..80c7fe9dce --- /dev/null +++ b/apps/chain-indexer/src/db/schema.spec.ts @@ -0,0 +1,46 @@ +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; + +import { AccountBalances, Accounts, BalanceChanges, Delegations, Validators } from "@src/db/schema"; + +describe("cosmos genesis schema", () => { + it("interns accounts under a unique address index", () => { + const config = getTableConfig(Accounts); + + expect(config.name).toBe("accounts"); + expect(config.columns.map(column => column.name)).toContain("is_module_account"); + expect(config.indexes).toHaveLength(1); + }); + + it("keys current balances by account and denom with an account foreign key", () => { + const config = getTableConfig(AccountBalances); + + expect(config.columns.map(column => column.name).sort()).toEqual(["account_id", "amount", "denom"]); + expect(config.primaryKeys).toHaveLength(1); + expect(config.foreignKeys).toHaveLength(1); + expect(config.foreignKeys[0].reference().foreignColumns[0].name).toBe("id"); + }); + + it("references accounts from the ledger for both the holder and the counterparty", () => { + const config = getTableConfig(BalanceChanges); + + expect(config.foreignKeys).toHaveLength(2); + expect(config.indexes).toHaveLength(1); + config.foreignKeys.forEach(foreignKey => expect(foreignKey.reference().foreignColumns[0].name).toBe("id")); + }); + + it("keys delegations by delegator and validator with a delegator foreign key", () => { + const config = getTableConfig(Delegations); + + expect(config.primaryKeys).toHaveLength(1); + expect(config.foreignKeys).toHaveLength(1); + expect(config.foreignKeys[0].reference().foreignColumns[0].name).toBe("id"); + }); + + it("keys validators by operator address", () => { + const config = getTableConfig(Validators); + + expect(config.name).toBe("validators"); + expect(config.columns.map(column => column.name)).toContain("operator_address"); + }); +}); diff --git a/apps/chain-indexer/src/db/schema.ts b/apps/chain-indexer/src/db/schema.ts index ea0591d979..93e9d6e184 100644 --- a/apps/chain-indexer/src/db/schema.ts +++ b/apps/chain-indexer/src/db/schema.ts @@ -1,4 +1,19 @@ -import { bigint, index, integer, jsonb, pgSchema, pgTable, primaryKey, serial, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { + bigint, + bigserial, + boolean, + index, + integer, + jsonb, + numeric, + pgSchema, + pgTable, + primaryKey, + serial, + text, + timestamp, + uniqueIndex +} from "drizzle-orm/pg-core"; import { bytea } from "@src/db/bytea"; @@ -60,3 +75,94 @@ export const IndexerState = pgTable("indexer_state", { lastHeight: bigint("last_height", { mode: "number" }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull() }); + +/** + * Why an address change happened. Only `genesis` is written today (L-3); the ongoing per-block + * reasons (transfer/fee/staking/...) land with the balance ledger in L-4. New values are added via + * migration (Postgres allows ALTER TYPE ... ADD VALUE) rather than editing this list retroactively. + */ +export const balanceChangeReason = cosmosSchema.enum("balance_change_reason", [ + "genesis", + "transfer", + "fee", + "reward", + "commission", + "slash", + "gov", + "ibc", + "escrow", + "bme", + "mint", + "burn" +]); + +/** Addresses interned once and referenced by integer id, mirroring the message_types lookup. */ +export const Accounts = cosmosSchema.table( + "accounts", + { + id: serial("id").primaryKey(), + address: text("address").notNull(), + accountNumber: bigint("account_number", { mode: "number" }), + accountType: text("account_type"), + isModuleAccount: boolean("is_module_account").notNull().default(false) + }, + t => [uniqueIndex("accounts_address_idx").on(t.address)] +); + +/** Current per-denom balance for each account, updated in the same transaction as the ledger. */ +export const AccountBalances = cosmosSchema.table( + "account_balances", + { + accountId: integer("account_id") + .notNull() + .references(() => Accounts.id), + denom: text("denom").notNull(), + amount: numeric("amount", { precision: 38, scale: 0 }).notNull() + }, + t => [primaryKey({ columns: [t.accountId, t.denom] })] +); + +/** Append-only ledger of balance changes. `numeric(38,0)` never loses precision on u-denom amounts the way DOUBLE does. */ +export const BalanceChanges = cosmosSchema.table( + "balance_changes", + { + id: bigserial("id", { mode: "number" }).primaryKey(), + accountId: integer("account_id") + .notNull() + .references(() => Accounts.id), + denom: text("denom").notNull(), + delta: numeric("delta", { precision: 38, scale: 0 }).notNull(), + balanceAfter: numeric("balance_after", { precision: 38, scale: 0 }).notNull(), + reason: balanceChangeReason("reason").notNull(), + height: bigint("height", { mode: "number" }).notNull(), + counterpartyAccountId: integer("counterparty_account_id").references(() => Accounts.id) + }, + t => [index("balance_changes_account_denom_height_idx").on(t.accountId, t.denom, t.height)] +); + +export const Validators = cosmosSchema.table("validators", { + operatorAddress: text("operator_address").primaryKey(), + accountAddress: text("account_address"), + hexAddress: text("hex_address"), + moniker: text("moniker"), + identity: text("identity"), + website: text("website"), + details: text("details"), + securityContact: text("security_contact"), + commissionRate: numeric("commission_rate", { precision: 20, scale: 18 }), + commissionMaxRate: numeric("commission_max_rate", { precision: 20, scale: 18 }), + commissionMaxChangeRate: numeric("commission_max_change_rate", { precision: 20, scale: 18 }), + minSelfDelegation: numeric("min_self_delegation", { precision: 38, scale: 0 }) +}); + +export const Delegations = cosmosSchema.table( + "delegations", + { + delegatorAccountId: integer("delegator_account_id") + .notNull() + .references(() => Accounts.id), + validatorOperatorAddress: text("validator_operator_address").notNull(), + shares: numeric("shares", { precision: 38, scale: 18 }).notNull() + }, + t => [primaryKey({ columns: [t.delegatorAccountId, t.validatorOperatorAddress] })] +); diff --git a/apps/chain-indexer/src/genesis/account-seeder.service.spec.ts b/apps/chain-indexer/src/genesis/account-seeder.service.spec.ts new file mode 100644 index 0000000000..b4e3bced46 --- /dev/null +++ b/apps/chain-indexer/src/genesis/account-seeder.service.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { Accounts } from "@src/db/schema"; +import { AccountSeeder } from "@src/genesis/account-seeder.service"; + +import { buildTxFake, rowsFor } from "@test/fakes/build-tx-fake"; +import { buildParsedGenesis } from "@test/fakes/genesis-fixtures"; + +describe(AccountSeeder.name, () => { + it("interns every genesis address once and returns the address→id map", async () => { + const { seeder, tx, inserts } = setup(); + + const idByAddress = await seeder.intern(tx, buildParsedGenesis()); + + expect([...idByAddress.keys()].sort()).toEqual(["akash1base", "akash1module", "akash1vesting"]); + expect(rowsFor(inserts, Accounts)).toHaveLength(3); + }); + + it("records account metadata including the module-account flag", async () => { + const { seeder, tx, inserts } = setup(); + + await seeder.intern(tx, buildParsedGenesis()); + + expect(rowsFor(inserts, Accounts)).toEqual([ + { address: "akash1base", accountNumber: 1, accountType: "base", isModuleAccount: false }, + { address: "akash1module", accountNumber: 2, accountType: "module", isModuleAccount: true }, + { address: "akash1vesting", accountNumber: 3, accountType: "vesting", isModuleAccount: false } + ]); + }); + + it("interns balance and delegator addresses that have no auth account entry", async () => { + const { seeder, tx, inserts } = setup(); + const genesis = { + ...buildParsedGenesis(), + accounts: [], + balances: [{ address: "akash1holder", coins: [{ denom: "uakt", amount: "1" }] }], + delegations: [{ delegatorAddress: "akash1delegator", validatorOperatorAddress: "akashvaloper1x", shares: "1" }] + }; + + const idByAddress = await seeder.intern(tx, genesis); + + expect([...idByAddress.keys()].sort()).toEqual(["akash1delegator", "akash1holder"]); + expect(rowsFor(inserts, Accounts)).toEqual([ + { address: "akash1holder", accountNumber: null, accountType: null, isModuleAccount: false }, + { address: "akash1delegator", accountNumber: null, accountType: null, isModuleAccount: false } + ]); + }); + + function setup() { + const { tx, inserts } = buildTxFake(); + return { seeder: new AccountSeeder(), tx, inserts }; + } +}); diff --git a/apps/chain-indexer/src/genesis/account-seeder.service.ts b/apps/chain-indexer/src/genesis/account-seeder.service.ts new file mode 100644 index 0000000000..27a61b26fb --- /dev/null +++ b/apps/chain-indexer/src/genesis/account-seeder.service.ts @@ -0,0 +1,42 @@ +import chunk from "lodash/chunk"; +import { singleton } from "tsyringe"; + +import { INSERT_CHUNK_SIZE } from "@src/db/insert-chunk-size"; +import { Accounts } from "@src/db/schema"; +import type { ParsedGenesis } from "@src/genesis/genesis-schema"; +import type { ChainTransaction } from "@src/providers/db.provider"; + +@singleton() +export class AccountSeeder { + /** + * Interns every address that appears in genesis — auth accounts, balance holders, and delegators — + * and returns the address→id map the other seeders reference. Genesis runs before block 1 against an + * empty accounts table, so `returning()` yields the full mapping without a follow-up select. + */ + async intern(tx: ChainTransaction, genesis: ParsedGenesis): Promise> { + const accountByAddress = new Map(genesis.accounts.map(account => [account.address, account])); + + const addresses = new Set(); + genesis.accounts.forEach(account => addresses.add(account.address)); + genesis.balances.forEach(balance => addresses.add(balance.address)); + genesis.delegations.forEach(delegation => addresses.add(delegation.delegatorAddress)); + + const rows: (typeof Accounts.$inferInsert)[] = [...addresses].map(address => { + const account = accountByAddress.get(address); + return { + address, + accountNumber: account?.accountNumber ?? null, + accountType: account?.accountType ?? null, + isModuleAccount: account?.isModuleAccount ?? false + }; + }); + + const idByAddress = new Map(); + for (const rowChunk of chunk(rows, INSERT_CHUNK_SIZE)) { + const inserted = await tx.insert(Accounts).values(rowChunk).onConflictDoNothing().returning({ id: Accounts.id, address: Accounts.address }); + inserted.forEach(row => idByAddress.set(row.address, row.id)); + } + + return idByAddress; + } +} diff --git a/apps/chain-indexer/src/genesis/bank-seeder.service.spec.ts b/apps/chain-indexer/src/genesis/bank-seeder.service.spec.ts new file mode 100644 index 0000000000..6774f23516 --- /dev/null +++ b/apps/chain-indexer/src/genesis/bank-seeder.service.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { AccountBalances, BalanceChanges } from "@src/db/schema"; +import { BankSeeder } from "@src/genesis/bank-seeder.service"; + +import { buildTxFake, rowsFor } from "@test/fakes/build-tx-fake"; +import { buildParsedGenesis } from "@test/fakes/genesis-fixtures"; + +describe(BankSeeder.name, () => { + it("writes a current balance and a genesis ledger entry for each coin", async () => { + const { seeder, tx, inserts } = setup(); + + await seeder.seed(tx, buildParsedGenesis(), context()); + + expect(rowsFor(inserts, AccountBalances)).toEqual([ + { accountId: 1, denom: "uakt", amount: "10" }, + { accountId: 2, denom: "uakt", amount: "5" }, + { accountId: 3, denom: "uakt", amount: "20" } + ]); + expect(rowsFor(inserts, BalanceChanges)).toEqual([ + { accountId: 1, denom: "uakt", delta: "10", balanceAfter: "10", reason: "genesis", height: 1, counterpartyAccountId: null }, + { accountId: 2, denom: "uakt", delta: "5", balanceAfter: "5", reason: "genesis", height: 1, counterpartyAccountId: null }, + { accountId: 3, denom: "uakt", delta: "20", balanceAfter: "20", reason: "genesis", height: 1, counterpartyAccountId: null } + ]); + }); + + it("seeds current balances that total the genesis supply", async () => { + const { seeder, tx, inserts } = setup(); + const genesis = buildParsedGenesis(); + + await seeder.seed(tx, genesis, context()); + + const seededTotal = rowsFor(inserts, AccountBalances).reduce((sum, row) => sum + BigInt(row.amount as string), 0n); + const supplyTotal = genesis.supply.reduce((sum, coin) => sum + BigInt(coin.amount), 0n); + expect(seededTotal).toBe(supplyTotal); + }); + + it("throws when a balance address was not interned", async () => { + const { seeder, tx } = setup(); + const genesis = { ...buildParsedGenesis(), balances: [{ address: "akash1missing", coins: [{ denom: "uakt", amount: "1" }] }] }; + + await expect(seeder.seed(tx, genesis, context())).rejects.toThrow("No interned account id for balance address akash1missing"); + }); + + function context() { + return { + accountIdByAddress: new Map([ + ["akash1base", 1], + ["akash1module", 2], + ["akash1vesting", 3] + ]), + initialHeight: 1 + }; + } + + function setup() { + const { tx, inserts } = buildTxFake(); + return { seeder: new BankSeeder(), tx, inserts }; + } +}); diff --git a/apps/chain-indexer/src/genesis/bank-seeder.service.ts b/apps/chain-indexer/src/genesis/bank-seeder.service.ts new file mode 100644 index 0000000000..5b27d5f690 --- /dev/null +++ b/apps/chain-indexer/src/genesis/bank-seeder.service.ts @@ -0,0 +1,44 @@ +import { singleton } from "tsyringe"; + +import { insertChunked } from "@src/db/insert-chunked"; +import { AccountBalances, BalanceChanges } from "@src/db/schema"; +import type { ParsedGenesis } from "@src/genesis/genesis-schema"; +import type { GenesisModuleSeeder, GenesisSeedContext } from "@src/genesis/genesis-seed-context"; +import type { ChainTransaction } from "@src/providers/db.provider"; + +@singleton() +export class BankSeeder implements GenesisModuleSeeder { + /** + * Seeds every genesis balance as both a current-balance row and a `genesis`-reason ledger entry. + * Seeding all `bank.balances` (module and vesting accounts included) makes the current-balance total + * reconcile to `bank.supply` by construction. Idempotency comes from the import marker, so the ledger + * insert intentionally has no conflict target. + */ + async seed(tx: ChainTransaction, genesis: ParsedGenesis, context: GenesisSeedContext): Promise { + const balanceRows: (typeof AccountBalances.$inferInsert)[] = []; + const changeRows: (typeof BalanceChanges.$inferInsert)[] = []; + + for (const balance of genesis.balances) { + const accountId = context.accountIdByAddress.get(balance.address); + if (accountId === undefined) { + throw new Error(`No interned account id for balance address ${balance.address}`); + } + + for (const coin of balance.coins) { + balanceRows.push({ accountId, denom: coin.denom, amount: coin.amount }); + changeRows.push({ + accountId, + denom: coin.denom, + delta: coin.amount, + balanceAfter: coin.amount, + reason: "genesis", + height: context.initialHeight, + counterpartyAccountId: null + }); + } + } + + await insertChunked(tx, AccountBalances, balanceRows); + await insertChunked(tx, BalanceChanges, changeRows, { onConflictDoNothing: false }); + } +} diff --git a/apps/chain-indexer/src/genesis/genesis-address.spec.ts b/apps/chain-indexer/src/genesis/genesis-address.spec.ts new file mode 100644 index 0000000000..b718651786 --- /dev/null +++ b/apps/chain-indexer/src/genesis/genesis-address.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { consensusHexAddress, operatorToAccountAddress } from "@src/genesis/genesis-address"; + +const ED25519_PUBKEY_TYPE = "/cosmos.crypto.ed25519.PubKey"; +const SANDBOX_VALIDATOR_PUBKEY = "1YM8H2iPYXxzSEQeFJQipwRnWV4sB2EKgujqdeTYLJs="; + +describe("consensusHexAddress", () => { + it("derives the uppercased hex consensus address from an ed25519 pubkey", () => { + expect(consensusHexAddress(ED25519_PUBKEY_TYPE, SANDBOX_VALIDATOR_PUBKEY)).toBe("31410FDD5FF7717918AB0D32645E12B6863B2576"); + }); + + it("throws for a non-ed25519 pubkey type", () => { + expect(() => consensusHexAddress("/cosmos.crypto.secp256k1.PubKey", SANDBOX_VALIDATOR_PUBKEY)).toThrow("Unsupported consensus pubkey type"); + }); +}); + +describe("operatorToAccountAddress", () => { + it("re-encodes a valoper address as its account address over the same bytes", () => { + expect(operatorToAccountAddress("akashvaloper1dq9wvqemmpvanmwsdttajsn4hmtx5zk7cgw7cz")).toBe("akash1dq9wvqemmpvanmwsdttajsn4hmtx5zk7j2qcgg"); + }); +}); diff --git a/apps/chain-indexer/src/genesis/genesis-address.ts b/apps/chain-indexer/src/genesis/genesis-address.ts new file mode 100644 index 0000000000..bca386e366 --- /dev/null +++ b/apps/chain-indexer/src/genesis/genesis-address.ts @@ -0,0 +1,24 @@ +import { fromBase64, fromBech32, toBech32, toHex } from "@cosmjs/encoding"; +import { createHash } from "node:crypto"; + +const ED25519_PUBKEY_TYPE = "/cosmos.crypto.ed25519.PubKey"; + +/** + * Consensus (hex) address of a validator: the first 20 bytes of SHA-256 over the ed25519 pubkey, + * uppercased, matching CometBFT and the legacy indexer. Consensus keys are always ed25519, so an + * unexpected type is a hard error rather than a silently wrong address. + */ +export function consensusHexAddress(pubkeyType: string, pubkeyBase64: string): string { + if (pubkeyType !== ED25519_PUBKEY_TYPE) { + throw new Error(`Unsupported consensus pubkey type ${pubkeyType}`); + } + + const digest = createHash("sha256").update(fromBase64(pubkeyBase64)).digest(); + return toHex(digest.subarray(0, 20)).toUpperCase(); +} + +/** Re-encodes an operator (`akashvaloper…`) address as its account (`akash…`) address; the underlying bytes are identical. */ +export function operatorToAccountAddress(operatorAddress: string): string { + const { prefix, data } = fromBech32(operatorAddress); + return toBech32(prefix.replace(/valoper$/, ""), data); +} diff --git a/apps/chain-indexer/src/genesis/genesis-import.service.spec.ts b/apps/chain-indexer/src/genesis/genesis-import.service.spec.ts new file mode 100644 index 0000000000..db156e08f2 --- /dev/null +++ b/apps/chain-indexer/src/genesis/genesis-import.service.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { IndexerState } from "@src/db/schema"; +import type { AccountSeeder } from "@src/genesis/account-seeder.service"; +import type { BankSeeder } from "@src/genesis/bank-seeder.service"; +import { GenesisImportService } from "@src/genesis/genesis-import.service"; +import { GenesisMidChainError } from "@src/genesis/genesis-mid-chain-error"; +import type { GenesisSource } from "@src/genesis/genesis-source"; +import type { StakingSeeder } from "@src/genesis/staking-seeder.service"; +import type { ChainDatabase } from "@src/providers/db.provider"; +import type { LoggerService } from "@src/providers/logging.provider"; + +import { buildParsedGenesis } from "@test/fakes/genesis-fixtures"; + +describe(GenesisImportService.name, () => { + it("rejects a fresh start that is not at the genesis height", async () => { + const { service, accountSeeder } = setup(); + + await expect(service.ensureSeeded(500)).rejects.toBeInstanceOf(GenesisMidChainError); + expect(accountSeeder.intern).not.toHaveBeenCalled(); + }); + + it("skips seeding when the genesis marker already exists", async () => { + const { service, accountSeeder, bankSeeder, stakingSeeder } = setup({ existingMarker: true }); + + await service.ensureSeeded(1); + + expect(accountSeeder.intern).not.toHaveBeenCalled(); + expect(bankSeeder.seed).not.toHaveBeenCalled(); + expect(stakingSeeder.seed).not.toHaveBeenCalled(); + }); + + it("seeds all modules in one transaction and claims the marker at the genesis height", async () => { + const { service, accountSeeder, bankSeeder, stakingSeeder, markerInserts } = setup(); + + await service.ensureSeeded(1); + + expect(accountSeeder.intern).toHaveBeenCalledTimes(1); + expect(bankSeeder.seed).toHaveBeenCalledTimes(1); + expect(stakingSeeder.seed).toHaveBeenCalledTimes(1); + expect(markerInserts).toEqual([expect.objectContaining({ stream: "genesis", lastHeight: 1 })]); + }); + + it("still seeds when the genesis has unmodeled account types", async () => { + const { service, source, accountSeeder } = setup(); + source.fetchGenesis.mockResolvedValue({ ...buildParsedGenesis(), unknownAccountTypes: ["/cosmos.auth.v1beta1.SomethingNew"] }); + + await service.ensureSeeded(1); + + expect(accountSeeder.intern).toHaveBeenCalledTimes(1); + }); + + it("does not seed when another writer claimed the marker first", async () => { + const { service, accountSeeder, bankSeeder } = setup({ claimReturnsEmpty: true }); + + await service.ensureSeeded(1); + + expect(accountSeeder.intern).not.toHaveBeenCalled(); + expect(bankSeeder.seed).not.toHaveBeenCalled(); + }); + + describe("hasSeeded", () => { + it("reports true when the genesis marker exists", async () => { + const { service } = setup({ existingMarker: true }); + + expect(await service.hasSeeded()).toBe(true); + }); + + it("reports false when the genesis marker is absent", async () => { + const { service } = setup(); + + expect(await service.hasSeeded()).toBe(false); + }); + }); + + function setup(input?: { existingMarker?: boolean; claimReturnsEmpty?: boolean }) { + const source = mock(); + source.fetchGenesis.mockResolvedValue(buildParsedGenesis()); + + const accountSeeder = mock(); + accountSeeder.intern.mockResolvedValue(new Map([["akash1base", 1]])); + const bankSeeder = mock(); + const stakingSeeder = mock(); + + const markerInserts: Record[] = []; + const txFake = { + insert: (table: unknown) => ({ + values: (row: Record) => { + if (table === IndexerState) { + markerInserts.push(row); + } + return { onConflictDoNothing: () => ({ returning: () => Promise.resolve(input?.claimReturnsEmpty ? [] : [row]) }) }; + } + }) + }; + + const dbFake = { + select: () => ({ from: () => ({ where: () => Promise.resolve(input?.existingMarker ? [{ stream: "genesis", lastHeight: 1 }] : []) }) }), + transaction: (callback: (tx: unknown) => Promise) => callback(txFake) + }; + + const service = new GenesisImportService(dbFake as unknown as ChainDatabase, source, accountSeeder, bankSeeder, stakingSeeder, mock()); + return { service, source, accountSeeder, bankSeeder, stakingSeeder, markerInserts }; + } +}); diff --git a/apps/chain-indexer/src/genesis/genesis-import.service.ts b/apps/chain-indexer/src/genesis/genesis-import.service.ts new file mode 100644 index 0000000000..dc98f93096 --- /dev/null +++ b/apps/chain-indexer/src/genesis/genesis-import.service.ts @@ -0,0 +1,106 @@ +import { eq } from "drizzle-orm"; +import { inject, singleton } from "tsyringe"; + +import { IndexerState } from "@src/db/schema"; +import { AccountSeeder } from "@src/genesis/account-seeder.service"; +import { BankSeeder } from "@src/genesis/bank-seeder.service"; +import { GenesisMidChainError } from "@src/genesis/genesis-mid-chain-error"; +import type { GenesisSource } from "@src/genesis/genesis-source"; +import { GENESIS_SOURCE } from "@src/genesis/genesis-source"; +import { StakingSeeder } from "@src/genesis/staking-seeder.service"; +import type { ChainDatabase } from "@src/providers/db.provider"; +import { CHAIN_DB } from "@src/providers/db.provider"; +import { LoggerService } from "@src/providers/logging.provider"; + +export const GENESIS_STREAM = "genesis"; + +@singleton() +export class GenesisImportService { + readonly #db: ChainDatabase; + readonly #source: GenesisSource; + readonly #accountSeeder: AccountSeeder; + readonly #bankSeeder: BankSeeder; + readonly #stakingSeeder: StakingSeeder; + readonly #logger: LoggerService; + + constructor( + @inject(CHAIN_DB) db: ChainDatabase, + @inject(GENESIS_SOURCE) source: GenesisSource, + @inject(AccountSeeder) accountSeeder: AccountSeeder, + @inject(BankSeeder) bankSeeder: BankSeeder, + @inject(StakingSeeder) stakingSeeder: StakingSeeder, + @inject(LoggerService) logger: LoggerService + ) { + this.#db = db; + this.#source = source; + this.#accountSeeder = accountSeeder; + this.#bankSeeder = bankSeeder; + this.#stakingSeeder = stakingSeeder; + this.#logger = logger; + this.#logger.setContext("GENESIS_IMPORT"); + } + + /** + * Seeds genesis state exactly once, before the first block. Rejects a fresh balance-tracking start + * whose start height is not the network's genesis height, so balances can never begin mid-chain. + * Safe to call on every fresh start: the marker row makes a repeat run a no-op, and the whole seed + * commits in one transaction so a crash mid-seed rolls back and retries cleanly. + */ + async ensureSeeded(startHeight: number): Promise { + const genesis = await this.#source.fetchGenesis(); + + if (startHeight !== genesis.initialHeight) { + throw new GenesisMidChainError( + `Balance tracking must start at genesis height ${genesis.initialHeight}, but the effective start height is ${startHeight}. Set SYNC_START_HEIGHT=${genesis.initialHeight} to index from genesis.` + ); + } + + const marker = await this.#findMarker(); + if (marker) { + this.#logger.info({ event: "GENESIS_ALREADY_SEEDED", height: marker.lastHeight }); + return; + } + + if (genesis.unknownAccountTypes.length > 0) { + this.#logger.warn({ event: "GENESIS_UNKNOWN_ACCOUNT_TYPES", types: genesis.unknownAccountTypes }); + } + + await this.#db.transaction(async tx => { + const claimed = await tx + .insert(IndexerState) + .values({ stream: GENESIS_STREAM, lastHeight: genesis.initialHeight, updatedAt: new Date() }) + .onConflictDoNothing() + .returning(); + + if (claimed.length === 0) { + this.#logger.info({ event: "GENESIS_SEED_SKIPPED_CONCURRENT" }); + return; + } + + const accountIdByAddress = await this.#accountSeeder.intern(tx, genesis); + const context = { accountIdByAddress, initialHeight: genesis.initialHeight }; + await this.#bankSeeder.seed(tx, genesis, context); + await this.#stakingSeeder.seed(tx, genesis, context); + + this.#logger.info({ + event: "GENESIS_SEEDED", + chainId: genesis.chainId, + initialHeight: genesis.initialHeight, + accounts: accountIdByAddress.size, + balances: genesis.balances.length, + validators: genesis.validators.length, + delegations: genesis.delegations.length + }); + }); + } + + /** Whether the one-time genesis seed has already run. Lets the sync runner detect a resume that turned the flag on too late to seed. */ + async hasSeeded(): Promise { + return (await this.#findMarker()) !== undefined; + } + + async #findMarker() { + const [marker] = await this.#db.select().from(IndexerState).where(eq(IndexerState.stream, GENESIS_STREAM)); + return marker; + } +} diff --git a/apps/chain-indexer/src/genesis/genesis-mid-chain-error.ts b/apps/chain-indexer/src/genesis/genesis-mid-chain-error.ts new file mode 100644 index 0000000000..c41047de78 --- /dev/null +++ b/apps/chain-indexer/src/genesis/genesis-mid-chain-error.ts @@ -0,0 +1,2 @@ +/** Raised when balance tracking would start past the network's genesis height; fatal by design so balances are never seeded from an incomplete history. */ +export class GenesisMidChainError extends Error {} diff --git a/apps/chain-indexer/src/genesis/genesis-schema.spec.ts b/apps/chain-indexer/src/genesis/genesis-schema.spec.ts new file mode 100644 index 0000000000..e38e498cc5 --- /dev/null +++ b/apps/chain-indexer/src/genesis/genesis-schema.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import { parseGenesis } from "@src/genesis/genesis-schema"; + +import { buildParsedGenesis, buildRawGenesis } from "@test/fakes/genesis-fixtures"; + +describe("parseGenesis", () => { + it("normalizes a genesis document into the flat parsed shape", () => { + expect(parseGenesis(buildRawGenesis())).toEqual(buildParsedGenesis()); + }); + + it("seeds balances that total the reported supply", () => { + const genesis = parseGenesis(buildRawGenesis()); + + const balanceTotal = genesis.balances.flatMap(balance => balance.coins).reduce((sum, coin) => sum + BigInt(coin.amount), 0n); + const supplyTotal = genesis.supply.reduce((sum, coin) => sum + BigInt(coin.amount), 0n); + + expect(balanceTotal).toBe(supplyTotal); + }); + + it("collects unmodeled account types instead of failing", () => { + const raw = { + chain_id: "sandbox-2", + initial_height: "1", + app_state: { + auth: { + accounts: [ + { "@type": "/cosmos.auth.v1beta1.BaseAccount", address: "akash1base", account_number: "1" }, + { "@type": "/cosmos.auth.v1beta1.SomethingNew", address: "akash1weird" } + ] + } + } + }; + + const genesis = parseGenesis(raw); + + expect(genesis.accounts).toEqual([{ address: "akash1base", accountNumber: 1, accountType: "base", isModuleAccount: false }]); + expect(genesis.unknownAccountTypes).toEqual(["/cosmos.auth.v1beta1.SomethingNew"]); + }); + + it("maps validators from the staking module export shape", () => { + const raw = { + chain_id: "akashnet-2", + initial_height: "9455001", + app_state: { + staking: { + validators: [ + { + operator_address: "akashvaloper1dq9wvqemmpvanmwsdttajsn4hmtx5zk7cgw7cz", + consensus_pubkey: { "@type": "/cosmos.crypto.ed25519.PubKey", key: "1YM8H2iPYXxzSEQeFJQipwRnWV4sB2EKgujqdeTYLJs=" }, + description: { moniker: "mainnet-val", identity: "id", website: "site", security_contact: "sc", details: "d" }, + commission: { commission_rates: { rate: "0.050000000000000000", max_rate: "0.200000000000000000", max_change_rate: "0.010000000000000000" } }, + min_self_delegation: "1000000" + } + ] + } + } + }; + + const genesis = parseGenesis(raw); + + expect(genesis.initialHeight).toBe(9455001); + expect(genesis.validators).toEqual([ + { + operatorAddress: "akashvaloper1dq9wvqemmpvanmwsdttajsn4hmtx5zk7cgw7cz", + accountAddress: "akash1dq9wvqemmpvanmwsdttajsn4hmtx5zk7j2qcgg", + hexAddress: "31410FDD5FF7717918AB0D32645E12B6863B2576", + moniker: "mainnet-val", + identity: "id", + website: "site", + details: "d", + securityContact: "sc", + commissionRate: "0.050000000000000000", + commissionMaxRate: "0.200000000000000000", + commissionMaxChangeRate: "0.010000000000000000", + minSelfDelegation: "1000000" + } + ]); + }); + + it("records a null account number when genesis omits it", () => { + const genesis = parseGenesis({ + chain_id: "sandbox-2", + initial_height: "1", + app_state: { auth: { accounts: [{ "@type": "/cosmos.auth.v1beta1.BaseAccount", address: "akash1noacctnum" }] } } + }); + + expect(genesis.accounts).toEqual([{ address: "akash1noacctnum", accountNumber: null, accountType: "base", isModuleAccount: false }]); + }); + + it("falls back to a null account address when the validator operator address is malformed", () => { + const genesis = parseGenesis({ + chain_id: "sandbox-2", + initial_height: "1", + app_state: { + staking: { validators: [{ operator_address: "invalid-operator", description: { moniker: "x" }, commission: {}, min_self_delegation: "1" }] } + } + }); + + expect(genesis.validators[0].accountAddress).toBeNull(); + expect(genesis.validators[0].hexAddress).toBeNull(); + }); + + it("defaults the initial height to 1 and tolerates missing modules", () => { + const genesis = parseGenesis({ chain_id: "sandbox-2", app_state: {} }); + + expect(genesis.initialHeight).toBe(1); + expect(genesis.accounts).toEqual([]); + expect(genesis.balances).toEqual([]); + expect(genesis.validators).toEqual([]); + expect(genesis.delegations).toEqual([]); + }); + + it("throws when a required top-level field is missing", () => { + expect(() => parseGenesis({ app_state: {} })).toThrow(); + }); +}); diff --git a/apps/chain-indexer/src/genesis/genesis-schema.ts b/apps/chain-indexer/src/genesis/genesis-schema.ts new file mode 100644 index 0000000000..0f04d3a685 --- /dev/null +++ b/apps/chain-indexer/src/genesis/genesis-schema.ts @@ -0,0 +1,280 @@ +import { z } from "zod"; + +import { consensusHexAddress, operatorToAccountAddress } from "@src/genesis/genesis-address"; + +const BASE_ACCOUNT_TYPE = "/cosmos.auth.v1beta1.BaseAccount"; +const MODULE_ACCOUNT_TYPE = "/cosmos.auth.v1beta1.ModuleAccount"; +const VESTING_TYPE_PREFIX = "/cosmos.vesting."; +const MSG_CREATE_VALIDATOR_TYPE = "/cosmos.staking.v1beta1.MsgCreateValidator"; + +export type AccountType = "base" | "module" | "vesting"; + +export interface ParsedCoin { + denom: string; + amount: string; +} + +export interface ParsedAccount { + address: string; + accountNumber: number | null; + accountType: AccountType | null; + isModuleAccount: boolean; +} + +export interface ParsedBalance { + address: string; + coins: ParsedCoin[]; +} + +export interface ParsedValidator { + operatorAddress: string; + accountAddress: string | null; + hexAddress: string | null; + moniker: string | null; + identity: string | null; + website: string | null; + details: string | null; + securityContact: string | null; + commissionRate: string | null; + commissionMaxRate: string | null; + commissionMaxChangeRate: string | null; + minSelfDelegation: string | null; +} + +export interface ParsedDelegation { + delegatorAddress: string; + validatorOperatorAddress: string; + shares: string; +} + +export interface ParsedGenesis { + chainId: string; + initialHeight: number; + genesisTime: string; + bondDenom: string | null; + accounts: ParsedAccount[]; + /** Account `@type`s we don't model, surfaced so the caller can log them without failing the import. */ + unknownAccountTypes: string[]; + balances: ParsedBalance[]; + supply: ParsedCoin[]; + validators: ParsedValidator[]; + delegations: ParsedDelegation[]; +} + +const coinSchema = z.object({ denom: z.string(), amount: z.string() }); + +const baseAccountInnerSchema = z.object({ address: z.string().optional(), account_number: z.string().optional() }).passthrough(); + +const rawAccountSchema = z + .object({ + "@type": z.string(), + address: z.string().optional(), + account_number: z.string().optional(), + base_account: baseAccountInnerSchema.optional(), + base_vesting_account: z.object({ base_account: baseAccountInnerSchema.optional() }).passthrough().optional() + }) + .passthrough(); + +const descriptionSchema = z + .object({ + moniker: z.string().optional(), + identity: z.string().optional(), + website: z.string().optional(), + security_contact: z.string().optional(), + details: z.string().optional() + }) + .partial() + .optional(); + +const pubkeySchema = z.object({ "@type": z.string(), key: z.string() }); + +const commissionRatesSchema = z.object({ rate: z.string().optional(), max_rate: z.string().optional(), max_change_rate: z.string().optional() }); + +const stakingValidatorSchema = z + .object({ + operator_address: z.string(), + consensus_pubkey: pubkeySchema.nullish(), + description: descriptionSchema, + commission: z.object({ commission_rates: commissionRatesSchema.optional() }).partial().optional(), + min_self_delegation: z.string().optional() + }) + .passthrough(); + +const createValidatorMsgSchema = z + .object({ + "@type": z.string(), + validator_address: z.string(), + delegator_address: z.string().optional(), + pubkey: pubkeySchema.nullish(), + description: descriptionSchema, + commission: commissionRatesSchema.optional(), + min_self_delegation: z.string().optional() + }) + .passthrough(); + +const delegationSchema = z.object({ delegator_address: z.string(), validator_address: z.string(), shares: z.string() }); + +const genesisSchema = z + .object({ + chain_id: z.string(), + initial_height: z.string().optional(), + genesis_time: z.string().optional(), + app_state: z + .object({ + auth: z + .object({ accounts: z.array(rawAccountSchema).optional() }) + .partial() + .optional(), + bank: z + .object({ + balances: z.array(z.object({ address: z.string(), coins: z.array(coinSchema) })).optional(), + supply: z.array(coinSchema).optional() + }) + .partial() + .optional(), + staking: z + .object({ + params: z.object({ bond_denom: z.string().optional() }).partial().optional(), + validators: z.array(stakingValidatorSchema).optional(), + delegations: z.array(delegationSchema).optional() + }) + .partial() + .optional(), + genutil: z + .object({ gen_txs: z.array(z.object({ body: z.object({ messages: z.array(z.record(z.unknown())) }).passthrough() }).passthrough()).optional() }) + .partial() + .optional() + }) + .passthrough() + }) + .passthrough(); + +type RawAccount = z.infer; +type RawStakingValidator = z.infer; +type RawCreateValidatorMsg = z.infer; + +/** Parses and validates the subset of a Cosmos-SDK genesis document the seeders need, normalizing snake_case + `@type` shapes into flat types. */ +export function parseGenesis(raw: unknown): ParsedGenesis { + const genesis = genesisSchema.parse(raw); + const appState = genesis.app_state; + + const unknownAccountTypes = new Set(); + const accounts: ParsedAccount[] = []; + for (const rawAccount of appState.auth?.accounts ?? []) { + const account = toParsedAccount(rawAccount); + if (account) { + accounts.push(account); + } else { + unknownAccountTypes.add(rawAccount["@type"]); + } + } + + return { + chainId: genesis.chain_id, + initialHeight: parseInt(genesis.initial_height ?? "1"), + genesisTime: genesis.genesis_time ?? "", + bondDenom: appState.staking?.params?.bond_denom ?? null, + accounts, + unknownAccountTypes: [...unknownAccountTypes], + balances: (appState.bank?.balances ?? []).map(balance => ({ address: balance.address, coins: balance.coins })), + supply: appState.bank?.supply ?? [], + validators: [...(appState.staking?.validators ?? []).map(toValidatorFromStaking), ...gentxValidators(appState.genutil?.gen_txs ?? [])], + delegations: (appState.staking?.delegations ?? []).map(delegation => ({ + delegatorAddress: delegation.delegator_address, + validatorOperatorAddress: delegation.validator_address, + shares: delegation.shares + })) + }; +} + +function toParsedAccount(raw: RawAccount): ParsedAccount | null { + const type = raw["@type"]; + + if (type === BASE_ACCOUNT_TYPE && raw.address) { + return { address: raw.address, accountNumber: toNumberOrNull(raw.account_number), accountType: "base", isModuleAccount: false }; + } + + if (type === MODULE_ACCOUNT_TYPE && raw.base_account?.address) { + return { address: raw.base_account.address, accountNumber: toNumberOrNull(raw.base_account.account_number), accountType: "module", isModuleAccount: true }; + } + + if (type.startsWith(VESTING_TYPE_PREFIX) && raw.base_vesting_account?.base_account?.address) { + return { + address: raw.base_vesting_account.base_account.address, + accountNumber: toNumberOrNull(raw.base_vesting_account.base_account.account_number), + accountType: "vesting", + isModuleAccount: false + }; + } + + return null; +} + +function toValidatorFromStaking(validator: RawStakingValidator): ParsedValidator { + return { + operatorAddress: validator.operator_address, + accountAddress: safeOperatorToAccountAddress(validator.operator_address), + hexAddress: validator.consensus_pubkey ? consensusHexAddress(validator.consensus_pubkey["@type"], validator.consensus_pubkey.key) : null, + ...mapDescription(validator.description), + ...mapCommissionRates(validator.commission?.commission_rates), + minSelfDelegation: validator.min_self_delegation ?? null + }; +} + +function gentxValidators(genTxs: { body: { messages: Record[] } }[]): ParsedValidator[] { + return genTxs + .flatMap(genTx => genTx.body.messages) + .filter(message => message["@type"] === MSG_CREATE_VALIDATOR_TYPE) + .map(message => toValidatorFromGentx(createValidatorMsgSchema.parse(message))); +} + +function toValidatorFromGentx(message: RawCreateValidatorMsg): ParsedValidator { + return { + operatorAddress: message.validator_address, + accountAddress: message.delegator_address ?? safeOperatorToAccountAddress(message.validator_address), + hexAddress: message.pubkey ? consensusHexAddress(message.pubkey["@type"], message.pubkey.key) : null, + ...mapDescription(message.description), + ...mapCommissionRates(message.commission), + minSelfDelegation: message.min_self_delegation ?? null + }; +} + +/** The staking export nests commission rates under `commission.commission_rates`; a gentx message puts them directly under `commission`. Both resolve to `commissionRatesSchema`, so callers pass whichever their shape exposes. */ +function mapCommissionRates( + rates: z.infer | undefined +): Pick { + return { + commissionRate: rates?.rate ?? null, + commissionMaxRate: rates?.max_rate ?? null, + commissionMaxChangeRate: rates?.max_change_rate ?? null + }; +} + +function mapDescription( + description: z.infer +): Pick { + return { + moniker: description?.moniker ?? null, + identity: description?.identity ?? null, + website: description?.website ?? null, + details: description?.details ?? null, + securityContact: description?.security_contact ?? null + }; +} + +function safeOperatorToAccountAddress(operatorAddress: string): string | null { + try { + return operatorToAccountAddress(operatorAddress); + } catch { + return null; + } +} + +function toNumberOrNull(value: string | undefined): number | null { + if (value === undefined) { + return null; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/apps/chain-indexer/src/genesis/genesis-seed-context.ts b/apps/chain-indexer/src/genesis/genesis-seed-context.ts new file mode 100644 index 0000000000..18371a3e65 --- /dev/null +++ b/apps/chain-indexer/src/genesis/genesis-seed-context.ts @@ -0,0 +1,13 @@ +import type { ParsedGenesis } from "@src/genesis/genesis-schema"; +import type { ChainTransaction } from "@src/providers/db.provider"; + +/** Shared context passed to each module seeder: the interned address→id map plus the genesis height. */ +export interface GenesisSeedContext { + accountIdByAddress: ReadonlyMap; + initialHeight: number; +} + +/** A per-module genesis seeder, matching the design's `ModuleDefinition.genesisSeeder`. Runs inside the shared import transaction. */ +export interface GenesisModuleSeeder { + seed(tx: ChainTransaction, genesis: ParsedGenesis, context: GenesisSeedContext): Promise; +} diff --git a/apps/chain-indexer/src/genesis/genesis-source.spec.ts b/apps/chain-indexer/src/genesis/genesis-source.spec.ts new file mode 100644 index 0000000000..7d5ec12982 --- /dev/null +++ b/apps/chain-indexer/src/genesis/genesis-source.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { RpcGenesisSource } from "@src/genesis/genesis-source"; +import type { LoggerService } from "@src/providers/logging.provider"; +import type { RpcClientPool } from "@src/rpc/rpc-client-pool.service"; + +import { buildParsedGenesis, buildRawGenesis } from "@test/fakes/genesis-fixtures"; + +describe(RpcGenesisSource.name, () => { + it("reassembles multiple genesis chunks into the parsed document", async () => { + const { source, pool } = setup({ chunkCount: 3 }); + + const genesis = await source.fetchGenesis(); + + expect(genesis).toEqual(buildParsedGenesis()); + expect(pool.getGenesisChunk).toHaveBeenCalledTimes(3); + expect(pool.getGenesisChunk).toHaveBeenNthCalledWith(1, 0); + expect(pool.getGenesisChunk).toHaveBeenNthCalledWith(3, 2); + }); + + it("fetches a single-chunk genesis", async () => { + const { source, pool } = setup({ chunkCount: 1 }); + + await source.fetchGenesis(); + + expect(pool.getGenesisChunk).toHaveBeenCalledTimes(1); + }); + + it("rejects when the genesis chain-id does not match the node", async () => { + const { source } = setup({ chunkCount: 1, nodeChainId: "othernet" }); + + await expect(source.fetchGenesis()).rejects.toThrow('Genesis chain_id "sandbox-2" does not match the RPC chain-id "othernet"'); + }); + + it("rejects an invalid chunk total", async () => { + const { source, pool } = setup({ chunkCount: 1 }); + pool.getGenesisChunk.mockResolvedValueOnce({ chunk: "0", total: "0", data: "" }); + + await expect(source.fetchGenesis()).rejects.toThrow("Invalid genesis chunk total"); + }); + + function setup(input: { chunkCount: number; nodeChainId?: string }) { + const encodedChunks = toBase64Chunks(JSON.stringify(buildRawGenesis()), input.chunkCount); + + const pool = mock(); + pool.getGenesisChunk.mockImplementation(async chunk => ({ chunk: String(chunk), total: String(encodedChunks.length), data: encodedChunks[chunk] })); + pool.getStatus.mockResolvedValue({ node_info: { network: input.nodeChainId ?? "sandbox-2" }, sync_info: { latest_block_height: "100" } }); + + const source = new RpcGenesisSource(pool, mock()); + return { source, pool }; + } + + function toBase64Chunks(json: string, count: number): string[] { + const size = Math.ceil(json.length / count); + const chunks: string[] = []; + for (let offset = 0; offset < json.length; offset += size) { + chunks.push(Buffer.from(json.slice(offset, offset + size)).toString("base64")); + } + return chunks; + } +}); diff --git a/apps/chain-indexer/src/genesis/genesis-source.ts b/apps/chain-indexer/src/genesis/genesis-source.ts new file mode 100644 index 0000000000..2a66f61881 --- /dev/null +++ b/apps/chain-indexer/src/genesis/genesis-source.ts @@ -0,0 +1,60 @@ +import { fromBase64 } from "@cosmjs/encoding"; +import type { InjectionToken } from "tsyringe"; +import { container, inject, singleton } from "tsyringe"; + +import type { ParsedGenesis } from "@src/genesis/genesis-schema"; +import { parseGenesis } from "@src/genesis/genesis-schema"; +import { LoggerService } from "@src/providers/logging.provider"; +import { RpcClientPool } from "@src/rpc/rpc-client-pool.service"; + +/** Seam over where the genesis document comes from. Swapping to a GitHub-mirror source (for a large mainnet genesis) is a one-line re-registration. */ +export interface GenesisSource { + fetchGenesis(): Promise; +} + +export const GENESIS_SOURCE: InjectionToken = Symbol("GENESIS_SOURCE"); + +@singleton() +export class RpcGenesisSource implements GenesisSource { + readonly #pool: RpcClientPool; + readonly #logger: LoggerService; + + constructor(@inject(RpcClientPool) pool: RpcClientPool, @inject(LoggerService) logger: LoggerService) { + this.#pool = pool; + this.#logger = logger; + this.#logger.setContext("GENESIS_SOURCE"); + } + + /** Fetches genesis from the same RPC pool the indexer syncs from and asserts its chain-id matches, so balances can only be seeded for the chain being indexed. */ + async fetchGenesis(): Promise { + const genesis = parseGenesis(await this.#fetchRawGenesis()); + const chainId = (await this.#pool.getStatus()).node_info.network; + + if (genesis.chainId !== chainId) { + throw new Error(`Genesis chain_id "${genesis.chainId}" does not match the RPC chain-id "${chainId}"`); + } + + return genesis; + } + + async #fetchRawGenesis(): Promise { + const first = await this.#pool.getGenesisChunk(0); + const total = Number(first.total); + + if (!Number.isInteger(total) || total < 1) { + throw new Error(`Invalid genesis chunk total: ${JSON.stringify(first.total)}`); + } + + const encodedChunks: string[] = [first.data]; + for (let chunk = 1; chunk < total; chunk++) { + encodedChunks.push((await this.#pool.getGenesisChunk(chunk)).data); + } + + const decoded = Buffer.concat(encodedChunks.map(encoded => Buffer.from(fromBase64(encoded)))); + this.#logger.info({ event: "GENESIS_FETCHED", chunks: total, bytes: decoded.byteLength }); + + return JSON.parse(decoded.toString("utf8")); + } +} + +container.register(GENESIS_SOURCE, { useToken: RpcGenesisSource }); diff --git a/apps/chain-indexer/src/genesis/staking-seeder.service.spec.ts b/apps/chain-indexer/src/genesis/staking-seeder.service.spec.ts new file mode 100644 index 0000000000..faf5efbf28 --- /dev/null +++ b/apps/chain-indexer/src/genesis/staking-seeder.service.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { Delegations, Validators } from "@src/db/schema"; +import { StakingSeeder } from "@src/genesis/staking-seeder.service"; + +import { buildTxFake, rowsFor } from "@test/fakes/build-tx-fake"; +import { buildParsedGenesis } from "@test/fakes/genesis-fixtures"; + +describe(StakingSeeder.name, () => { + it("seeds validators from genesis", async () => { + const { seeder, tx, inserts } = setup(); + + await seeder.seed(tx, buildParsedGenesis(), context()); + + expect(rowsFor(inserts, Validators)).toEqual([ + expect.objectContaining({ + operatorAddress: "akashvaloper1dq9wvqemmpvanmwsdttajsn4hmtx5zk7cgw7cz", + accountAddress: "akash1dq9wvqemmpvanmwsdttajsn4hmtx5zk7j2qcgg", + hexAddress: "31410FDD5FF7717918AB0D32645E12B6863B2576", + moniker: "validator-01", + commissionRate: "0.100000000000000000", + minSelfDelegation: "1" + }) + ]); + }); + + it("resolves the delegator account id for each delegation", async () => { + const { seeder, tx, inserts } = setup(); + + await seeder.seed(tx, buildParsedGenesis(), context()); + + expect(rowsFor(inserts, Delegations)).toEqual([ + { delegatorAccountId: 1, validatorOperatorAddress: "akashvaloper1dq9wvqemmpvanmwsdttajsn4hmtx5zk7cgw7cz", shares: "1000000.000000000000000000" } + ]); + }); + + it("writes nothing when there are no validators or delegations", async () => { + const { seeder, tx, inserts } = setup(); + const genesis = { ...buildParsedGenesis(), validators: [], delegations: [] }; + + await seeder.seed(tx, genesis, context()); + + expect(inserts).toEqual([]); + }); + + it("throws when a delegator was not interned", async () => { + const { seeder, tx } = setup(); + const genesis = { ...buildParsedGenesis(), delegations: [{ delegatorAddress: "akash1missing", validatorOperatorAddress: "akashvaloper1x", shares: "1" }] }; + + await expect(seeder.seed(tx, genesis, context())).rejects.toThrow("No interned account id for delegator akash1missing"); + }); + + function context() { + return { accountIdByAddress: new Map([["akash1base", 1]]), initialHeight: 1 }; + } + + function setup() { + const { tx, inserts } = buildTxFake(); + return { seeder: new StakingSeeder(), tx, inserts }; + } +}); diff --git a/apps/chain-indexer/src/genesis/staking-seeder.service.ts b/apps/chain-indexer/src/genesis/staking-seeder.service.ts new file mode 100644 index 0000000000..1060e635e2 --- /dev/null +++ b/apps/chain-indexer/src/genesis/staking-seeder.service.ts @@ -0,0 +1,44 @@ +import { singleton } from "tsyringe"; + +import { insertChunked } from "@src/db/insert-chunked"; +import { Delegations, Validators } from "@src/db/schema"; +import type { ParsedGenesis } from "@src/genesis/genesis-schema"; +import type { GenesisModuleSeeder, GenesisSeedContext } from "@src/genesis/genesis-seed-context"; +import type { ChainTransaction } from "@src/providers/db.provider"; + +@singleton() +export class StakingSeeder implements GenesisModuleSeeder { + /** + * Seeds validators (from `staking.validators` and `genutil.gen_txs` create-validator messages) and + * explicit `staking.delegations`. Genesis gentx self-delegations are applied at InitChain rather than + * listed in `staking.delegations`, so they are intentionally not reconstructed here. + */ + async seed(tx: ChainTransaction, genesis: ParsedGenesis, context: GenesisSeedContext): Promise { + const validatorRows: (typeof Validators.$inferInsert)[] = genesis.validators.map(validator => ({ + operatorAddress: validator.operatorAddress, + accountAddress: validator.accountAddress, + hexAddress: validator.hexAddress, + moniker: validator.moniker, + identity: validator.identity, + website: validator.website, + details: validator.details, + securityContact: validator.securityContact, + commissionRate: validator.commissionRate, + commissionMaxRate: validator.commissionMaxRate, + commissionMaxChangeRate: validator.commissionMaxChangeRate, + minSelfDelegation: validator.minSelfDelegation + })); + + const delegationRows: (typeof Delegations.$inferInsert)[] = genesis.delegations.map(delegation => { + const delegatorAccountId = context.accountIdByAddress.get(delegation.delegatorAddress); + if (delegatorAccountId === undefined) { + throw new Error(`No interned account id for delegator ${delegation.delegatorAddress}`); + } + + return { delegatorAccountId, validatorOperatorAddress: delegation.validatorOperatorAddress, shares: delegation.shares }; + }); + + await insertChunked(tx, Validators, validatorRows); + await insertChunked(tx, Delegations, delegationRows); + } +} diff --git a/apps/chain-indexer/src/pipeline/block-committer.service.ts b/apps/chain-indexer/src/pipeline/block-committer.service.ts index 72c01f8f42..17935209f2 100644 --- a/apps/chain-indexer/src/pipeline/block-committer.service.ts +++ b/apps/chain-indexer/src/pipeline/block-committer.service.ts @@ -2,6 +2,7 @@ import { inArray, sql } from "drizzle-orm"; import chunk from "lodash/chunk"; import { inject, singleton } from "tsyringe"; +import { INSERT_CHUNK_SIZE } from "@src/db/insert-chunk-size"; import { Blocks, IndexerState, Messages, MessageTypes, Transactions } from "@src/db/schema"; import type { DecodedBlock } from "@src/pipeline/decoded-block"; import type { ChainDatabase } from "@src/providers/db.provider"; @@ -9,9 +10,6 @@ import { CHAIN_DB } from "@src/providers/db.provider"; export const SYNC_STREAM = "sync"; -/** Keeps multi-row inserts well under postgres.js's ~65k bind-parameter limit when batches span hundreds of blocks. */ -const INSERT_CHUNK_SIZE = 2_000; - @singleton() export class BlockCommitterService { readonly #db: ChainDatabase; diff --git a/apps/chain-indexer/src/pipeline/sync-runner.service.spec.ts b/apps/chain-indexer/src/pipeline/sync-runner.service.spec.ts index c8a446ce86..f998063442 100644 --- a/apps/chain-indexer/src/pipeline/sync-runner.service.spec.ts +++ b/apps/chain-indexer/src/pipeline/sync-runner.service.spec.ts @@ -3,6 +3,8 @@ import { mock } from "vitest-mock-extended"; import type { BlockArchiveService } from "@src/archive/block-archive.service"; import { envSchema } from "@src/config/env.config"; +import { Blocks, IndexerState } from "@src/db/schema"; +import type { GenesisImportService } from "@src/genesis/genesis-import.service"; import type { BlockCommitterService } from "@src/pipeline/block-committer.service"; import type { BlockDecoderService } from "@src/pipeline/block-decoder.service"; import type { DecodedBlock } from "@src/pipeline/decoded-block"; @@ -71,18 +73,96 @@ describe(SyncRunnerService.name, () => { }); }); - function setup(input: { tipHeight: number; archiveEnabled?: boolean; archiveFailure?: Error }) { + it("starts from the chain tip when no checkpoint or start height is configured", async () => { + const { runner, committer } = setup({ tipHeight: 4, omitStartHeight: true }); + + await runner.start(); + + expect(committer.commit).toHaveBeenCalledTimes(1); + expect(committer.commit).toHaveBeenCalledWith(expect.objectContaining({ height: 4 })); + }); + + describe("genesis import", () => { + it("runs the genesis import at the fresh start height when enabled", async () => { + const { runner, genesisImport } = setup({ tipHeight: 1, genesisImportEnabled: true }); + + await runner.start(); + + expect(genesisImport.ensureSeeded).toHaveBeenCalledWith(1); + }); + + it("does not run the genesis import when disabled", async () => { + const { runner, genesisImport } = setup({ tipHeight: 1 }); + + await runner.start(); + + expect(genesisImport.ensureSeeded).not.toHaveBeenCalled(); + }); + + it("does not run the genesis import when resuming from a checkpoint", async () => { + const { runner, genesisImport } = setup({ tipHeight: 2, genesisImportEnabled: true, checkpointHeight: 1 }); + + await runner.start(); + + expect(genesisImport.ensureSeeded).not.toHaveBeenCalled(); + }); + + it("halts before syncing when the genesis guard rejects a mid-chain start", async () => { + const { runner, genesisImport, committer } = setup({ tipHeight: 1, genesisImportEnabled: true }); + genesisImport.ensureSeeded.mockRejectedValue(new Error("mid-chain")); + + await expect(runner.start()).rejects.toThrow("mid-chain"); + expect(committer.commit).not.toHaveBeenCalled(); + }); + + it("warns when genesis import is enabled on resume but genesis was never seeded", async () => { + const { runner, genesisImport, logger } = setup({ tipHeight: 2, genesisImportEnabled: true, checkpointHeight: 1 }); + genesisImport.hasSeeded.mockResolvedValue(false); + + await runner.start(); + + expect(genesisImport.ensureSeeded).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ event: "GENESIS_IMPORT_SKIPPED_RESUMED_WITHOUT_MARKER" })); + }); + + it("does not warn when resuming an indexer that already seeded genesis", async () => { + const { runner, genesisImport, logger } = setup({ tipHeight: 2, genesisImportEnabled: true, checkpointHeight: 1 }); + genesisImport.hasSeeded.mockResolvedValue(true); + + await runner.start(); + + expect(logger.warn).not.toHaveBeenCalledWith(expect.objectContaining({ event: "GENESIS_IMPORT_SKIPPED_RESUMED_WITHOUT_MARKER" })); + }); + }); + + function setup(input: { + tipHeight: number; + archiveEnabled?: boolean; + archiveFailure?: Error; + genesisImportEnabled?: boolean; + checkpointHeight?: number; + omitStartHeight?: boolean; + }) { const archiveEnabled = input.archiveEnabled ?? true; const config = envSchema.parse({ POSTGRES_DB_URI: "postgres://unit:unit@localhost:5432/unit", - SYNC_START_HEIGHT: "1", - ARCHIVE_BUCKET: archiveEnabled ? "raw-blocks" : "" + ...(input.omitStartHeight ? {} : { SYNC_START_HEIGHT: "1" }), + ARCHIVE_BUCKET: archiveEnabled ? "raw-blocks" : "", + ...(input.genesisImportEnabled ? { GENESIS_IMPORT: "true" } : {}) }); const dbFake = { select: () => ({ - from: () => ({ - where: () => Promise.resolve([]) + from: (table: unknown) => ({ + where: () => { + if (table === IndexerState && input.checkpointHeight != null) { + return Promise.resolve([{ stream: "sync", lastHeight: input.checkpointHeight }]); + } + if (table === Blocks && input.checkpointHeight != null) { + return Promise.resolve([{ height: input.checkpointHeight, hash: Buffer.from(`hash-${input.checkpointHeight}`) }]); + } + return Promise.resolve([]); + } }) }) }; @@ -104,15 +184,16 @@ describe(SyncRunnerService.name, () => { } const committer = mock(); + const genesisImport = mock(); const logger = mock(); - const runner = new SyncRunnerService(dbFake as unknown as ChainDatabase, pool, decoder, committer, archive, config, logger); + const runner = new SyncRunnerService(dbFake as unknown as ChainDatabase, pool, decoder, committer, archive, genesisImport, config, logger); committer.commit.mockImplementation(async decoded => { if (decoded.height >= input.tipHeight) { await runner.dispose(); } }); - return { runner, archive, committer, logger, pool }; + return { runner, archive, committer, genesisImport, logger, pool }; } function buildDecodedBlock(height: number): DecodedBlock { diff --git a/apps/chain-indexer/src/pipeline/sync-runner.service.ts b/apps/chain-indexer/src/pipeline/sync-runner.service.ts index 35518c13b3..e37f38afdc 100644 --- a/apps/chain-indexer/src/pipeline/sync-runner.service.ts +++ b/apps/chain-indexer/src/pipeline/sync-runner.service.ts @@ -6,6 +6,7 @@ import { fetchRawBlock } from "@src/archive/archive-layout"; import { BlockArchiveService } from "@src/archive/block-archive.service"; import type { EnvConfig } from "@src/config/env.config"; import { Blocks, IndexerState } from "@src/db/schema"; +import { GenesisImportService } from "@src/genesis/genesis-import.service"; import { BlockCommitterService, SYNC_STREAM } from "@src/pipeline/block-committer.service"; import { BlockDecoderService } from "@src/pipeline/block-decoder.service"; import { ChainContinuityError } from "@src/pipeline/chain-continuity-error"; @@ -26,6 +27,7 @@ export class SyncRunnerService { readonly #decoder: BlockDecoderService; readonly #committer: BlockCommitterService; readonly #archive: BlockArchiveService; + readonly #genesisImport: GenesisImportService; readonly #config: EnvConfig; readonly #logger: LoggerService; @@ -38,6 +40,7 @@ export class SyncRunnerService { @inject(BlockDecoderService) decoder: BlockDecoderService, @inject(BlockCommitterService) committer: BlockCommitterService, @inject(BlockArchiveService) archive: BlockArchiveService, + @inject(GenesisImportService) genesisImport: GenesisImportService, @inject(APP_CONFIG) config: EnvConfig, @inject(LoggerService) logger: LoggerService ) { @@ -46,6 +49,7 @@ export class SyncRunnerService { this.#decoder = decoder; this.#committer = committer; this.#archive = archive; + this.#genesisImport = genesisImport; this.#config = config; this.#logger = logger; this.#logger.setContext("SYNC"); @@ -68,7 +72,13 @@ export class SyncRunnerService { } async #run(): Promise { - let nextHeight = await this.#resolveStartHeight(); + const { height, resumed } = await this.#resolveStartHeight(); + + if (this.#config.GENESIS_IMPORT) { + await this.#seedGenesisOrWarn(height, resumed); + } + + let nextHeight = height; this.#logger.info({ event: "SYNC_STARTED", network: this.#config.NETWORK, nextHeight }); this.#archive.logState(); @@ -88,6 +98,22 @@ export class SyncRunnerService { } } + /** + * Genesis is seeded only on a fresh start; a resume is already past genesis and seeding mid-chain is refused + * by design. Turning GENESIS_IMPORT on after an indexer already has a sync checkpoint would otherwise skip the + * seed with no trace, so warn when the flag is set on a resume whose genesis was never seeded. + */ + async #seedGenesisOrWarn(height: number, resumed: boolean): Promise { + if (!resumed) { + await this.#genesisImport.ensureSeeded(height); + return; + } + + if (!(await this.#genesisImport.hasSeeded())) { + this.#logger.warn({ event: "GENESIS_IMPORT_SKIPPED_RESUMED_WITHOUT_MARKER", height }); + } + } + async #retryTransient(operation: () => Promise, logContext: { event: string; height?: number }): Promise { return await retryTransient(operation, { isStopped: () => this.#stopped, logger: this.#logger, logContext }); } @@ -125,19 +151,20 @@ export class SyncRunnerService { } } - async #resolveStartHeight(): Promise { + /** `resumed` distinguishes continuing from an existing sync checkpoint from a fresh forward start, which gates whether the one-time genesis seed runs. */ + async #resolveStartHeight(): Promise<{ height: number; resumed: boolean }> { const [state] = await this.#db.select().from(IndexerState).where(eq(IndexerState.stream, SYNC_STREAM)); if (state) { const [checkpointBlock] = await this.#db.select().from(Blocks).where(eq(Blocks.height, state.lastHeight)); this.#lastHash = checkpointBlock?.hash ?? null; - return state.lastHeight + 1; + return { height: state.lastHeight + 1, resumed: true }; } if (this.#config.SYNC_START_HEIGHT) { - return this.#config.SYNC_START_HEIGHT; + return { height: this.#config.SYNC_START_HEIGHT, resumed: false }; } - return await this.#pool.getTipHeight(); + return { height: await this.#pool.getTipHeight(), resumed: false }; } } diff --git a/apps/chain-indexer/src/providers/db.provider.ts b/apps/chain-indexer/src/providers/db.provider.ts index b62480732b..092ee3e778 100644 --- a/apps/chain-indexer/src/providers/db.provider.ts +++ b/apps/chain-indexer/src/providers/db.provider.ts @@ -11,6 +11,10 @@ import { APP_CONFIG } from "@src/providers/app-config.provider"; const createDatabase = (c: DependencyContainer) => drizzle(c.resolve(PgClientService).client, { schema }); export type ChainDatabase = ReturnType; + +/** The transaction handle drizzle passes to a `db.transaction(async tx => …)` callback; lets services take a `tx` param without restating drizzle's generics. */ +export type ChainTransaction = Parameters[0]>[0]; + export const CHAIN_DB: InjectionToken = Symbol("CHAIN_DB"); container.register(CHAIN_DB, { diff --git a/apps/chain-indexer/src/rpc/rpc-client-pool.service.spec.ts b/apps/chain-indexer/src/rpc/rpc-client-pool.service.spec.ts index e506dd097d..6561c5120d 100644 --- a/apps/chain-indexer/src/rpc/rpc-client-pool.service.spec.ts +++ b/apps/chain-indexer/src/rpc/rpc-client-pool.service.spec.ts @@ -88,6 +88,16 @@ describe(RpcClientPool.name, () => { expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["http://node-a/block?height=7", "http://node-a/block_results?height=7"]); }); + it("requests a genesis chunk by index", async () => { + const { pool, fetchMock } = setup(); + fetchMock.mockResolvedValue(jsonResponse({ result: { chunk: "2", total: "5", data: "eyJ9" } })); + + const chunk = await pool.getGenesisChunk(2); + + expect(chunk).toEqual({ chunk: "2", total: "5", data: "eyJ9" }); + expect(fetchMock.mock.calls[0][0]).toBe("http://node-a/genesis_chunked?chunk=2"); + }); + function setup() { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); diff --git a/apps/chain-indexer/src/rpc/rpc-client-pool.service.ts b/apps/chain-indexer/src/rpc/rpc-client-pool.service.ts index 27f76063e4..3cee43a144 100644 --- a/apps/chain-indexer/src/rpc/rpc-client-pool.service.ts +++ b/apps/chain-indexer/src/rpc/rpc-client-pool.service.ts @@ -4,7 +4,7 @@ import { inject, singleton } from "tsyringe"; import type { EnvConfig } from "@src/config/env.config"; import { APP_CONFIG } from "@src/providers/app-config.provider"; import { LoggerService } from "@src/providers/logging.provider"; -import type { RpcBlockResult, RpcBlockResultsResult, RpcStatusResult } from "@src/rpc/rpc-types"; +import type { RpcBlockResult, RpcBlockResultsResult, RpcGenesisChunkResult, RpcStatusResult } from "@src/rpc/rpc-types"; interface RpcNodeState { endpoint: string; @@ -59,6 +59,10 @@ export class RpcClientPool { return await this.#get(`/block_results?height=${height}`); } + async getGenesisChunk(chunk: number): Promise { + return await this.#get(`/genesis_chunked?chunk=${chunk}`); + } + async #get(path: string): Promise { const errors: unknown[] = []; diff --git a/apps/chain-indexer/src/rpc/rpc-types.ts b/apps/chain-indexer/src/rpc/rpc-types.ts index 7b3da76653..60fbec7b23 100644 --- a/apps/chain-indexer/src/rpc/rpc-types.ts +++ b/apps/chain-indexer/src/rpc/rpc-types.ts @@ -38,3 +38,10 @@ export interface RpcBlockResultsResult { height: string; txs_results: RpcTxResult[] | null; } + +/** CometBFT `/genesis_chunked` response. `chunk`/`total` are marshaled as strings; `data` is base64-encoded genesis JSON. */ +export interface RpcGenesisChunkResult { + chunk: string | number; + total: string | number; + data: string; +} diff --git a/apps/chain-indexer/test/fakes/build-tx-fake.ts b/apps/chain-indexer/test/fakes/build-tx-fake.ts new file mode 100644 index 0000000000..0ca740a754 --- /dev/null +++ b/apps/chain-indexer/test/fakes/build-tx-fake.ts @@ -0,0 +1,39 @@ +import type { ChainTransaction } from "@src/providers/db.provider"; + +export interface RecordedInsert { + table: unknown; + rows: Record[]; +} + +/** + * Minimal drizzle-transaction double that records inserts and supports the seeders' + * `.values(...).onConflictDoNothing().returning()` chain. `.returning()` echoes each inserted row with an + * incrementing `id`, matching how genesis seeds an empty accounts table and reads the ids straight back. + */ +export function buildTxFake(): { tx: ChainTransaction; inserts: RecordedInsert[] } { + const inserts: RecordedInsert[] = []; + let nextId = 1; + + const tx = { + insert(table: unknown) { + return { + 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: () => Promise.resolve() + }); + } + }; + } + }; + + return { tx: tx as unknown as ChainTransaction, inserts }; +} + +export function rowsFor(inserts: RecordedInsert[], table: unknown): Record[] { + return inserts.filter(insert => insert.table === table).flatMap(insert => insert.rows); +} diff --git a/apps/chain-indexer/test/fakes/genesis-fixtures.ts b/apps/chain-indexer/test/fakes/genesis-fixtures.ts new file mode 100644 index 0000000000..6c95f9bdcb --- /dev/null +++ b/apps/chain-indexer/test/fakes/genesis-fixtures.ts @@ -0,0 +1,112 @@ +import type { ParsedGenesis } from "@src/genesis/genesis-schema"; + +const VALIDATOR_OPERATOR_ADDRESS = "akashvaloper1dq9wvqemmpvanmwsdttajsn4hmtx5zk7cgw7cz"; +const VALIDATOR_ACCOUNT_ADDRESS = "akash1dq9wvqemmpvanmwsdttajsn4hmtx5zk7j2qcgg"; +const VALIDATOR_PUBKEY = "1YM8H2iPYXxzSEQeFJQipwRnWV4sB2EKgujqdeTYLJs="; +const VALIDATOR_HEX_ADDRESS = "31410FDD5FF7717918AB0D32645E12B6863B2576"; + +/** + * A small but representative Cosmos-SDK genesis document: a base account, a module account, and a + * vesting account whose balances total exactly `bank.supply`, one validator created via a genutil + * gentx, and one explicit delegation. Mirrors the sandbox-2 gentx shape verified against live RPC. + */ +export function buildRawGenesis(): Record { + return { + chain_id: "sandbox-2", + initial_height: "1", + genesis_time: "2025-10-03T17:35:37Z", + app_state: { + auth: { + accounts: [ + { "@type": "/cosmos.auth.v1beta1.BaseAccount", address: "akash1base", account_number: "1", sequence: "0" }, + { + "@type": "/cosmos.auth.v1beta1.ModuleAccount", + base_account: { address: "akash1module", account_number: "2", sequence: "0" }, + name: "bonded_tokens_pool", + permissions: [] + }, + { + "@type": "/cosmos.vesting.v1beta1.ContinuousVestingAccount", + base_vesting_account: { + base_account: { address: "akash1vesting", account_number: "3", sequence: "0" }, + original_vesting: [{ denom: "uakt", amount: "20" }] + }, + start_time: "0" + } + ] + }, + bank: { + balances: [ + { address: "akash1base", coins: [{ denom: "uakt", amount: "10" }] }, + { address: "akash1module", coins: [{ denom: "uakt", amount: "5" }] }, + { address: "akash1vesting", coins: [{ denom: "uakt", amount: "20" }] } + ], + supply: [{ denom: "uakt", amount: "35" }] + }, + staking: { + params: { bond_denom: "uakt" }, + validators: [], + delegations: [{ delegator_address: "akash1base", validator_address: VALIDATOR_OPERATOR_ADDRESS, shares: "1000000.000000000000000000" }] + }, + genutil: { + gen_txs: [ + { + body: { + messages: [ + { + "@type": "/cosmos.staking.v1beta1.MsgCreateValidator", + description: { moniker: "validator-01", identity: "", website: "", security_contact: "", details: "" }, + commission: { rate: "0.100000000000000000", max_rate: "0.200000000000000000", max_change_rate: "0.010000000000000000" }, + min_self_delegation: "1", + delegator_address: VALIDATOR_ACCOUNT_ADDRESS, + validator_address: VALIDATOR_OPERATOR_ADDRESS, + pubkey: { "@type": "/cosmos.crypto.ed25519.PubKey", key: VALIDATOR_PUBKEY }, + value: { denom: "uakt", amount: "1000000" } + } + ] + } + } + ] + } + } + }; +} + +/** The exact `ParsedGenesis` that `parseGenesis(buildRawGenesis())` must produce. */ +export function buildParsedGenesis(): ParsedGenesis { + return { + chainId: "sandbox-2", + initialHeight: 1, + genesisTime: "2025-10-03T17:35:37Z", + bondDenom: "uakt", + accounts: [ + { address: "akash1base", accountNumber: 1, accountType: "base", isModuleAccount: false }, + { address: "akash1module", accountNumber: 2, accountType: "module", isModuleAccount: true }, + { address: "akash1vesting", accountNumber: 3, accountType: "vesting", isModuleAccount: false } + ], + unknownAccountTypes: [], + balances: [ + { address: "akash1base", coins: [{ denom: "uakt", amount: "10" }] }, + { address: "akash1module", coins: [{ denom: "uakt", amount: "5" }] }, + { address: "akash1vesting", coins: [{ denom: "uakt", amount: "20" }] } + ], + supply: [{ denom: "uakt", amount: "35" }], + validators: [ + { + operatorAddress: VALIDATOR_OPERATOR_ADDRESS, + accountAddress: VALIDATOR_ACCOUNT_ADDRESS, + hexAddress: VALIDATOR_HEX_ADDRESS, + moniker: "validator-01", + identity: "", + website: "", + details: "", + securityContact: "", + commissionRate: "0.100000000000000000", + commissionMaxRate: "0.200000000000000000", + commissionMaxChangeRate: "0.010000000000000000", + minSelfDelegation: "1" + } + ], + delegations: [{ delegatorAddress: "akash1base", validatorOperatorAddress: VALIDATOR_OPERATOR_ADDRESS, shares: "1000000.000000000000000000" }] + }; +}