From aa22344b61236a2d75cd921959d760e98989e969 Mon Sep 17 00:00:00 2001 From: feyishola Date: Sun, 27 Sep 2026 12:41:15 +0100 Subject: [PATCH] feat(backend): diagnostic XDR error parser and ledger-partitioned event_logs BE-004: Soroban RPC diagnostic event & XDR error code parser - DiagnosticParserService decodes diagnostic events, TransactionResult, TransactionMeta and simulation error strings into structured reports (contract error variant, auth failure, footprint/budget exhaustion, archived entries); never throws on malformed XDR - contract error registry generated from packages/contracts/*/src/errors.rs (20 contracts, 290 codes) with a sync test against the Rust sources - failed transactions are logged, persisted to failed_transactions and the submitting user is notified once (TRANSACTION_FAILED) BE-003: range partitioning of event_logs by ledger sequence - PartitionEventStore migration: 100k-ledger partitions, default partition, (contractId, topic0, ledger) composite index, verified zero-loss copy of existing rows and a lossless down() - event_logs_maintain_partitions() run by EventLogPartitionService on boot and every 10 minutes (and pg_cron when installed) - event_logs_max_ledger() helper keeps the indexer's latest-ledger lookup fast on the partitioned table - EventStoreService.getRawEventsByLedgerRange() range scan with keyset pagination; reconciliation now pushes its ledger range into SQL Also fixes resolveStartLedger concatenating the bigint ledger string. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/backend/package.json | 4 +- .../scripts/generate-contract-errors.js | 209 +++ ...000060000-CreateFailedTransactionsTable.ts | 63 + .../1760000070000-PartitionEventStore.ts | 452 +++++++ .../event-log-partition.service.spec.ts | 119 ++ .../event-log-partition.service.ts | 129 ++ .../src/event-store/event-store.module.ts | 10 +- .../event-store/event-store.service.spec.ts | 99 ++ .../src/event-store/event-store.service.ts | 72 ++ .../src/indexer/contract-errors.generated.ts | 1137 +++++++++++++++++ .../indexer/diagnostic-parser.service.spec.ts | 805 ++++++++++++ .../src/indexer/diagnostic-parser.service.ts | 962 ++++++++++++++ .../entities/failed-transaction.entity.ts | 68 + .../backend/src/indexer/event-log.entity.ts | 38 +- .../backend/src/indexer/indexer.module.ts | 7 +- .../src/indexer/indexer.service.spec.ts | 285 ++++- .../backend/src/indexer/indexer.service.ts | 183 ++- .../notifications/notification-type.enum.ts | 2 + .../reconciliation/reconciliation.service.ts | 14 +- 19 files changed, 4626 insertions(+), 32 deletions(-) create mode 100644 packages/backend/scripts/generate-contract-errors.js create mode 100644 packages/backend/src/database/migrations/1760000060000-CreateFailedTransactionsTable.ts create mode 100644 packages/backend/src/database/migrations/1760000070000-PartitionEventStore.ts create mode 100644 packages/backend/src/event-store/event-log-partition.service.spec.ts create mode 100644 packages/backend/src/event-store/event-log-partition.service.ts create mode 100644 packages/backend/src/indexer/contract-errors.generated.ts create mode 100644 packages/backend/src/indexer/diagnostic-parser.service.spec.ts create mode 100644 packages/backend/src/indexer/diagnostic-parser.service.ts create mode 100644 packages/backend/src/indexer/entities/failed-transaction.entity.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index ae0bebd0..b90ac6a6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -23,7 +23,9 @@ "typeorm:generate": "npm run typeorm -- migration:generate", "typeorm:run": "npm run typeorm -- migration:run", "typeorm:revert": "npm run typeorm -- migration:revert", - "typeorm:show": "npm run typeorm -- migration:show" + "typeorm:show": "npm run typeorm -- migration:show", + "generate:contract-errors": "node scripts/generate-contract-errors.js", + "check:contract-errors": "node scripts/generate-contract-errors.js --check" }, "dependencies": { "@nestjs/axios": "^4.0.1", diff --git a/packages/backend/scripts/generate-contract-errors.js b/packages/backend/scripts/generate-contract-errors.js new file mode 100644 index 00000000..55a67225 --- /dev/null +++ b/packages/backend/scripts/generate-contract-errors.js @@ -0,0 +1,209 @@ +#!/usr/bin/env node +/** + * Generates src/indexer/contract-errors.generated.ts from every + * public `#[contracterror]` enum under packages/contracts//src/errors.rs + * (falling back to src/lib.rs for crates without an errors.rs). + * + * The Rust enums are the single source of truth for contract error codes; the + * backend's DiagnosticParserService uses the generated registry to translate + * `Error(Contract, #N)` host errors into human-readable messages. + * + * Usage: + * node scripts/generate-contract-errors.js # write the file + * node scripts/generate-contract-errors.js --check # exit 1 if out of date + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const BACKEND_ROOT = path.resolve(__dirname, '..'); +const CONTRACTS_ROOT = path.resolve(BACKEND_ROOT, '..', 'contracts'); +const OUTPUT = path.join( + BACKEND_ROOT, + 'src', + 'indexer', + 'contract-errors.generated.ts', +); + +/** "StakeBelowMinimum" -> "Stake below minimum". */ +function humanize(name) { + const words = name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .toLowerCase(); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +/** Turn a Rust doc comment into a single user-facing sentence. */ +function cleanDoc(lines) { + const text = lines + .join(' ') + .replace(/`/g, '') + .replace(/\[([^\]]+)\]/g, '$1') + .replace(/^#\d+:\s*/, '') + .replace(/\s+/g, ' ') + .trim(); + if (!text) return ''; + // Don't capitalise a leading identifier such as `stake_amount`. + const firstWord = text.split(' ')[0]; + const sentence = /[_\d]/.test(firstWord) + ? text + : text.charAt(0).toUpperCase() + text.slice(1); + return /[.!?]$/.test(sentence) ? sentence : `${sentence}.`; +} + +/** Parse every #[contracterror] enum in one errors.rs source. */ +function parseErrorEnums(source) { + const enums = []; + const enumRe = /#\[contracterror\][\s\S]*?pub enum (\w+)\s*\{([\s\S]*?)\n\}/g; + let match; + while ((match = enumRe.exec(source)) !== null) { + const [, enumName, body] = match; + const variants = []; + let doc = []; + for (const rawLine of body.split(/\r?\n/)) { + const line = rawLine.trim(); + const docMatch = /^\/\/\/\s?(.*)$/.exec(line); + if (docMatch) { + doc.push(docMatch[1]); + continue; + } + const variantMatch = /^(\w+)\s*=\s*(\d+)\s*,?/.exec(line); + if (variantMatch) { + const [, name, code] = variantMatch; + variants.push({ + code: Number(code), + name, + message: cleanDoc(doc) || `${humanize(name)}.`, + }); + } + if (line && !line.startsWith('//')) doc = []; + } + variants.sort((a, b) => a.code - b.code); + enums.push({ enumName, variants }); + } + return enums; +} + +function collectRegistry() { + const crates = fs + .readdirSync(CONTRACTS_ROOT, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + + const registry = []; + for (const crate of crates) { + // errors.rs is the convention; a few single-file crates (e.g. + // prediction_market_futures) declare their enum in lib.rs instead. + // Private mirror enums used for cross-contract calls are not `pub` and + // are skipped by parseErrorEnums. + const file = ['errors.rs', 'lib.rs'] + .map((f) => path.join(CONTRACTS_ROOT, crate, 'src', f)) + .find((f) => fs.existsSync(f)); + if (!file) continue; + const enums = parseErrorEnums(fs.readFileSync(file, 'utf8')); + if (enums.length === 0) continue; + // Each crate exposes exactly one contract error enum; the first wins if + // a crate ever declares more (a second would collide on codes anyway). + registry.push({ crate, ...enums[0] }); + } + return registry; +} + +function render(registry) { + const q = (s) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + const out = []; + out.push('/* eslint-disable */'); + out.push( + '// AUTO-GENERATED by scripts/generate-contract-errors.js — DO NOT EDIT.', + ); + out.push('// Source of truth: packages/contracts/*/src/errors.rs'); + out.push( + '// Regenerate with: pnpm --filter backend generate:contract-errors', + ); + out.push(''); + out.push('export interface ContractErrorDefinition {'); + out.push(' code: number;'); + out.push(' name: string;'); + out.push(' message: string;'); + out.push('}'); + out.push(''); + out.push('export interface ContractErrorEnum {'); + out.push(' /** Rust crate directory under packages/contracts. */'); + out.push(' contract: string;'); + out.push(' /** Rust `#[contracterror]` enum name. */'); + out.push(' enumName: string;'); + out.push(' errors: Readonly>;'); + out.push('}'); + out.push(''); + out.push( + 'export const CONTRACT_ERROR_REGISTRY: Readonly> = {', + ); + for (const { crate, enumName, variants } of registry) { + out.push(` ${crate}: {`); + out.push(` contract: ${q(crate)},`); + out.push(` enumName: ${q(enumName)},`); + out.push(' errors: {'); + for (const v of variants) { + out.push( + ` ${v.code}: { code: ${v.code}, name: ${q(v.name)}, message: ${q(v.message)} },`, + ); + } + out.push(' },'); + out.push(' },'); + } + out.push('};'); + out.push(''); + return out.join('\n'); +} + +/** + * Format with the repo's prettier config so the pre-commit lint-staged + * `prettier --write` hook leaves the generated file untouched. + */ +async function format(content) { + try { + const prettier = require('prettier'); + const options = (await prettier.resolveConfig(OUTPUT)) ?? {}; + return await prettier.format(content, { ...options, filepath: OUTPUT }); + } catch (err) { + console.warn(`prettier unavailable, writing unformatted: ${err.message}`); + return content; + } +} + +async function main() { + if (!fs.existsSync(CONTRACTS_ROOT)) { + console.error(`Contracts directory not found: ${CONTRACTS_ROOT}`); + process.exit(1); + } + const content = await format(render(collectRegistry())); + + if (process.argv.includes('--check')) { + const current = fs.existsSync(OUTPUT) + ? fs.readFileSync(OUTPUT, 'utf8') + : ''; + if (current.replace(/\r\n/g, '\n') !== content) { + console.error( + 'contract-errors.generated.ts is out of date — run generate:contract-errors', + ); + process.exit(1); + } + console.log('contract-errors.generated.ts is up to date'); + return; + } + + fs.writeFileSync(OUTPUT, content, 'utf8'); + console.log(`Wrote ${path.relative(BACKEND_ROOT, OUTPUT)}`); +} + +if (require.main === module) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} + +module.exports = { parseErrorEnums, collectRegistry, render, humanize }; diff --git a/packages/backend/src/database/migrations/1760000060000-CreateFailedTransactionsTable.ts b/packages/backend/src/database/migrations/1760000060000-CreateFailedTransactionsTable.ts new file mode 100644 index 00000000..7332b34f --- /dev/null +++ b/packages/backend/src/database/migrations/1760000060000-CreateFailedTransactionsTable.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Failed Soroban transaction index (BE-004). + * + * One row per failed transaction hash with the parsed diagnostic report + * (contract error variant, auth / footprint flags, host messages) so users + * can be notified with a human-readable reason and operators can debug + * failures after the RPC's short retention window has expired. + */ +export class CreateFailedTransactionsTable1760000060000 implements MigrationInterface { + name = 'CreateFailedTransactionsTable1760000060000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "failed_transactions" ( + "id" SERIAL NOT NULL, + "txHash" character varying(64) NOT NULL, + "contractId" character varying(64), + "userAddress" character varying(64), + "ledger" bigint, + "resultCode" character varying(64), + "operationResultCode" character varying(64), + "errorCategory" character varying(32), + "errorName" character varying(128), + "errorCode" integer, + "message" text NOT NULL, + "diagnostics" jsonb NOT NULL, + "notified" boolean NOT NULL DEFAULT false, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_failed_transactions" PRIMARY KEY ("id"), + CONSTRAINT "UQ_failed_transactions_txHash" UNIQUE ("txHash") + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_failed_transactions_contractId" + ON "failed_transactions" ("contractId") + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_failed_transactions_userAddress" + ON "failed_transactions" ("userAddress") + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_failed_transactions_errorCategory" + ON "failed_transactions" ("errorCategory") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_failed_transactions_errorCategory"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_failed_transactions_userAddress"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_failed_transactions_contractId"`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS "failed_transactions"`); + } +} diff --git a/packages/backend/src/database/migrations/1760000070000-PartitionEventStore.ts b/packages/backend/src/database/migrations/1760000070000-PartitionEventStore.ts new file mode 100644 index 00000000..76e851b2 --- /dev/null +++ b/packages/backend/src/database/migrations/1760000070000-PartitionEventStore.ts @@ -0,0 +1,452 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Range-partitions the raw `event_logs` table by ledger sequence (BE-003). + * + * Layout + * event_logs PARTITION BY RANGE ("ledger") + * ├─ event_logs_p0000000 ledgers [0, 100000) + * ├─ event_logs_p0000001 ledgers [100000, 200000) + * ├─ ... one partition per 100,000 ledgers + * └─ event_logs_default safety net — catches rows no partition covers + * yet, so an insert can never fail + * + * Range scans on (contractId, topic0, ledger) prune to the partitions that + * overlap the requested ledger window and then use the composite B-Tree + * index inside each one, instead of walking one ever-growing table. + * + * Partition maintenance + * Postgres cannot create a partition from a trigger on the table being + * inserted into ("cannot CREATE TABLE .. PARTITION OF because it is being + * used by active queries"), so maintenance is a plpgsql function — + * event_logs_maintain_partitions() — that: + * 1. rescues rows stranded in event_logs_default into proper partitions, + * 2. pre-creates partitions ahead of the highest indexed ledger. + * It is invoked automatically by EventLogPartitionService (NestJS cron, + * on boot and every 10 minutes) and, when the pg_cron extension is + * installed, also scheduled inside the database. + * + * Zero data loss + * An existing unpartitioned event_logs (created by `synchronize`) is + * renamed, copied into the partitioned table and row-count verified before + * it is dropped. Any mismatch raises, rolling the whole migration back. + */ + +const PARTITION_SIZE = 100_000; +const LOOKAHEAD_PARTITIONS = 2; + +export class PartitionEventStore1760000070000 implements MigrationInterface { + name = 'PartitionEventStore1760000070000'; + + public async up(queryRunner: QueryRunner): Promise { + const [{ kind }] = (await queryRunner.query(` + SELECT (SELECT c.relkind::text FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() AND c.relname = 'event_logs') AS kind + `)) as Array<{ kind: string | null }>; + + if (kind === 'p') return; // already partitioned + + const hasLegacy = kind === 'r'; + if (hasLegacy) { + await queryRunner.query( + `ALTER TABLE "event_logs" RENAME TO "event_logs_legacy"`, + ); + // RENAME TABLE keeps index/constraint names, which would collide with + // the partitioned table's PK_event_logs / IDX_event_logs_* below. + await queryRunner.query(` + DO $$ + DECLARE idx record; + BEGIN + FOR idx IN + SELECT ic.relname AS name FROM pg_index i + JOIN pg_class ic ON ic.oid = i.indexrelid + WHERE i.indrelid = 'event_logs_legacy'::regclass + LOOP + EXECUTE format('ALTER INDEX %I RENAME TO %I', + idx.name, left(idx.name, 48) || '_legacy'); + END LOOP; + END $$ + `); + // Keep the id sequence alive when the legacy table is dropped. + await queryRunner.query(` + DO $$ + DECLARE seq text := pg_get_serial_sequence('event_logs_legacy', 'id'); + BEGIN + IF seq IS NOT NULL THEN + EXECUTE format('ALTER SEQUENCE %s OWNED BY NONE', seq); + IF seq <> 'public.event_logs_id_seq' AND to_regclass('public.event_logs_id_seq') IS NULL THEN + EXECUTE format('ALTER SEQUENCE %s RENAME TO event_logs_id_seq', seq); + END IF; + END IF; + END $$ + `); + } + + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS "event_logs_id_seq" AS integer`, + ); + + await queryRunner.query(` + CREATE TABLE "event_logs" ( + "id" integer NOT NULL DEFAULT nextval('event_logs_id_seq'), + "eventId" character varying NOT NULL, + "pagingToken" character varying NOT NULL, + "contractId" character varying(64) NOT NULL, + "topic0" character varying(64), + "eventType" character varying(50) NOT NULL, + "ledger" bigint NOT NULL, + "txHash" character varying(64) NOT NULL, + "txOrder" integer NOT NULL, + "eventData" jsonb NOT NULL, + "timestamp" TIMESTAMP NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_event_logs" PRIMARY KEY ("id", "ledger") + ) PARTITION BY RANGE ("ledger") + `); + await queryRunner.query( + `ALTER SEQUENCE "event_logs_id_seq" OWNED BY "event_logs"."id"`, + ); + + await queryRunner.query( + `CREATE TABLE "event_logs_default" PARTITION OF "event_logs" DEFAULT`, + ); + + // Indexes on the parent cascade to every existing and future partition. + await queryRunner.query(` + CREATE INDEX "IDX_event_logs_contract_topic0_ledger" + ON "event_logs" ("contractId", "topic0", "ledger") + `); + await queryRunner.query( + `CREATE INDEX "IDX_event_logs_ledger" ON "event_logs" ("ledger")`, + ); + await queryRunner.query(` + CREATE INDEX "IDX_event_logs_eventType_ledger" + ON "event_logs" ("eventType", "ledger") + `); + await queryRunner.query( + `CREATE INDEX "IDX_event_logs_txHash" ON "event_logs" ("txHash")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_event_logs_eventId" ON "event_logs" ("eventId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_event_logs_timestamp" ON "event_logs" ("timestamp")`, + ); + + await this.createMaintenanceFunctions(queryRunner); + + if (hasLegacy) { + await this.copyLegacyRows(queryRunner); + } + + await queryRunner.query( + `SELECT event_logs_maintain_partitions(${LOOKAHEAD_PARTITIONS})`, + ); + + await this.schedulePgCron(queryRunner); + } + + public async down(queryRunner: QueryRunner): Promise { + const [{ kind }] = (await queryRunner.query(` + SELECT (SELECT c.relkind::text FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() AND c.relname = 'event_logs') AS kind + `)) as Array<{ kind: string | null }>; + if (kind !== 'p') return; + + await queryRunner.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN + PERFORM cron.unschedule(jobid) FROM cron.job + WHERE jobname = 'event_logs_maintain_partitions'; + END IF; + END $$ + `); + + // Rebuild a plain table and copy everything back — no data loss on revert. + await queryRunner.query(` + CREATE TABLE "event_logs_unpartitioned" ( + "id" integer NOT NULL DEFAULT nextval('event_logs_id_seq'), + "eventId" character varying NOT NULL, + "pagingToken" character varying NOT NULL, + "contractId" character varying(64) NOT NULL, + "topic0" character varying(64), + "eventType" character varying(50) NOT NULL, + "ledger" bigint NOT NULL, + "txHash" character varying(64) NOT NULL, + "txOrder" integer NOT NULL, + "eventData" jsonb NOT NULL, + "timestamp" TIMESTAMP NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_event_logs_unpartitioned" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + INSERT INTO "event_logs_unpartitioned" + ("id", "eventId", "pagingToken", "contractId", "topic0", "eventType", + "ledger", "txHash", "txOrder", "eventData", "timestamp", "createdAt") + SELECT "id", "eventId", "pagingToken", "contractId", "topic0", "eventType", + "ledger", "txHash", "txOrder", "eventData", "timestamp", "createdAt" + FROM "event_logs" + `); + await this.assertSameRowCount( + queryRunner, + 'event_logs', + 'event_logs_unpartitioned', + ); + + await queryRunner.query(`ALTER SEQUENCE "event_logs_id_seq" OWNED BY NONE`); + await queryRunner.query(`DROP TABLE "event_logs" CASCADE`); + await this.dropMaintenanceFunctions(queryRunner); + await queryRunner.query( + `ALTER TABLE "event_logs_unpartitioned" RENAME TO "event_logs"`, + ); + await queryRunner.query( + `ALTER TABLE "event_logs" RENAME CONSTRAINT "PK_event_logs_unpartitioned" TO "PK_event_logs"`, + ); + await queryRunner.query( + `ALTER SEQUENCE "event_logs_id_seq" OWNED BY "event_logs"."id"`, + ); + + for (const col of [ + 'eventId', + 'contractId', + 'eventType', + 'ledger', + 'txHash', + 'timestamp', + ]) { + await queryRunner.query( + `CREATE INDEX "IDX_event_logs_${col}" ON "event_logs" ("${col}")`, + ); + } + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private async createMaintenanceFunctions( + queryRunner: QueryRunner, + ): Promise { + /** + * Create (idempotently) the partition covering [p_start, p_start + size). + * Rows already sitting in the default partition for that range are moved + * into the new table *before* it is attached, because ATTACH PARTITION + * refuses to proceed while the default partition holds matching rows. + */ + await queryRunner.query(` + CREATE OR REPLACE FUNCTION event_logs_create_partition(p_start bigint) + RETURNS boolean AS $$ + DECLARE + v_size constant bigint := ${PARTITION_SIZE}; + v_start bigint := (p_start / v_size) * v_size; + v_end bigint := v_start + v_size; + v_name text := format('event_logs_p%s', lpad((v_start / v_size)::text, 7, '0')); + v_moved bigint; + BEGIN + IF v_start < 0 THEN + RETURN false; + END IF; + IF to_regclass(format('%I.%I', current_schema(), v_name)) IS NOT NULL THEN + RETURN false; + END IF; + + EXECUTE format( + 'CREATE TABLE %I (LIKE event_logs INCLUDING DEFAULTS INCLUDING CONSTRAINTS)', + v_name); + + EXECUTE format( + 'WITH moved AS (DELETE FROM event_logs_default + WHERE "ledger" >= %s AND "ledger" < %s RETURNING *) + INSERT INTO %I SELECT * FROM moved', v_start, v_end, v_name); + GET DIAGNOSTICS v_moved = ROW_COUNT; + + -- A matching CHECK constraint lets ATTACH skip its validation scan. + EXECUTE format( + 'ALTER TABLE %I ADD CONSTRAINT %I CHECK ("ledger" >= %s AND "ledger" < %s)', + v_name, v_name || '_range', v_start, v_end); + EXECUTE format( + 'ALTER TABLE event_logs ATTACH PARTITION %I FOR VALUES FROM (%s) TO (%s)', + v_name, v_start, v_end); + EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', v_name, v_name || '_range'); + + RAISE NOTICE 'event_logs: created partition % [%, %) moved % row(s) from default', + v_name, v_start, v_end, v_moved; + RETURN true; + END; + $$ LANGUAGE plpgsql + `); + + /** + * Rescue stranded rows from the default partition, then make sure the + * partition holding the highest known ledger (the max indexed ledger, or + * p_current_ledger if the caller knows the chain tip) and the next + * p_lookahead partitions exist. Returns the number of partitions created. + */ + await queryRunner.query(` + CREATE OR REPLACE FUNCTION event_logs_maintain_partitions( + p_lookahead integer DEFAULT ${LOOKAHEAD_PARTITIONS}, + p_current_ledger bigint DEFAULT NULL + ) + RETURNS integer AS $$ + DECLARE + v_size constant bigint := ${PARTITION_SIZE}; + v_created integer := 0; + v_head bigint; + r record; + BEGIN + -- Serialise concurrent maintainers (cron + app instances). + PERFORM pg_advisory_xact_lock(hashtext('event_logs_maintain_partitions')); + + FOR r IN + SELECT DISTINCT ("ledger" / v_size) * v_size AS start + FROM event_logs_default + LOOP + IF event_logs_create_partition(r.start) THEN + v_created := v_created + 1; + END IF; + END LOOP; + + SELECT GREATEST(COALESCE(MAX("ledger"), 0), COALESCE(p_current_ledger, 0)) + INTO v_head FROM event_logs; + + FOR i IN 0..GREATEST(p_lookahead, 0) LOOP + IF event_logs_create_partition(((v_head / v_size) + i) * v_size) THEN + v_created := v_created + 1; + END IF; + END LOOP; + + RETURN v_created; + END; + $$ LANGUAGE plpgsql + `); + + /** + * Highest indexed ledger, probing partitions newest-first. A plain + * `ORDER BY ledger DESC LIMIT 1` on the parent has to plan a Merge Append + * over every partition (~10ms at 200 partitions); this touches the + * default partition plus the first non-empty named partition (~0.5ms). + * Partition names are zero-padded, so name order == range order. + */ + await queryRunner.query(` + CREATE OR REPLACE FUNCTION event_logs_max_ledger() + RETURNS bigint AS $$ + DECLARE + r record; + v_default bigint; + v bigint; + BEGIN + SELECT MAX("ledger") INTO v_default FROM event_logs_default; + FOR r IN + SELECT c.relname FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + WHERE i.inhparent = 'event_logs'::regclass + AND c.relname <> 'event_logs_default' + ORDER BY c.relname DESC + LOOP + EXECUTE format('SELECT MAX("ledger") FROM %I', r.relname) INTO v; + IF v IS NOT NULL THEN + RETURN GREATEST(v, v_default); + END IF; + END LOOP; + RETURN v_default; + END; + $$ LANGUAGE plpgsql STABLE + `); + } + + private async dropMaintenanceFunctions( + queryRunner: QueryRunner, + ): Promise { + await queryRunner.query(`DROP FUNCTION IF EXISTS event_logs_max_ledger()`); + await queryRunner.query( + `DROP FUNCTION IF EXISTS event_logs_maintain_partitions(integer, bigint)`, + ); + await queryRunner.query( + `DROP FUNCTION IF EXISTS event_logs_create_partition(bigint)`, + ); + } + + private async copyLegacyRows(queryRunner: QueryRunner): Promise { + // Create every partition the legacy data needs up-front so rows route + // straight into their final partition instead of via the default one. + await queryRunner.query(` + SELECT event_logs_create_partition(s.start) + FROM (SELECT DISTINCT ("ledger" / ${PARTITION_SIZE}) * ${PARTITION_SIZE} AS start + FROM "event_logs_legacy") s + `); + + // Rows written before topic0 existed get the best available + // discriminator (their eventType); a table produced by down() still has + // topic0 and keeps it. New rows carry the raw topic symbol. + const [{ hasTopic0 }] = (await queryRunner.query(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'event_logs_legacy' AND column_name = 'topic0' + ) AS "hasTopic0" + `)) as Array<{ hasTopic0: boolean }>; + const derivedTopic0 = `CASE "eventType" + WHEN 'admin_params_changed' THEN 'AdminParamsChanged' + ELSE "eventType" + END`; + + await queryRunner.query(` + INSERT INTO "event_logs" + ("id", "eventId", "pagingToken", "contractId", "topic0", "eventType", + "ledger", "txHash", "txOrder", "eventData", "timestamp", "createdAt") + SELECT "id", "eventId", "pagingToken", "contractId", + ${hasTopic0 ? `COALESCE("topic0", ${derivedTopic0})` : derivedTopic0}, + "eventType", "ledger", "txHash", "txOrder", "eventData", + "timestamp", "createdAt" + FROM "event_logs_legacy" + `); + + await this.assertSameRowCount( + queryRunner, + 'event_logs_legacy', + 'event_logs', + ); + + await queryRunner.query(` + SELECT setval('event_logs_id_seq', + GREATEST((SELECT COALESCE(MAX("id"), 0) FROM "event_logs"), 1), + (SELECT COUNT(*) > 0 FROM "event_logs")) + `); + + await queryRunner.query(`DROP TABLE "event_logs_legacy"`); + } + + private async assertSameRowCount( + queryRunner: QueryRunner, + source: string, + target: string, + ): Promise { + await queryRunner.query(` + DO $$ + DECLARE src bigint; dst bigint; + BEGIN + SELECT COUNT(*) INTO src FROM "${source}"; + SELECT COUNT(*) INTO dst FROM "${target}"; + IF src <> dst THEN + RAISE EXCEPTION 'event_logs partition migration row count mismatch: % (%) vs % (%)', + '${source}', src, '${target}', dst; + END IF; + END $$ + `); + } + + private async schedulePgCron(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN + PERFORM cron.schedule( + 'event_logs_maintain_partitions', + '*/10 * * * *', + 'SELECT event_logs_maintain_partitions()' + ); + END IF; + END $$ + `); + } +} diff --git a/packages/backend/src/event-store/event-log-partition.service.spec.ts b/packages/backend/src/event-store/event-log-partition.service.spec.ts new file mode 100644 index 00000000..65b0a821 --- /dev/null +++ b/packages/backend/src/event-store/event-log-partition.service.spec.ts @@ -0,0 +1,119 @@ +import { EventLogPartitionService } from './event-log-partition.service'; + +describe('EventLogPartitionService', () => { + const makeDataSource = (type = 'postgres') => ({ + options: { type }, + query: jest.fn(), + }); + + const setup = (type?: string) => { + const ds = makeDataSource(type); + const service = new EventLogPartitionService(ds as any); + const logger = (service as any).logger; + const spies = { + log: jest.spyOn(logger, 'log').mockImplementation(() => {}), + debug: jest.spyOn(logger, 'debug').mockImplementation(() => {}), + warn: jest.spyOn(logger, 'warn').mockImplementation(() => {}), + error: jest.spyOn(logger, 'error').mockImplementation(() => {}), + }; + return { ds, service, spies }; + }; + + it('is a no-op on non-postgres databases', async () => { + const { ds, service } = setup('sqlite'); + await expect(service.maintainPartitions()).resolves.toBeNull(); + expect(ds.query).not.toHaveBeenCalled(); + }); + + it('warns and skips when event_logs is not partitioned yet', async () => { + const { ds, service, spies } = setup(); + ds.query.mockResolvedValueOnce([]); + + await expect(service.maintainPartitions()).resolves.toBeNull(); + expect(ds.query).toHaveBeenCalledTimes(1); + expect(spies.warn).toHaveBeenCalledWith( + expect.objectContaining({ + msg: expect.stringContaining('not partitioned'), + }), + ); + }); + + it('calls event_logs_maintain_partitions and reports the result', async () => { + const { ds, service, spies } = setup(); + ds.query + .mockResolvedValueOnce([{ '?column?': 1 }]) + .mockResolvedValueOnce([{ created: 2 }]) + .mockResolvedValueOnce([{ partitions: 12, defaultRows: 0 }]); + + const result = await service.maintainPartitions(5_123_456); + + expect(result).toEqual({ + created: 2, + partitions: 12, + defaultPartitionRows: 0, + }); + expect(ds.query).toHaveBeenNthCalledWith( + 2, + 'SELECT event_logs_maintain_partitions($1, $2) AS created', + [2, 5_123_456], + ); + expect(spies.log).toHaveBeenCalledWith( + expect.objectContaining({ + msg: 'event_logs partition maintenance complete', + created: 2, + partitionSize: 100_000, + }), + ); + }); + + it('logs at debug when nothing was created and warns on stranded default rows', async () => { + const { ds, service, spies } = setup(); + ds.query + .mockResolvedValueOnce([{}]) + .mockResolvedValueOnce([{ created: 0 }]) + .mockResolvedValueOnce([{ partitions: 4, defaultRows: 3 }]); + + await service.maintainPartitions(); + + expect(ds.query).toHaveBeenNthCalledWith(2, expect.any(String), [2, null]); + expect(spies.log).not.toHaveBeenCalled(); + expect(spies.debug).toHaveBeenCalled(); + expect(spies.warn).toHaveBeenCalledWith( + expect.objectContaining({ defaultPartitionRows: 3 }), + ); + }); + + it('never throws when maintenance fails', async () => { + const { ds, service, spies } = setup(); + ds.query.mockRejectedValueOnce(new Error('connection refused')); + + await expect(service.maintainPartitions()).resolves.toBeNull(); + expect(spies.error).toHaveBeenCalledWith( + expect.objectContaining({ error: 'connection refused' }), + ); + }); + + it('skips overlapping runs', async () => { + const { ds, service } = setup(); + let release!: () => void; + ds.query.mockImplementationOnce( + () => new Promise((resolve) => (release = () => resolve([]))), + ); + + const first = service.maintainPartitions(); + await expect(service.maintainPartitions()).resolves.toBeNull(); + release(); + await first; + expect(ds.query).toHaveBeenCalledTimes(1); + }); + + it('runs on bootstrap and on the cron tick', async () => { + const { service } = setup(); + const spy = jest + .spyOn(service, 'maintainPartitions') + .mockResolvedValue(null); + await service.onApplicationBootstrap(); + await service.scheduledMaintenance(); + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/backend/src/event-store/event-log-partition.service.ts b/packages/backend/src/event-store/event-log-partition.service.ts new file mode 100644 index 00000000..bed2e865 --- /dev/null +++ b/packages/backend/src/event-store/event-log-partition.service.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { DataSource } from 'typeorm'; +import { EVENT_LOG_PARTITION_SIZE } from '../indexer/event-log.entity'; + +export interface PartitionMaintenanceResult { + created: number; + partitions: number; + defaultPartitionRows: number; +} + +/** + * Automated maintenance for the ledger-range partitioned `event_logs` + * table (BE-003). + * + * Calls the `event_logs_maintain_partitions()` plpgsql function installed by + * the PartitionEventStore migration on boot and every 10 minutes. The + * function pre-creates the next partitions ahead of the highest indexed + * ledger and rescues any rows that landed in `event_logs_default`, so the + * default partition stays empty and range scans keep pruning. + * + * Lookahead: 2 partitions × 100,000 ledgers ≈ 11.5 days at ~5s/ledger, so + * a maintenance outage of days still never forces inserts into the default + * partition — and even then they are only parked, never rejected. + */ +@Injectable() +export class EventLogPartitionService implements OnApplicationBootstrap { + private readonly logger = new Logger(EventLogPartitionService.name); + private readonly lookahead = Number( + process.env.EVENT_LOG_PARTITION_LOOKAHEAD ?? 2, + ); + private running = false; + + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + async onApplicationBootstrap(): Promise { + await this.maintainPartitions(); + } + + @Cron(CronExpression.EVERY_10_MINUTES) + async scheduledMaintenance(): Promise { + await this.maintainPartitions(); + } + + /** + * Run partition maintenance. Pass the chain's latest ledger when known so + * partitions exist before the indexer reaches them even on an empty table. + * Never throws — failures are logged and reported as null. + */ + async maintainPartitions( + currentLedger?: number, + ): Promise { + if (this.dataSource.options.type !== 'postgres') return null; + if (this.running) return null; + this.running = true; + + const startedAt = Date.now(); + try { + if (!(await this.isPartitioned())) { + this.logger.warn({ + msg: 'event_logs is not partitioned — run migrations (PartitionEventStore1760000070000)', + }); + return null; + } + + const [{ created }] = await this.dataSource.query< + Array<{ created: number }> + >(`SELECT event_logs_maintain_partitions($1, $2) AS created`, [ + this.lookahead, + currentLedger ?? null, + ]); + + const [{ partitions, defaultRows }] = await this.dataSource.query< + Array<{ partitions: number; defaultRows: number }> + >(` + SELECT + (SELECT COUNT(*)::int FROM pg_inherits i + JOIN pg_class p ON p.oid = i.inhparent + WHERE p.relname = 'event_logs') AS partitions, + (SELECT COUNT(*)::int FROM event_logs_default) AS "defaultRows" + `); + + const result: PartitionMaintenanceResult = { + created: Number(created), + partitions: Number(partitions), + defaultPartitionRows: Number(defaultRows), + }; + + const log = { + msg: 'event_logs partition maintenance complete', + ...result, + lookahead: this.lookahead, + partitionSize: EVENT_LOG_PARTITION_SIZE, + durationMs: Date.now() - startedAt, + }; + if (result.created > 0) this.logger.log(log); + else this.logger.debug(log); + + if (result.defaultPartitionRows > 0) { + this.logger.warn({ + msg: 'event_logs_default still holds rows after maintenance', + defaultPartitionRows: result.defaultPartitionRows, + }); + } + return result; + } catch (err) { + this.logger.error({ + msg: 'event_logs partition maintenance failed', + error: (err as Error).message, + durationMs: Date.now() - startedAt, + }); + return null; + } finally { + this.running = false; + } + } + + private async isPartitioned(): Promise { + const rows = await this.dataSource.query(` + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() + AND c.relname = 'event_logs' + AND c.relkind = 'p' + `); + return rows.length > 0; + } +} diff --git a/packages/backend/src/event-store/event-store.module.ts b/packages/backend/src/event-store/event-store.module.ts index 15902781..4b686797 100644 --- a/packages/backend/src/event-store/event-store.module.ts +++ b/packages/backend/src/event-store/event-store.module.ts @@ -2,14 +2,18 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { EventStoreEntry } from './entities/event-store-entry.entity'; import { AggregateSnapshot } from './entities/aggregate-snapshot.entity'; +import { EventLog } from '../indexer/event-log.entity'; import { EventStoreService } from './event-store.service'; import { EventStoreListener } from './event-store.listener'; import { EventsController } from './events.controller'; +import { EventLogPartitionService } from './event-log-partition.service'; @Module({ - imports: [TypeOrmModule.forFeature([EventStoreEntry, AggregateSnapshot])], - providers: [EventStoreService, EventStoreListener], + imports: [ + TypeOrmModule.forFeature([EventStoreEntry, AggregateSnapshot, EventLog]), + ], + providers: [EventStoreService, EventStoreListener, EventLogPartitionService], controllers: [EventsController], - exports: [EventStoreService], + exports: [EventStoreService, EventLogPartitionService], }) export class EventStoreModule {} diff --git a/packages/backend/src/event-store/event-store.service.spec.ts b/packages/backend/src/event-store/event-store.service.spec.ts index fff11da2..82eed7d1 100644 --- a/packages/backend/src/event-store/event-store.service.spec.ts +++ b/packages/backend/src/event-store/event-store.service.spec.ts @@ -8,6 +8,7 @@ import { } from './entities/event-store-entry.entity'; import { AggregateSnapshot } from './entities/aggregate-snapshot.entity'; import { correlationStorage } from '../common/middleware/correlation-id.middleware'; +import { EventLog } from '../indexer/event-log.entity'; describe('EventStoreService', () => { let service: EventStoreService; @@ -26,6 +27,18 @@ describe('EventStoreService', () => { findOne: jest.fn(), }; + const eventLogQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + const eventLogRepo = { + createQueryBuilder: jest.fn(() => eventLogQb), + }; + beforeEach(async () => { jest.clearAllMocks(); eventRepo.count.mockResolvedValue(0); @@ -39,6 +52,7 @@ describe('EventStoreService', () => { provide: getRepositoryToken(AggregateSnapshot), useValue: snapshotRepo, }, + { provide: getRepositoryToken(EventLog), useValue: eventLogRepo }, ], }).compile(); @@ -300,4 +314,89 @@ describe('EventStoreService', () => { ); }); }); + + describe('getRawEventsByLedgerRange', () => { + it('builds a partition-prunable contract/topic/ledger range scan', async () => { + eventLogQb.getMany.mockResolvedValueOnce([{ id: 1, ledger: 150 }]); + + const rows = await service.getRawEventsByLedgerRange({ + contractId: 'CID', + topic0: 'PayoutClaimed', + fromLedger: 100, + toLedger: 200, + limit: 50, + }); + + expect(rows).toEqual([{ id: 1, ledger: 150 }]); + expect(eventLogQb.where).toHaveBeenCalledWith( + 'log.contractId = :contractId', + { contractId: 'CID' }, + ); + expect(eventLogQb.andWhere).toHaveBeenCalledWith( + 'log.ledger >= :fromLedger', + { fromLedger: 100 }, + ); + expect(eventLogQb.andWhere).toHaveBeenCalledWith( + 'log.ledger <= :toLedger', + { toLedger: 200 }, + ); + expect(eventLogQb.andWhere).toHaveBeenCalledWith('log.topic0 = :topic0', { + topic0: 'PayoutClaimed', + }); + expect(eventLogQb.orderBy).toHaveBeenCalledWith('log.ledger', 'ASC'); + expect(eventLogQb.addOrderBy).toHaveBeenCalledWith('log.id', 'ASC'); + expect(eventLogQb.limit).toHaveBeenCalledWith(50); + }); + + it('omits the topic filter and applies a keyset cursor', async () => { + eventLogQb.getMany.mockResolvedValueOnce([]); + + await service.getRawEventsByLedgerRange({ + contractId: 'CID', + fromLedger: 1, + toLedger: 10, + after: { ledger: 5, id: 42 }, + }); + + expect(eventLogQb.andWhere).not.toHaveBeenCalledWith( + 'log.topic0 = :topic0', + expect.anything(), + ); + expect(eventLogQb.andWhere).toHaveBeenCalledWith( + '(log.ledger, log.id) > (:afterLedger, :afterId)', + { afterLedger: 5, afterId: 42 }, + ); + expect(eventLogQb.limit).toHaveBeenCalledWith(100); + }); + + it('clamps the page size', async () => { + eventLogQb.getMany.mockResolvedValue([]); + await service.getRawEventsByLedgerRange({ + contractId: 'CID', + fromLedger: 1, + toLedger: 2, + limit: 1_000_000, + }); + expect(eventLogQb.limit).toHaveBeenLastCalledWith(1000); + + await service.getRawEventsByLedgerRange({ + contractId: 'CID', + fromLedger: 1, + toLedger: 2, + limit: 0, + }); + expect(eventLogQb.limit).toHaveBeenLastCalledWith(1); + }); + + it('returns nothing for an inverted range without querying', async () => { + await expect( + service.getRawEventsByLedgerRange({ + contractId: 'CID', + fromLedger: 10, + toLedger: 1, + }), + ).resolves.toEqual([]); + expect(eventLogRepo.createQueryBuilder).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/backend/src/event-store/event-store.service.ts b/packages/backend/src/event-store/event-store.service.ts index 01bc4f2a..4ddc2212 100644 --- a/packages/backend/src/event-store/event-store.service.ts +++ b/packages/backend/src/event-store/event-store.service.ts @@ -9,6 +9,26 @@ import { } from './entities/event-store-entry.entity'; import { AggregateSnapshot } from './entities/aggregate-snapshot.entity'; import { correlationStorage } from '../common/middleware/correlation-id.middleware'; +import { EventLog } from '../indexer/event-log.entity'; + +export interface LedgerRangeFilter { + contractId: string; + /** First topic symbol, e.g. `PayoutClaimed`. Omit to scan every topic. */ + topic0?: string; + /** Inclusive lower ledger bound. */ + fromLedger: number; + /** Inclusive upper ledger bound. */ + toLedger: number; + limit?: number; + /** + * Keyset cursor from the previous page: `{ ledger, id }` of its last row. + * Keeps deep pages O(limit) instead of O(offset). + */ + after?: { ledger: number; id: number }; +} + +/** Hard cap so a single range scan can never pull an unbounded result set. */ +const MAX_RANGE_SCAN_LIMIT = 1000; export interface AggregateState { aggregateType: AggregateType; @@ -40,8 +60,60 @@ export class EventStoreService { private readonly eventRepo: Repository, @InjectRepository(AggregateSnapshot) private readonly snapshotRepo: Repository, + @InjectRepository(EventLog) + private readonly eventLogRepo: Repository, ) {} + /** + * Range scan over the raw, ledger-partitioned `event_logs` table (BE-003). + * + * The predicate shape — equality on contractId/topic0 plus a bounded + * ledger range, ordered by ledger — is deliberate: the ledger bounds let + * Postgres prune to the partitions overlapping [fromLedger, toLedger], and + * inside each one the (contractId, topic0, ledger) composite index serves + * both the filter and the ORDER BY without a sort. + */ + async getRawEventsByLedgerRange( + filter: LedgerRangeFilter, + ): Promise { + const { contractId, topic0, fromLedger, toLedger, after } = filter; + const limit = Math.min( + Math.max(filter.limit ?? 100, 1), + MAX_RANGE_SCAN_LIMIT, + ); + + if (toLedger < fromLedger) return []; + + const qb = this.eventLogRepo + .createQueryBuilder('log') + .where('log.contractId = :contractId', { contractId }) + .andWhere('log.ledger >= :fromLedger', { fromLedger }) + .andWhere('log.ledger <= :toLedger', { toLedger }) + .orderBy('log.ledger', 'ASC') + .addOrderBy('log.id', 'ASC') + .limit(limit); + + if (topic0) qb.andWhere('log.topic0 = :topic0', { topic0 }); + if (after) { + qb.andWhere('(log.ledger, log.id) > (:afterLedger, :afterId)', { + afterLedger: after.ledger, + afterId: after.id, + }); + } + + const rows = await qb.getMany(); + this.logger.debug({ + msg: 'event_logs range scan', + contractId, + topic0: topic0 ?? null, + fromLedger, + toLedger, + limit, + returned: rows.length, + }); + return rows; + } + /** * Atomically append a single immutable event to the store. A single * INSERT is already atomic in Postgres; there is deliberately no diff --git a/packages/backend/src/indexer/contract-errors.generated.ts b/packages/backend/src/indexer/contract-errors.generated.ts new file mode 100644 index 00000000..0f4cd29a --- /dev/null +++ b/packages/backend/src/indexer/contract-errors.generated.ts @@ -0,0 +1,1137 @@ +/* eslint-disable */ +// AUTO-GENERATED by scripts/generate-contract-errors.js — DO NOT EDIT. +// Source of truth: packages/contracts/*/src/errors.rs +// Regenerate with: pnpm --filter backend generate:contract-errors + +export interface ContractErrorDefinition { + code: number; + name: string; + message: string; +} + +export interface ContractErrorEnum { + /** Rust crate directory under packages/contracts. */ + contract: string; + /** Rust `#[contracterror]` enum name. */ + enumName: string; + errors: Readonly>; +} + +export const CONTRACT_ERROR_REGISTRY: Readonly< + Record +> = { + backit_token: { + contract: 'backit_token', + enumName: 'BackitError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Initialize called on an already-initialised contract.', + }, + 2: { + code: 2, + name: 'NotInitialized', + message: + 'A function requiring initialisation was called before initialize.', + }, + 3: { + code: 3, + name: 'Unauthorized', + message: 'Caller is not authorised for this operation.', + }, + 4: { + code: 4, + name: 'ZeroBalance', + message: + 'The holder has no tokens and therefore no revenue share to claim.', + }, + 5: { + code: 5, + name: 'NoFeesAvailable', + message: 'The fee pool is empty — nothing to distribute.', + }, + 6: { + code: 6, + name: 'AlreadyStaked', + message: 'The staker already has an active stake.', + }, + 7: { + code: 7, + name: 'NotStaked', + message: 'No active stake found for this address.', + }, + 8: { + code: 8, + name: 'LockNotExpired', + message: 'The stake lock period has not yet expired.', + }, + 9: { + code: 9, + name: 'InvalidAmount', + message: 'Amount is invalid (zero or negative).', + }, + 10: { + code: 10, + name: 'InsufficientBalance', + message: 'Insufficient token balance for the requested operation.', + }, + 11: { + code: 11, + name: 'InvalidLockDuration', + message: 'Lock duration provided is zero.', + }, + 12: { + code: 12, + name: 'UsdcNotConfigured', + message: 'USDC SAC address has not been configured.', + }, + }, + }, + call_registry: { + contract: 'call_registry', + enumName: 'CallRegistryError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Initialize was called on an already-initialised contract.', + }, + 2: { + code: 2, + name: 'NotInitialized', + message: + 'A function that requires the contract to be initialised was called before initialize.', + }, + 3: { + code: 3, + name: 'InvalidStakeAmount', + message: 'stake_amount (or the amount passed to stake_on_call) is ≤ 0.', + }, + 4: { + code: 4, + name: 'InvalidEndTime', + message: + 'end_ts is not strictly in the future relative to the current ledger timestamp.', + }, + 5: { + code: 5, + name: 'CallNotFound', + message: 'No call exists for the supplied call_id.', + }, + 6: { + code: 6, + name: 'CallEnded', + message: + "The call's end_ts has already passed; staking is no longer allowed.", + }, + 7: { + code: 7, + name: 'CallSettled', + message: 'The call has already been settled; the operation is a no-op.', + }, + 8: { + code: 8, + name: 'InvalidPosition', + message: 'Position is not 1 (UP) or 2 (DOWN).', + }, + 9: { + code: 9, + name: 'Unauthorized', + message: + 'The caller does not hold the required role (admin / outcome_manager).', + }, + 10: { + code: 10, + name: 'ContractPaused', + message: + 'Reserved for a future pause mechanism; no operations are permitted while paused.', + }, + 11: { + code: 11, + name: 'CallNotEnded', + message: 'resolve_call was called before end_ts has passed.', + }, + 12: { + code: 12, + name: 'InvalidOutcome', + message: 'Outcome passed to resolve_call is not 1 (UP) or 2 (DOWN).', + }, + 13: { + code: 13, + name: 'InvalidOutcomeCount', + message: 'outcome_count is less than 2.', + }, + 14: { + code: 14, + name: 'FeeTooHigh', + message: 'fee_bps exceeds 10 000 (100 %).', + }, + 15: { + code: 15, + name: 'StakingCutoffActive', + message: 'Staking attempted within the cutoff window before end_ts.', + }, + 16: { + code: 16, + name: 'Sep10TokenExpired', + message: "The SEP-10 token's valid_until ledger sequence has passed.", + }, + 17: { + code: 17, + name: 'ReentrancyDetected', + message: 'Re-entrant call detected on a guarded function.', + }, + 18: { + code: 18, + name: 'Overflow', + message: + 'A checked arithmetic operation (multiplication, division, addition, or subtraction) would have overflowed/underflowed or divided by zero. Raised by the reputation-weighted stake-limit calculations and any other checked-math call sites in this crate.', + }, + 20: { + code: 20, + name: 'NotFound', + message: + 'Generic not found error for requested entity (e.g., proposal).', + }, + 21: { + code: 21, + name: 'InvalidInput', + message: 'Invalid input was provided to the operation.', + }, + 22: { + code: 22, + name: 'NotReady', + message: + 'The operation is not ready to be executed yet (timelock not expired).', + }, + }, + }, + charity_markets: { + contract: 'charity_markets', + enumName: 'CharityError', + errors: { + 1: { code: 1, name: 'NotInitialized', message: 'Not initialized.' }, + 2: { + code: 2, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { code: 4, name: 'InvalidCharity', message: 'Invalid charity.' }, + 5: { code: 5, name: 'AlreadyResolved', message: 'Already resolved.' }, + 6: { + code: 6, + name: 'CharitySplitExceedsMax', + message: 'Charity split exceeds max.', + }, + 7: { code: 7, name: 'CallNotFound', message: 'Call not found.' }, + 8: { code: 8, name: 'InvalidOutcome', message: 'Invalid outcome.' }, + 9: { + code: 9, + name: 'InvalidStakeAmount', + message: 'Invalid stake amount.', + }, + 10: { code: 10, name: 'InvalidCallId', message: 'Invalid call id.' }, + 11: { code: 11, name: 'ZeroPool', message: 'Zero pool.' }, + }, + }, + cross_chain_oracle_relay: { + contract: 'cross_chain_oracle_relay', + enumName: 'OracleRelayError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { + code: 4, + name: 'UnauthorizedRelayer', + message: 'Unauthorized relayer.', + }, + 5: { + code: 5, + name: 'InvalidBlockHeader', + message: 'Invalid block header.', + }, + 6: { + code: 6, + name: 'MerkleProofVerificationFailed', + message: 'Merkle proof verification failed.', + }, + 7: { code: 7, name: 'PriceOutOfRange', message: 'Price out of range.' }, + 8: { + code: 8, + name: 'InsufficientSignatures', + message: 'Insufficient signatures.', + }, + 9: { + code: 9, + name: 'BlockAlreadyRelayed', + message: 'Block already relayed.', + }, + }, + }, + dutch_auction: { + contract: 'dutch_auction', + enumName: 'DutchAuctionError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { + code: 3, + name: 'AuctionNotStarted', + message: 'Auction not started.', + }, + 4: { + code: 4, + name: 'AuctionAlreadySettled', + message: 'Auction already settled.', + }, + 5: { code: 5, name: 'CallNotEligible', message: 'Call not eligible.' }, + 6: { + code: 6, + name: 'OracleDeadlineNotMet', + message: 'Oracle deadline not met.', + }, + 7: { code: 7, name: 'Unauthorized', message: 'Unauthorized.' }, + 8: { code: 8, name: 'InvalidParams', message: 'Invalid params.' }, + 9: { code: 9, name: 'Overflow', message: 'Overflow.' }, + 10: { code: 10, name: 'InvalidPrice', message: 'Invalid price.' }, + 11: { + code: 11, + name: 'UnknownConditionType', + message: 'Unknown condition type.', + }, + }, + }, + gas_station: { + contract: 'gas_station', + enumName: 'GasStationError', + errors: { + 1: { + code: 1, + name: 'NotInitialized', + message: + 'A function requiring initialization was called before initialize.', + }, + 2: { + code: 2, + name: 'AlreadyInitialized', + message: 'Initialize was called on an already-initialized contract.', + }, + 3: { + code: 3, + name: 'Unauthorized', + message: + 'The caller-supplied admin address does not match the stored admin.', + }, + 4: { + code: 4, + name: 'InvalidWinningCutBps', + message: 'winning_cut_bps exceeds 10 000 (100%).', + }, + 5: { + code: 5, + name: 'InvalidGasAmount', + message: + 'max_gas_xlm (or an effective-stake gas estimate) is not positive, or would make the effective stake negative.', + }, + 6: { + code: 6, + name: 'UserNotSponsored', + message: + 'User has no active sponsorship registered with this gas station.', + }, + 7: { + code: 7, + name: 'CallAlreadyProcessed', + message: 'This call_id has already had its sponsored payout processed.', + }, + 8: { + code: 8, + name: 'InvalidWinningStake', + message: + 'staker_winning_stake is 0 or negative; there is nothing to claim.', + }, + 9: { + code: 9, + name: 'Overflow', + message: + 'An arithmetic operation overflowed; the transaction is reverted.', + }, + 10: { + code: 10, + name: 'InvalidRefillAmount', + message: 'refill_gas_pool was called with a non-positive amount.', + }, + }, + }, + governance: { + contract: 'governance', + enumName: 'GovernanceError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { code: 4, name: 'ProposalNotFound', message: 'Proposal not found.' }, + 5: { code: 5, name: 'AlreadyVoted', message: 'Already voted.' }, + 6: { code: 6, name: 'ProposalInactive', message: 'Proposal inactive.' }, + 7: { + code: 7, + name: 'VotingPeriodEnded', + message: 'Voting period ended.', + }, + 8: { + code: 8, + name: 'InsufficientReputation', + message: 'Insufficient reputation.', + }, + 9: { code: 9, name: 'AlreadyExecuted', message: 'Already executed.' }, + 10: { + code: 10, + name: 'ProposalNotPassed', + message: 'Proposal not passed.', + }, + 11: { code: 11, name: 'QuorumNotMet', message: 'Quorum not met.' }, + 12: { code: 12, name: 'AlreadyDelegated', message: 'Already delegated.' }, + 13: { code: 13, name: 'SelfDelegation', message: 'Self delegation.' }, + }, + }, + index_fund: { + contract: 'index_fund', + enumName: 'IndexFundError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { + code: 4, + name: 'InsufficientLiquidity', + message: 'Insufficient liquidity.', + }, + 5: { code: 5, name: 'InvalidAmount', message: 'Invalid amount.' }, + 6: { + code: 6, + name: 'RebalanceTooFrequent', + message: 'Rebalance too frequent.', + }, + 7: { code: 7, name: 'MarketNotFound', message: 'Market not found.' }, + 8: { + code: 8, + name: 'NavCalculationError', + message: 'Nav calculation error.', + }, + 9: { code: 9, name: 'ZeroSupply', message: 'Zero supply.' }, + 10: { code: 10, name: 'FeeTooHigh', message: 'Fee too high.' }, + 11: { code: 11, name: 'IndexFull', message: 'Index full.' }, + 12: { + code: 12, + name: 'MarketNotResolved', + message: 'Market not resolved.', + }, + 13: { code: 13, name: 'TransferFailed', message: 'Transfer failed.' }, + }, + }, + lending_pool: { + contract: 'lending_pool', + enumName: 'LendingPoolError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'InvalidAmount', message: 'Invalid amount.' }, + 4: { code: 4, name: 'BelowMinDeposit', message: 'Below min deposit.' }, + 5: { code: 5, name: 'PoolFull', message: 'Pool full.' }, + 6: { + code: 6, + name: 'InsufficientShares', + message: 'Insufficient shares.', + }, + 7: { code: 7, name: 'ZeroSupply', message: 'Zero supply.' }, + 8: { + code: 8, + name: 'InsufficientLiquidity', + message: 'Insufficient liquidity.', + }, + 9: { code: 9, name: 'Overflow', message: 'Overflow.' }, + 10: { code: 10, name: 'PoolTooSmall', message: 'Pool too small.' }, + 11: { code: 11, name: 'EmptyInput', message: 'Empty input.' }, + 12: { + code: 12, + name: 'AllocationNotFound', + message: 'Allocation not found.', + }, + 13: { code: 13, name: 'AlreadyHarvested', message: 'Already harvested.' }, + 14: { + code: 14, + name: 'MarketNotResolved', + message: 'Market not resolved.', + }, + 15: { + code: 15, + name: 'MarketCallFailed', + message: 'Market call failed.', + }, + 16: { code: 16, name: 'Unauthorized', message: 'Unauthorized.' }, + 17: { code: 17, name: 'InvalidBps', message: 'Invalid bps.' }, + 18: { code: 18, name: 'InvalidConfig', message: 'Invalid config.' }, + }, + }, + oracle_marketplace: { + contract: 'oracle_marketplace', + enumName: 'OracleMarketplaceError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { + code: 4, + name: 'OracleAlreadyRegistered', + message: 'Oracle already registered.', + }, + 5: { code: 5, name: 'OracleNotFound', message: 'Oracle not found.' }, + 6: { code: 6, name: 'OracleNotActive', message: 'Oracle not active.' }, + 7: { code: 7, name: 'InsufficientStake', message: 'Insufficient stake.' }, + 8: { code: 8, name: 'CooldownActive', message: 'Cooldown active.' }, + 9: { code: 9, name: 'InvalidFee', message: 'Invalid fee.' }, + 10: { code: 10, name: 'CallNotFound', message: 'Call not found.' }, + 11: { + code: 11, + name: 'OracleNotSelectedForCall', + message: 'Oracle not selected for call.', + }, + 12: { code: 12, name: 'AlreadyRated', message: 'Already rated.' }, + 13: { code: 13, name: 'InvalidRating', message: 'Invalid rating.' }, + }, + }, + orderbook: { + contract: 'orderbook', + enumName: 'OrderbookError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { code: 4, name: 'InvalidAmount', message: 'Invalid amount.' }, + 5: { code: 5, name: 'InvalidPrice', message: 'Invalid price.' }, + 6: { code: 6, name: 'OrderNotFound', message: 'Order not found.' }, + 7: { + code: 7, + name: 'InsufficientBalance', + message: 'Insufficient balance.', + }, + 8: { code: 8, name: 'SelfTrading', message: 'Self trading.' }, + 9: { + code: 9, + name: 'MaxMatchesReached', + message: 'Max matches reached.', + }, + }, + }, + outcome_manager: { + contract: 'outcome_manager', + enumName: 'OutcomeError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Initialize was called on an already-initialized contract.', + }, + 2: { + code: 2, + name: 'InvalidQuorum', + message: 'Quorum is 0 or exceeds the current oracle count.', + }, + 3: { + code: 3, + name: 'UnauthorizedOracle', + message: 'The oracle public key is not in the trusted oracle set.', + }, + 4: { + code: 4, + name: 'AlreadySettled', + message: + 'Quorum was already reached; the call outcome is already settled.', + }, + 5: { + code: 5, + name: 'DuplicateSubmission', + message: 'This oracle already submitted a vote for the given call_id.', + }, + 6: { + code: 6, + name: 'InvalidOutcome', + message: 'The outcome value is not within 1, outcome_count.', + }, + 7: { + code: 7, + name: 'CallNotSettled', + message: 'claim_payout was called before quorum was reached.', + }, + 8: { + code: 8, + name: 'AlreadyClaimed', + message: + 'The staker has already claimed their payout for this call_id.', + }, + 9: { + code: 9, + name: 'NothingToClaim', + message: "The staker's winning stake is 0; there is nothing to claim.", + }, + 10: { + code: 10, + name: 'InvalidWinningStake', + message: + 'total_winning_stake is 0 or negative; payout cannot be calculated.', + }, + 11: { + code: 11, + name: 'Overflow', + message: + 'An arithmetic operation overflowed; the transaction is reverted.', + }, + 12: { + code: 12, + name: 'CallNotFinalized', + message: + 'No pending outcome exists, or the dispute window has not yet elapsed.', + }, + 13: { + code: 13, + name: 'InvalidFeeBps', + message: 'fee_bps exceeds 10 000 (100%).', + }, + 14: { + code: 14, + name: 'ContractPaused', + message: + 'The contract is paused; submit_outcome and claim_payout are blocked.', + }, + 15: { + code: 15, + name: 'MaxOraclesReached', + message: 'Adding an oracle would exceed the MAX_ORACLES cap of 20.', + }, + 16: { + code: 16, + name: 'SubmissionWindowExpired', + message: + "The oracle's timestamp is after call_end_ts + max_submission_delay.", + }, + 17: { + code: 17, + name: 'EmptyBatch', + message: 'batch_claim_payouts was called with an empty stakers vec.', + }, + 18: { + code: 18, + name: 'LengthMismatch', + message: + 'Stakers and stakes vecs passed to batch_claim_payouts differ in length.', + }, + 19: { + code: 19, + name: 'NotInitialized', + message: + 'A function requiring initialization was called before initialize.', + }, + 20: { + code: 20, + name: 'FeeCollectorNotSet', + message: 'A fee collector address has not been set yet.', + }, + 21: { + code: 21, + name: 'ObservationOutOfOrder', + message: + 'A price observation was submitted out of chronological order.', + }, + 22: { + code: 22, + name: 'InsufficientPriceObservations', + message: + 'compute_twap was called but fewer than 3 price observations are stored.', + }, + 23: { + code: 23, + name: 'NoPriceObservations', + message: + 'compute_twap was called but no price observations exist for this call.', + }, + 24: { + code: 24, + name: 'ZeroTimeWindow', + message: + 'All stored observations share the same timestamp; TWAP window is zero.', + }, + 25: { + code: 25, + name: 'RegistryNotSet', + message: + 'The CallRegistry address has not been set in instance storage.', + }, + 26: { + code: 26, + name: 'DisputeWindowExpired', + message: + 'dispute_outcome was called after the dispute window has already closed.', + }, + 27: { + code: 27, + name: 'FactoryNotSet', + message: 'The factory address has not been set in instance storage.', + }, + 28: { + code: 28, + name: 'InvalidMarket', + message: + "The market address does not match the factory's registry for this call_id.", + }, + 29: { + code: 29, + name: 'ObservationOutsideWindow', + message: + "A price observation's timestamp falls outside call_end_ts - twap_window_secs, call_end_ts.", + }, + 30: { + code: 30, + name: 'NotRecoveryAgent', + message: + "claim_on_behalf was called by an address that is not the original winner's designated recovery address (or none has been set).", + }, + 31: { + code: 31, + name: 'RecoveryGracePeriodNotElapsed', + message: + 'claim_on_behalf was called before recovery_grace_period has elapsed since the call was settled.', + }, + 32: { + code: 32, + name: 'DuplicateOracleObservation', + message: + 'The same oracle submitted a second resolution observation for the same call_id via submit_resolution_observation.', + }, + }, + }, + parlay_betting: { + contract: 'parlay_betting', + enumName: 'ParlayError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'InvalidLegCount', message: 'Invalid leg count.' }, + 4: { + code: 4, + name: 'InvalidStakeAmount', + message: 'Invalid stake amount.', + }, + 5: { code: 5, name: 'InvalidOutcome', message: 'Invalid outcome.' }, + 6: { code: 6, name: 'ParlayNotFound', message: 'Parlay not found.' }, + 7: { code: 7, name: 'ParlayNotActive', message: 'Parlay not active.' }, + 8: { code: 8, name: 'LegNotResolved', message: 'Leg not resolved.' }, + 9: { code: 9, name: 'MarketCallFailed', message: 'Market call failed.' }, + 10: { code: 10, name: 'ContractPaused', message: 'Contract paused.' }, + }, + }, + prediction_market: { + contract: 'prediction_market', + enumName: 'MarketError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { + code: 3, + name: 'InvalidStakeAmount', + message: 'Invalid stake amount.', + }, + 4: { code: 4, name: 'InvalidEndTime', message: 'Invalid end time.' }, + 5: { code: 5, name: 'CallNotFound', message: 'Call not found.' }, + 6: { code: 6, name: 'CallEnded', message: 'Call ended.' }, + 7: { code: 7, name: 'CallSettled', message: 'Call settled.' }, + 8: { code: 8, name: 'InvalidPosition', message: 'Invalid position.' }, + 9: { code: 9, name: 'Unauthorized', message: 'Unauthorized.' }, + 10: { code: 10, name: 'ContractPaused', message: 'Contract paused.' }, + 11: { code: 11, name: 'CallNotEnded', message: 'Call not ended.' }, + 12: { code: 12, name: 'InvalidOutcome', message: 'Invalid outcome.' }, + 13: { + code: 13, + name: 'InvalidOutcomeCount', + message: 'Invalid outcome count.', + }, + 15: { + code: 15, + name: 'StakingCutoffActive', + message: 'Staking cutoff active.', + }, + 16: { code: 16, name: 'InvalidCallId', message: 'Invalid call id.' }, + 17: { + code: 17, + name: 'ReserveDiscrepancy', + message: 'Reserve discrepancy.', + }, + 18: { + code: 18, + name: 'NotEligibleForBonus', + message: 'Not eligible for bonus.', + }, + 19: { + code: 19, + name: 'Overflow', + message: 'Checked arithmetic overflowed.', + }, + 20: { + code: 20, + name: 'OrderNotFound', + message: 'No limit order exists with the given id.', + }, + 21: { + code: 21, + name: 'NotOrderOwner', + message: 'Caller is not the owner of the limit order.', + }, + 22: { + code: 22, + name: 'InvalidTargetProbability', + message: + 'target_implied_probability_bps is out of the valid 0..=10_000 range.', + }, + 23: { + code: 23, + name: 'InvalidOrderTTL', + message: + 'ttl_secs is zero or exceeds the maximum allowed order lifetime.', + }, + 24: { + code: 24, + name: 'OrderNotExpired', + message: + 'The order has not yet expired, so it cannot be force-refunded.', + }, + 25: { + code: 25, + name: 'InvalidRolloverPercentage', + message: 'Rollover percentage exceeds 100% (10_000 bps).', + }, + 26: { + code: 26, + name: 'NoWinningStake', + message: 'User has no stake on the winning outcome.', + }, + 27: { + code: 27, + name: 'RolloverInsufficientAmount', + message: + 'Rollover amount is below the minimum stake for the new market.', + }, + 28: { + code: 28, + name: 'CallNotSettled', + message: 'The call has not been settled yet.', + }, + }, + }, + prediction_market_factory: { + contract: 'prediction_market_factory', + enumName: 'FactoryError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { + code: 4, + name: 'InvalidStakeAmount', + message: 'Invalid stake amount.', + }, + 5: { code: 5, name: 'InvalidEndTime', message: 'Invalid end time.' }, + 6: { + code: 6, + name: 'InvalidOutcomeCount', + message: 'Invalid outcome count.', + }, + 7: { + code: 7, + name: 'TokenNotWhitelisted', + message: 'Token not whitelisted.', + }, + 8: { code: 8, name: 'ContractPaused', message: 'Contract paused.' }, + 9: { code: 9, name: 'MarketWasmNotSet', message: 'Market wasm not set.' }, + 10: { code: 10, name: 'MarketNotFound', message: 'Market not found.' }, + 11: { + code: 11, + name: 'StrategyNotFound', + message: 'Strategy not found.', + }, + 12: { + code: 12, + name: 'StrategyAlreadyExecuted', + message: 'Strategy already executed.', + }, + 13: { + code: 13, + name: 'StrategyCancelled', + message: 'Strategy cancelled.', + }, + 14: { code: 14, name: 'StrategyExpired', message: 'Strategy expired.' }, + 15: { code: 15, name: 'TooManyActions', message: 'Too many actions.' }, + 16: { + code: 16, + name: 'StrategyNotExecutable', + message: 'Strategy not executable.', + }, + }, + }, + prediction_market_futures: { + contract: 'prediction_market_futures', + enumName: 'FuturesError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'ContractNotFound', message: 'Contract not found.' }, + 4: { + code: 4, + name: 'ContractAlreadySettled', + message: 'Contract already settled.', + }, + 5: { code: 5, name: 'NoCounterparty', message: 'No counterparty.' }, + 6: { + code: 6, + name: 'CounterpartyAlreadyAssigned', + message: 'Counterparty already assigned.', + }, + 7: { code: 7, name: 'ContractExpired', message: 'Contract expired.' }, + 8: { + code: 8, + name: 'ContractNotExpired', + message: 'Contract not expired.', + }, + 9: { code: 9, name: 'InvalidMargin', message: 'Invalid margin.' }, + 10: { + code: 10, + name: 'InvalidStrikeProbability', + message: 'Invalid strike probability.', + }, + 11: { code: 11, name: 'InvalidExpiry', message: 'Invalid expiry.' }, + 12: { code: 12, name: 'InvalidOutcome', message: 'Invalid outcome.' }, + 13: { code: 13, name: 'MarketNotFound', message: 'Market not found.' }, + 14: { code: 14, name: 'Overflow', message: 'Overflow.' }, + 15: { code: 15, name: 'Unauthorized', message: 'Unauthorized.' }, + }, + }, + reputation_nft: { + contract: 'reputation_nft', + enumName: 'ReputationError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { + code: 4, + name: 'BadgeAlreadyAwarded', + message: 'Badge already awarded.', + }, + 5: { + code: 5, + name: 'TransferNotAllowed', + message: 'Transfer not allowed.', + }, + 6: { code: 6, name: 'InvalidBadgeType', message: 'Invalid badge type.' }, + }, + }, + schelling_oracle: { + contract: 'schelling_oracle', + enumName: 'OracleError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { code: 4, name: 'InvalidPeriod', message: 'Invalid period.' }, + 5: { code: 5, name: 'InvalidBondBps', message: 'Invalid bond bps.' }, + 6: { code: 6, name: 'InvalidOutcome', message: 'Invalid outcome.' }, + 7: { code: 7, name: 'SameOutcome', message: 'Same outcome.' }, + 8: { + code: 8, + name: 'InvalidPoolAmount', + message: 'Invalid pool amount.', + }, + 9: { + code: 9, + name: 'InvalidBondAmount', + message: 'Invalid bond amount.', + }, + 10: { + code: 10, + name: 'BondBelowMinimum', + message: 'Bond below minimum.', + }, + 11: { code: 11, name: 'DisputeNotFound', message: 'Dispute not found.' }, + 12: { + code: 12, + name: 'CommitPeriodEnded', + message: 'Commit period ended.', + }, + 13: { + code: 13, + name: 'RevealPeriodNotStarted', + message: 'Reveal period not started.', + }, + 14: { + code: 14, + name: 'RevealPeriodEnded', + message: 'Reveal period ended.', + }, + 15: { + code: 15, + name: 'RevealPeriodNotEnded', + message: 'Reveal period not ended.', + }, + 16: { code: 16, name: 'AlreadyCommitted', message: 'Already committed.' }, + 17: { + code: 17, + name: 'NoCommitmentFound', + message: 'No commitment found.', + }, + 18: { code: 18, name: 'AlreadyRevealed', message: 'Already revealed.' }, + 19: { + code: 19, + name: 'CommitmentMismatch', + message: 'Commitment mismatch.', + }, + 20: { + code: 20, + name: 'InvalidVoteOutcome', + message: 'Invalid vote outcome.', + }, + 21: { + code: 21, + name: 'InvalidStakeAmount', + message: 'Invalid stake amount.', + }, + 22: { code: 22, name: 'AlreadyResolved', message: 'Already resolved.' }, + 23: { code: 23, name: 'TooManyVoters', message: 'Too many voters.' }, + 24: { code: 24, name: 'Overflow', message: 'Overflow.' }, + }, + }, + tournament: { + contract: 'tournament', + enumName: 'TournamentError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { + code: 4, + name: 'TournamentNotFound', + message: 'Tournament not found.', + }, + 5: { + code: 5, + name: 'TournamentAlreadyFinalized', + message: 'Tournament already finalized.', + }, + 6: { + code: 6, + name: 'TournamentNotFinalized', + message: 'Tournament not finalized.', + }, + 7: { code: 7, name: 'InvalidTimeRange', message: 'Invalid time range.' }, + 8: { code: 8, name: 'InvalidWeights', message: 'Invalid weights.' }, + 9: { + code: 9, + name: 'MarketAlreadyEntered', + message: 'Market already entered.', + }, + 10: { + code: 10, + name: 'TournamentNotActive', + message: 'Tournament not active.', + }, + 11: { + code: 11, + name: 'InvalidPrizePool', + message: 'Invalid prize pool.', + }, + 12: { code: 12, name: 'InvalidTopN', message: 'Invalid top n.' }, + 13: { + code: 13, + name: 'ParticipantNotFound', + message: 'Participant not found.', + }, + }, + }, + yield_aggregator: { + contract: 'yield_aggregator', + enumName: 'AggregatorError', + errors: { + 1: { + code: 1, + name: 'AlreadyInitialized', + message: 'Already initialized.', + }, + 2: { code: 2, name: 'NotInitialized', message: 'Not initialized.' }, + 3: { code: 3, name: 'Unauthorized', message: 'Unauthorized.' }, + 4: { code: 4, name: 'InvalidAmount', message: 'Invalid amount.' }, + 5: { + code: 5, + name: 'InsufficientShares', + message: 'Insufficient shares.', + }, + 6: { code: 6, name: 'NoPayoutToClaim', message: 'No payout to claim.' }, + 7: { code: 7, name: 'ZeroTotalAssets', message: 'Zero total assets.' }, + }, + }, +}; diff --git a/packages/backend/src/indexer/diagnostic-parser.service.spec.ts b/packages/backend/src/indexer/diagnostic-parser.service.spec.ts new file mode 100644 index 00000000..802b94a8 --- /dev/null +++ b/packages/backend/src/indexer/diagnostic-parser.service.spec.ts @@ -0,0 +1,805 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { randomBytes } from 'crypto'; +import { + Account, + Keypair, + Networks, + Operation, + StrKey, + TransactionBuilder, + xdr, +} from '@stellar/stellar-sdk'; +import { + DiagnosticErrorCategory, + DiagnosticParserService, +} from './diagnostic-parser.service'; +import { CONTRACT_ERROR_REGISTRY } from './contract-errors.generated'; + +const CALL_REGISTRY_ID = StrKey.encodeContract(Buffer.alloc(32, 1)); +const OUTCOME_MANAGER_ID = StrKey.encodeContract(Buffer.alloc(32, 2)); +const UNKNOWN_ID = StrKey.encodeContract(Buffer.alloc(32, 9)); + +// ─── XDR builders ─────────────────────────────────────────────────────────── + +function diagnosticEvent(opts: { + contractId?: string | null; + topics: xdr.ScVal[]; + data?: xdr.ScVal; + inSuccessfulContractCall?: boolean; +}): xdr.DiagnosticEvent { + return new xdr.DiagnosticEvent({ + inSuccessfulContractCall: opts.inSuccessfulContractCall ?? false, + event: new xdr.ContractEvent({ + ext: new (xdr.ExtensionPoint as any)(0), + contractId: opts.contractId + ? (StrKey.decodeContract(opts.contractId) as any) + : null, + type: xdr.ContractEventType.diagnostic(), + body: new (xdr.ContractEventBody as any)( + 0, + new xdr.ContractEventV0({ + topics: opts.topics, + data: opts.data ?? xdr.ScVal.scvVoid(), + }), + ), + }), + }); +} + +const sym = (s: string) => xdr.ScVal.scvSymbol(s); +const str = (s: string) => xdr.ScVal.scvString(s); +const contractErr = (code: number) => + xdr.ScVal.scvError(xdr.ScError.sceContract(code)); +const hostErr = (type: string, code: string) => + xdr.ScVal.scvError( + (xdr.ScError as any)[`sce${type}`]( + (xdr.ScErrorCode as any)[`scec${code}`](), + ), + ); + +function errorEvent( + contractId: string | null, + err: xdr.ScVal, + data: xdr.ScVal = str('escalating error'), +) { + return diagnosticEvent({ contractId, topics: [sym('error'), err], data }); +} + +function fnCallEvent(contractId: string, fn: string) { + return diagnosticEvent({ + contractId: null, + topics: [ + sym('fn_call'), + xdr.ScVal.scvBytes(StrKey.decodeContract(contractId) as any), + sym(fn), + ], + data: xdr.ScVal.scvVoid(), + }); +} + +function txResult( + opResult: xdr.InvokeHostFunctionResult, + code: 'txFailed' | 'txSuccess' = 'txFailed', +): xdr.TransactionResult { + const ops = [ + new (xdr.OperationResult as any).opInner( + xdr.OperationResultTr.invokeHostFunction(opResult), + ), + ]; + return new xdr.TransactionResult({ + feeCharged: xdr.Int64.fromString('100'), + result: (xdr.TransactionResultResult as any)[code](ops), + ext: new (xdr.TransactionResultExt as any)(0), + }); +} + +describe('DiagnosticParserService', () => { + let service: DiagnosticParserService; + const envBackup = { ...process.env }; + + beforeEach(() => { + process.env = { ...envBackup }; + delete process.env.SOROBAN_CONTRACT_ID; + delete process.env.OUTCOME_MANAGER_CONTRACT_ADDRESS; + delete process.env.SOROBAN_CONTRACT_ERROR_MAP; + delete process.env.SOROBAN_CONTRACT_KIND; + service = new DiagnosticParserService(); + service.registerContract(CALL_REGISTRY_ID, 'call_registry'); + service.registerContract(OUTCOME_MANAGER_ID, 'outcome_manager'); + jest.spyOn((service as any).logger, 'warn').mockImplementation(() => {}); + jest.spyOn((service as any).logger, 'debug').mockImplementation(() => {}); + }); + + afterAll(() => { + process.env = envBackup; + }); + + // ─── Registry coverage ──────────────────────────────────────────────────── + + describe('contract error registry', () => { + const entries = Object.values(CONTRACT_ERROR_REGISTRY).flatMap((r) => + Object.values(r.errors).map((e) => [r.contract, r.enumName, e] as const), + ); + + it('covers every contract crate with a #[contracterror] enum', () => { + expect( + Object.keys(CONTRACT_ERROR_REGISTRY).length, + ).toBeGreaterThanOrEqual(20); + expect(entries.length).toBeGreaterThan(250); + }); + + it.each( + entries.map(([c, en, e]) => [`${en}::${e.name} (#${e.code})`, c, e]), + )('translates %s to a clear message', (_label, contract, def) => { + const t = service.translateContractError(def.code, null, contract); + expect(t.name).toBe(def.name); + expect(t.contract).toBe(contract); + expect(t.message).toBe(def.message); + expect(t.message.length).toBeGreaterThan(5); + expect(t.message).toMatch(/[.!?]$/); + }); + + const contractsRoot = path.resolve( + __dirname, + '..', + '..', + '..', + 'contracts', + ); + const contractsAvailable = fs.existsSync(contractsRoot); + (contractsAvailable ? it : it.skip)( + 'is in sync with packages/contracts/*/src/errors.rs', + () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const gen = require('../../scripts/generate-contract-errors.js') as { + collectRegistry: () => Array<{ + crate: string; + enumName: string; + variants: Array<{ code: number; name: string; message: string }>; + }>; + }; + // Compare data, not text, so formatting of the generated file is free. + const expected = Object.fromEntries( + gen.collectRegistry().map((r) => [ + r.crate, + { + contract: r.crate, + enumName: r.enumName, + errors: Object.fromEntries(r.variants.map((v) => [v.code, v])), + }, + ]), + ); + expect(CONTRACT_ERROR_REGISTRY).toEqual(expected); + }, + ); + + (contractsAvailable ? it : it.skip)( + 'maps every numeric variant declared in the Rust sources', + () => { + // Independent check with a deliberately simple regex, so a generator + // parsing bug cannot hide a missing code. + for (const [crate, reg] of Object.entries(CONTRACT_ERROR_REGISTRY)) { + const dir = path.join(contractsRoot, crate, 'src'); + const file = ['errors.rs', 'lib.rs'] + .map((f) => path.join(dir, f)) + .find((f) => fs.existsSync(f))!; + const src = fs.readFileSync(file, 'utf8'); + const body = src.split(`pub enum ${reg.enumName}`)[1].split('\n}')[0]; + const rustCodes = [...body.matchAll(/^\s*(\w+)\s*=\s*(\d+)/gm)].map( + (m) => [m[1], Number(m[2])] as const, + ); + expect(rustCodes.length).toBe(Object.keys(reg.errors).length); + for (const [name, code] of rustCodes) { + expect(reg.errors[code]?.name).toBe(name); + } + } + }, + ); + }); + + // ─── translateContractError ─────────────────────────────────────────────── + + describe('translateContractError', () => { + it('resolves the contract from its registered contract ID', () => { + const t = service.translateContractError(6, CALL_REGISTRY_ID); + expect(t).toMatchObject({ + contract: 'call_registry', + enumName: 'CallRegistryError', + name: 'CallEnded', + }); + expect(t.message).toContain('staking is no longer allowed'); + }); + + it('uses the same code differently per contract', () => { + expect(service.translateContractError(6, OUTCOME_MANAGER_ID).name).toBe( + 'InvalidOutcome', + ); + }); + + it('lists candidates when the contract is unknown and the code is ambiguous', () => { + const t = service.translateContractError(6, UNKNOWN_ID); + expect(t.name).toBeNull(); + expect(t.candidates).toEqual( + expect.arrayContaining(['call_registry', 'outcome_manager']), + ); + expect(t.message).toContain('CallRegistryError::CallEnded'); + expect(t.message).toContain(UNKNOWN_ID); + }); + + it('reports unrecognised codes for a known contract', () => { + const t = service.translateContractError(999, CALL_REGISTRY_ID); + expect(t.name).toBeNull(); + expect(t.message).toContain('#999'); + }); + + it('reports codes no contract defines', () => { + const t = service.translateContractError(4242, UNKNOWN_ID); + expect(t.candidates).toEqual([]); + expect(t.message).toContain('#4242'); + }); + + it('seeds contract mappings from the environment', () => { + process.env.SOROBAN_CONTRACT_ID = UNKNOWN_ID; + process.env.SOROBAN_CONTRACT_ERROR_MAP = `${OUTCOME_MANAGER_ID}:outcome_manager, bad-entry ,X:not_a_crate`; + const fromEnv = new DiagnosticParserService(); + jest.spyOn((fromEnv as any).logger, 'warn').mockImplementation(() => {}); + expect(fromEnv.translateContractError(6, UNKNOWN_ID).name).toBe( + 'CallEnded', + ); + expect(fromEnv.translateContractError(6, OUTCOME_MANAGER_ID).name).toBe( + 'InvalidOutcome', + ); + }); + + it('honours SOROBAN_CONTRACT_KIND for the indexed contract', () => { + process.env.SOROBAN_CONTRACT_ID = UNKNOWN_ID; + process.env.SOROBAN_CONTRACT_KIND = 'prediction_market'; + const fromEnv = new DiagnosticParserService(); + expect(fromEnv.translateContractError(27, UNKNOWN_ID).name).toBe( + 'RolloverInsufficientAmount', + ); + }); + + it('ignores mappings to unknown crates', () => { + service.registerContract(UNKNOWN_ID, 'nope'); + expect(service.translateContractError(6, UNKNOWN_ID).name).toBeNull(); + }); + }); + + // ─── parseDiagnosticEvent ───────────────────────────────────────────────── + + describe('parseDiagnosticEvent', () => { + it('decodes a contract error event from base64 XDR', () => { + const b64 = errorEvent(CALL_REGISTRY_ID, contractErr(6)).toXDR('base64'); + const decoded = service.parseDiagnosticEvent(b64)!; + expect(decoded.contractId).toBe(CALL_REGISTRY_ID); + expect(decoded.eventType).toBe('diagnostic'); + expect(decoded.topics).toEqual(['error', 'Error(Contract, #6)']); + expect(decoded.error).toMatchObject({ + category: DiagnosticErrorCategory.CONTRACT, + code: 6, + enumName: 'CallRegistryError', + errorName: 'CallEnded', + hostMessage: 'escalating error', + }); + }); + + it('accepts xdr instances and raw buffers', () => { + const ev = errorEvent(OUTCOME_MANAGER_ID, contractErr(8)); + expect(service.parseDiagnosticEvent(ev)!.error!.errorName).toBe( + 'AlreadyClaimed', + ); + expect(service.parseDiagnosticEvent(ev.toXDR())!.error!.errorName).toBe( + 'AlreadyClaimed', + ); + }); + + it('extracts host message and args from a vec payload', () => { + const ev = errorEvent( + CALL_REGISTRY_ID, + contractErr(3), + xdr.ScVal.scvVec([ + str('stake amount must be positive'), + xdr.ScVal.scvI128( + new xdr.Int128Parts({ + hi: xdr.Int64.fromString('0'), + lo: xdr.Uint64.fromString('0'), + }), + ), + xdr.ScVal.scvBytes(Buffer.from([0xde, 0xad])), + ]), + ); + const { error } = service.parseDiagnosticEvent(ev)!; + expect(error!.hostMessage).toBe('stake amount must be positive'); + expect(error!.args).toEqual(['0', 'dead']); + }); + + it('finds an error carried in the data field', () => { + const ev = diagnosticEvent({ + contractId: CALL_REGISTRY_ID, + topics: [sym('log')], + data: contractErr(9), + }); + expect(service.parseDiagnosticEvent(ev)!.error!.errorName).toBe( + 'Unauthorized', + ); + }); + + it('records fn_call targets', () => { + const decoded = service.parseDiagnosticEvent( + fnCallEvent(CALL_REGISTRY_ID, 'stake_on_call'), + )!; + expect(decoded.fnCall).toBe('stake_on_call'); + expect(decoded.error).toBeNull(); + }); + + it.each([ + ['empty string', ''], + ['whitespace', ' '], + ['non-base64 garbage', '%%%not-xdr%%%'], + ['truncated xdr', 'AAAA'], + ['null', null], + ['undefined', undefined], + ['number', 42], + ['boolean', true], + ['plain object', { foo: 'bar' }], + [ + 'object whose accessors throw', + { + event: () => { + throw new Error('x'); + }, + }, + ], + ['empty buffer', Buffer.alloc(0)], + ])('returns null for %s', (_label, input) => { + expect(() => service.parseDiagnosticEvent(input)).not.toThrow(); + expect(service.parseDiagnosticEvent(input)).toBeNull(); + }); + + it('never throws on random bytes (fuzz)', () => { + for (let i = 0; i < 500; i++) { + const buf = randomBytes(1 + (i % 256)); + expect(() => service.parseDiagnosticEvent(buf)).not.toThrow(); + expect(() => + service.parseDiagnosticEvent(buf.toString('base64')), + ).not.toThrow(); + } + }); + + it('never throws on corrupted valid events (fuzz)', () => { + const valid = errorEvent(CALL_REGISTRY_ID, contractErr(6)).toXDR(); + for (let i = 0; i < 500; i++) { + const buf = Buffer.from(valid); + buf[i % buf.length] ^= 0xff; + const cut = buf.subarray(0, buf.length - (i % 7)); + expect(() => service.parseDiagnosticEvent(cut)).not.toThrow(); + } + }); + }); + + // ─── Host error categories ──────────────────────────────────────────────── + + describe('host errors', () => { + const analyze = (err: xdr.ScVal, msg = 'x') => + service.analyzeTransaction({ + txHash: 'tx', + diagnosticEvents: [errorEvent(CALL_REGISTRY_ID, err, str(msg))], + }); + + it('flags authorization failures', () => { + const r = analyze( + hostErr('Auth', 'InvalidAction'), + 'Unauthorized function call for address', + ); + expect(r.authFailure).toBe(true); + expect(r.primaryError).toMatchObject({ + category: DiagnosticErrorCategory.AUTH, + codeName: 'InvalidAction', + hostMessage: 'Unauthorized function call for address', + }); + expect(r.summary).toMatch(/^Authorization failed/); + }); + + it('treats contract Unauthorized variants as auth failures', () => { + expect(analyze(contractErr(9)).authFailure).toBe(true); + }); + + it('flags footprint exhaustion (Storage/ExceededLimit)', () => { + const r = analyze( + hostErr('Storage', 'ExceededLimit'), + 'trying to access contract storage key outside of the footprint', + ); + expect(r.footprintExhausted).toBe(true); + expect(r.resourceLimitExceeded).toBe(true); + expect(r.primaryError!.category).toBe(DiagnosticErrorCategory.FOOTPRINT); + expect(r.summary).toMatch(/^Footprint exhausted/); + }); + + it('flags footprint exhaustion from the host message alone', () => { + const r = analyze( + hostErr('Storage', 'InvalidAction'), + 'key is outside of the declared footprint', + ); + expect(r.footprintExhausted).toBe(true); + }); + + it('flags budget exhaustion', () => { + const r = analyze(hostErr('Budget', 'ExceededLimit')); + expect(r.budgetExhausted).toBe(true); + expect(r.resourceLimitExceeded).toBe(true); + expect(r.summary).toMatch(/budget exceeded/i); + }); + + it.each([ + ['Budget', 'InternalError', /Resource budget error/], + ['Storage', 'MissingValue', /ledger entry was not found/], + ['Storage', 'ExistingValue', /Storage error: a value already exists/], + ['WasmVm', 'InvalidAction', /Host WasmVm error: an invalid action/], + ['Value', 'UnexpectedType', /unexpected type/], + ['Crypto', 'InvalidInput', /Host Crypto error/], + ])('describes %s/%s', (type, code, expected) => { + expect(analyze(hostErr(type, code)).primaryError!.message).toMatch( + expected, + ); + }); + }); + + // ─── analyzeTransaction ─────────────────────────────────────────────────── + + describe('analyzeTransaction', () => { + it('prefers the contract error over escalated host errors and dedupes', () => { + const r = service.analyzeTransaction({ + txHash: 'abc', + diagnosticEvents: [ + fnCallEvent(CALL_REGISTRY_ID, 'stake_on_call'), + errorEvent(CALL_REGISTRY_ID, contractErr(6), xdr.ScVal.scvVoid()), + errorEvent(CALL_REGISTRY_ID, contractErr(6), str('call ended')), + errorEvent(null, hostErr('WasmVm', 'InvalidAction')), + errorEvent(CALL_REGISTRY_ID, contractErr(6)), + ], + resultXdr: txResult( + xdr.InvokeHostFunctionResult.invokeHostFunctionTrapped(), + ).toXDR('base64'), + }); + + expect(r.failed).toBe(true); + expect(r.contractId).toBe(CALL_REGISTRY_ID); + expect(r.callStack).toEqual(['stake_on_call']); + expect(r.errors).toHaveLength(2); + expect(r.contractError).toMatchObject({ + errorName: 'CallEnded', + hostMessage: 'call ended', + }); + expect(r.primaryError).toBe(r.contractError); + expect(r.resultCode).toBe('txFailed'); + expect(r.operationResultCode).toBe('invokeHostFunctionTrapped'); + expect(r.summary).toBe( + "The call's end_ts has already passed; staking is no longer allowed. [CallRegistryError::CallEnded]", + ); + }); + + it('counts malformed events without failing the rest', () => { + const r = service.analyzeTransaction({ + diagnosticEvents: [ + 'garbage', + null, + errorEvent(OUTCOME_MANAGER_ID, contractErr(9)).toXDR('base64'), + { nope: true }, + ], + }); + expect(r.eventCount).toBe(4); + expect(r.malformedEventCount).toBe(3); + expect(r.contractError!.errorName).toBe('NothingToClaim'); + }); + + it('tolerates non-array diagnostics and garbage everywhere', () => { + for (const junk of [ + null, + undefined, + 'x', + 1, + {}, + [], + [[]], + { length: 3 }, + ]) { + expect(() => + service.analyzeTransaction({ + txHash: 'junk', + diagnosticEvents: junk, + resultXdr: junk, + resultMetaXdr: junk, + errorMessage: junk, + }), + ).not.toThrow(); + } + }); + + it('reports success when nothing failed', () => { + const r = service.analyzeTransaction({ + diagnosticEvents: [fnCallEvent(CALL_REGISTRY_ID, 'get_call')], + resultXdr: txResult( + xdr.InvokeHostFunctionResult.invokeHostFunctionSuccess( + Buffer.alloc(32), + ), + 'txSuccess', + ), + }); + expect(r.failed).toBe(false); + expect(r.summary).toBe('Transaction succeeded.'); + }); + + it('falls back to result codes when there are no diagnostic events', () => { + const archived = service.analyzeTransaction({ + resultXdr: txResult( + xdr.InvokeHostFunctionResult.invokeHostFunctionEntryArchived(), + ), + }); + expect(archived.failed).toBe(true); + expect(archived.entryArchived).toBe(true); + expect(archived.summary).toMatch(/archived/); + + const limit = service.analyzeTransaction({ + resultXdr: txResult( + xdr.InvokeHostFunctionResult.invokeHostFunctionResourceLimitExceeded(), + ), + }); + expect(limit.resourceLimitExceeded).toBe(true); + expect(limit.summary).toMatch(/resource limits/); + + const trapped = service.analyzeTransaction({ + resultXdr: txResult( + xdr.InvokeHostFunctionResult.invokeHostFunctionTrapped(), + ), + }); + expect(trapped.summary).toBe( + 'Transaction failed (invokeHostFunctionTrapped).', + ); + }); + + it('reads diagnostic events from TransactionMeta when not provided directly', () => { + const meta = new (xdr.TransactionMeta as any)( + 3, + new xdr.TransactionMetaV3({ + ext: new (xdr.ExtensionPoint as any)(0), + txChangesBefore: [], + operations: [], + txChangesAfter: [], + sorobanMeta: new xdr.SorobanTransactionMeta({ + ext: new (xdr.SorobanTransactionMetaExt as any)(0), + events: [], + returnValue: xdr.ScVal.scvVoid(), + diagnosticEvents: [errorEvent(CALL_REGISTRY_ID, contractErr(15))], + }), + }), + ); + const r = service.analyzeTransaction({ + resultMetaXdr: meta.toXDR('base64'), + }); + expect(r.contractError!.errorName).toBe('StakingCutoffActive'); + }); + }); + + // ─── Error strings ──────────────────────────────────────────────────────── + + describe('parseErrorString / analyzeSimulationResponse', () => { + const simError = + 'HostError: Error(Contract, #6)\n\nEvent log (newest first):\n' + + ` 0: [Diagnostic Event] contract:${CALL_REGISTRY_ID}, topics:[error, Error(Contract, #6)], data:"escalating"\n` + + ' 1: [Diagnostic Event] topics:[error, Error(WasmVm, InvalidAction)]'; + + it('parses contract and host errors from a simulation error', () => { + const errors = service.parseErrorString(simError); + expect(errors).toHaveLength(2); + expect(errors[0]).toMatchObject({ + contractId: CALL_REGISTRY_ID, + errorName: 'CallEnded', + }); + expect(errors[1]).toMatchObject({ + category: DiagnosticErrorCategory.WASM_VM, + codeName: 'InvalidAction', + }); + }); + + it('analyzes a failed simulation response', () => { + const r = service.analyzeSimulationResponse({ + error: simError, + events: [], + }); + expect(r.contractError!.errorName).toBe('CallEnded'); + }); + + it('handles numeric non-contract codes and junk', () => { + expect(service.parseErrorString('Error(Budget, #3)')[0]).toMatchObject({ + category: DiagnosticErrorCategory.BUDGET, + codeName: '3', + }); + expect(service.parseErrorString(undefined)).toEqual([]); + expect(service.parseErrorString(12 as any)).toEqual([]); + expect(service.parseErrorString('no errors here')).toEqual([]); + }); + + it('uses a contract hint when no ID is available', () => { + const [e] = service.parseErrorString( + 'Error(Contract, #27)', + null, + 'prediction_market', + ); + expect(e.errorName).toBe('RolloverInsufficientAmount'); + }); + }); + + // ─── RPC response adapters ──────────────────────────────────────────────── + + describe('RPC response adapters', () => { + it('analyzes a sendTransaction ERROR response', () => { + const r = service.analyzeSendTransactionResponse({ + status: 'ERROR', + hash: 'deadbeef', + errorResult: txResult( + xdr.InvokeHostFunctionResult.invokeHostFunctionTrapped(), + ), + diagnosticEvents: [errorEvent(CALL_REGISTRY_ID, contractErr(7))], + }); + expect(r.txHash).toBe('deadbeef'); + expect(r.contractError!.errorName).toBe('CallSettled'); + }); + + it('analyzes a getTransaction FAILED response with raw XDR strings', () => { + const r = service.analyzeGetTransactionResponse('h1', { + status: 'FAILED', + resultXdr: txResult( + xdr.InvokeHostFunctionResult.invokeHostFunctionTrapped(), + ).toXDR('base64'), + diagnosticEventsXdr: [ + errorEvent(OUTCOME_MANAGER_ID, contractErr(26)).toXDR('base64'), + ], + }); + expect(r.txHash).toBe('h1'); + expect(r.contractError!.errorName).toBe('DisputeWindowExpired'); + }); + + it('survives null responses', () => { + expect(service.analyzeGetTransactionResponse('h', null).failed).toBe( + false, + ); + expect( + service.analyzeSendTransactionResponse(undefined).txHash, + ).toBeNull(); + expect(service.analyzeSimulationResponse(null).failed).toBe(false); + }); + }); + + // ─── parseTransactionResult ─────────────────────────────────────────────── + + describe('parseTransactionResult', () => { + it('unwraps fee-bump inner results', () => { + const inner = txResult( + xdr.InvokeHostFunctionResult.invokeHostFunctionTrapped(), + ); + const feeBump = new xdr.TransactionResult({ + feeCharged: xdr.Int64.fromString('200'), + result: (xdr.TransactionResultResult as any).txFeeBumpInnerFailed( + new xdr.InnerTransactionResultPair({ + transactionHash: Buffer.alloc(32), + result: new xdr.InnerTransactionResult({ + feeCharged: xdr.Int64.fromString('100'), + result: (xdr.InnerTransactionResultResult as any).txFailed( + inner.result().results(), + ), + ext: new (xdr.InnerTransactionResultExt as any)(0), + }), + }), + ), + ext: new (xdr.TransactionResultExt as any)(0), + }); + expect(service.parseTransactionResult(feeBump.toXDR('base64'))).toEqual({ + resultCode: 'txFailed', + operationResultCode: 'invokeHostFunctionTrapped', + }); + }); + + it('reports tx-level failures without operation results', () => { + const r = new xdr.TransactionResult({ + feeCharged: xdr.Int64.fromString('100'), + result: (xdr.TransactionResultResult as any).txBadSeq(), + ext: new (xdr.TransactionResultExt as any)(0), + }); + expect(service.parseTransactionResult(r)).toEqual({ + resultCode: 'txBadSeq', + operationResultCode: null, + }); + }); + + it('returns nulls for malformed input', () => { + for (const junk of ['AAAA', 'zzz', {}, 5, null]) { + expect(service.parseTransactionResult(junk)).toEqual({ + resultCode: null, + operationResultCode: null, + }); + } + }); + }); + + // ─── extractSourceAccount ───────────────────────────────────────────────── + + describe('extractSourceAccount', () => { + const user = Keypair.random(); + const sponsor = Keypair.random(); + const inner = new TransactionBuilder(new Account(user.publicKey(), '1'), { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.bumpSequence({ bumpTo: '5' })) + .setTimeout(0) + .build(); + + it('reads the source of a v1 envelope (xdr or base64)', () => { + expect(service.extractSourceAccount(inner.toEnvelope())).toBe( + user.publicKey(), + ); + expect(service.extractSourceAccount(inner.toXDR())).toBe( + user.publicKey(), + ); + }); + + it('reads the inner (user) source of a fee-bump envelope', () => { + inner.sign(user); + const fb = TransactionBuilder.buildFeeBumpTransaction( + sponsor, + '200', + inner, + Networks.TESTNET, + ); + expect(service.extractSourceAccount(fb.toEnvelope())).toBe( + user.publicKey(), + ); + }); + + it('returns null for malformed envelopes', () => { + for (const junk of ['', 'AAAA', null, {}, 3]) { + expect(service.extractSourceAccount(junk)).toBeNull(); + } + }); + }); + + // ─── logReport ──────────────────────────────────────────────────────────── + + describe('logReport', () => { + it('emits a structured warning for failed transactions', () => { + const warn = jest.spyOn((service as any).logger, 'warn'); + const debug = jest.spyOn((service as any).logger, 'debug'); + const report = service.analyzeTransaction({ + txHash: 'tx9', + diagnosticEvents: [ + errorEvent(CALL_REGISTRY_ID, contractErr(6)), + errorEvent(null, hostErr('WasmVm', 'InvalidAction')), + ], + }); + service.logReport(report, { userAddress: 'GUSER' }); + + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ + msg: 'Soroban transaction failed', + txHash: 'tx9', + userAddress: 'GUSER', + contractId: CALL_REGISTRY_ID, + primaryError: expect.objectContaining({ errorName: 'CallEnded' }), + }), + ); + expect(debug).toHaveBeenCalledWith( + expect.objectContaining({ + msg: 'Soroban transaction diagnostic errors', + }), + ); + }); + + it('stays silent for successful transactions', () => { + const warn = jest.spyOn((service as any).logger, 'warn'); + service.logReport(service.analyzeTransaction({})); + expect(warn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/backend/src/indexer/diagnostic-parser.service.ts b/packages/backend/src/indexer/diagnostic-parser.service.ts new file mode 100644 index 00000000..e9cf04eb --- /dev/null +++ b/packages/backend/src/indexer/diagnostic-parser.service.ts @@ -0,0 +1,962 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + StrKey, + encodeMuxedAccountToAddress, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; +import { + CONTRACT_ERROR_REGISTRY, + ContractErrorEnum, +} from './contract-errors.generated'; + +/** + * Soroban RPC diagnostic event & XDR error code parser (BE-004). + * + * Turns the raw `diagnosticEvents` / `resultXdr` / simulation error strings + * returned by Soroban RPC for a failed transaction into a structured + * {@link TransactionDiagnosticReport}: which contract failed, the + * `#[contracterror]` variant it raised (translated via the registry generated + * from packages/contracts/*\/src/errors.rs), and whether the failure was an + * authorization failure, a footprint/storage exhaustion or a budget overrun. + * + * Every entry point is total: malformed XDR, unexpected shapes or unknown + * codes degrade to `UNKNOWN` entries and counters — they never throw. + */ + +export enum DiagnosticErrorCategory { + CONTRACT = 'contract', + AUTH = 'auth', + FOOTPRINT = 'footprint', + STORAGE = 'storage', + BUDGET = 'budget', + WASM_VM = 'wasm_vm', + CONTEXT = 'context', + OBJECT = 'object', + CRYPTO = 'crypto', + EVENTS = 'events', + VALUE = 'value', + UNKNOWN = 'unknown', +} + +export interface ContractErrorTranslation { + /** Crate name under packages/contracts, when the contract is known. */ + contract: string | null; + enumName: string | null; + /** Rust variant name, e.g. `CallEnded`. */ + name: string | null; + message: string; + /** + * When the emitting contract could not be identified, every registered + * contract that defines this code — useful for debugging mis-configured + * contract ID mappings. + */ + candidates: string[]; +} + +export interface HostErrorDetail { + category: DiagnosticErrorCategory; + /** ScErrorType without the `sce` prefix: `Contract`, `Auth`, `Storage`... */ + errorType: string; + /** Numeric code for contract errors. */ + code: number | null; + /** ScErrorCode without the `scec` prefix for host errors, e.g. `ExceededLimit`. */ + codeName: string | null; + contractId: string | null; + contract: string | null; + enumName: string | null; + /** Rust variant name for contract errors, e.g. `CallEnded`. */ + errorName: string | null; + /** Human-readable, user-facing explanation. */ + message: string; + /** Raw diagnostic message emitted by the host, if any. */ + hostMessage: string | null; + /** JSON-safe diagnostic arguments emitted alongside the error. */ + args: unknown[]; +} + +export interface DecodedDiagnosticEvent { + inSuccessfulContractCall: boolean; + contractId: string | null; + eventType: string; + topics: unknown[]; + data: unknown; + error: HostErrorDetail | null; + /** Function name when this is a `fn_call` diagnostic event. */ + fnCall: string | null; +} + +export interface TransactionDiagnosticReport { + txHash: string | null; + contractId: string | null; + failed: boolean; + /** TransactionResultCode, e.g. `txFailed`. */ + resultCode: string | null; + /** InvokeHostFunctionResultCode, e.g. `invokeHostFunctionTrapped`. */ + operationResultCode: string | null; + errors: HostErrorDetail[]; + primaryError: HostErrorDetail | null; + contractError: HostErrorDetail | null; + authFailure: boolean; + footprintExhausted: boolean; + budgetExhausted: boolean; + resourceLimitExceeded: boolean; + entryArchived: boolean; + /** Contract function names invoked, outermost first. */ + callStack: string[]; + eventCount: number; + malformedEventCount: number; + summary: string; +} + +export interface AnalyzeTransactionInput { + txHash?: string | null; + contractId?: string | null; + /** xdr.DiagnosticEvent instances, base64 strings or raw buffers. */ + diagnosticEvents?: unknown; + /** xdr.TransactionResult or base64 string. */ + resultXdr?: unknown; + /** xdr.TransactionMeta or base64 — diagnostic events fallback. */ + resultMetaXdr?: unknown; + /** Simulation / host error string, e.g. `HostError: Error(Contract, #6)`. */ + errorMessage?: unknown; + /** Force a contract crate for code translation (skips contract ID lookup). */ + contractHint?: string | null; +} + +const ERROR_TYPE_CATEGORY: Record = { + Contract: DiagnosticErrorCategory.CONTRACT, + WasmVm: DiagnosticErrorCategory.WASM_VM, + Context: DiagnosticErrorCategory.CONTEXT, + Storage: DiagnosticErrorCategory.STORAGE, + Object: DiagnosticErrorCategory.OBJECT, + Crypto: DiagnosticErrorCategory.CRYPTO, + Events: DiagnosticErrorCategory.EVENTS, + Budget: DiagnosticErrorCategory.BUDGET, + Value: DiagnosticErrorCategory.VALUE, + Auth: DiagnosticErrorCategory.AUTH, +}; + +const HOST_CODE_MESSAGES: Record = { + ArithDomain: 'an arithmetic operation was out of domain', + IndexBounds: 'an index was out of bounds', + InvalidInput: 'invalid input was supplied', + MissingValue: 'a required value was missing', + ExistingValue: 'a value already exists', + ExceededLimit: 'a limit was exceeded', + InvalidAction: 'an invalid action was attempted', + InternalError: 'an internal host error occurred', + UnexpectedType: 'a value had an unexpected type', + UnexpectedSize: 'a value had an unexpected size', +}; + +/** Lower number = more specific root cause; drives primaryError selection. */ +const CATEGORY_PRIORITY: Record = { + [DiagnosticErrorCategory.CONTRACT]: 0, + [DiagnosticErrorCategory.AUTH]: 1, + [DiagnosticErrorCategory.FOOTPRINT]: 2, + [DiagnosticErrorCategory.BUDGET]: 3, + [DiagnosticErrorCategory.STORAGE]: 4, + [DiagnosticErrorCategory.VALUE]: 5, + [DiagnosticErrorCategory.OBJECT]: 6, + [DiagnosticErrorCategory.CRYPTO]: 7, + [DiagnosticErrorCategory.CONTEXT]: 8, + [DiagnosticErrorCategory.EVENTS]: 9, + [DiagnosticErrorCategory.WASM_VM]: 10, + [DiagnosticErrorCategory.UNKNOWN]: 11, +}; + +const CONTRACT_ID_RE = /\bC[A-Z2-7]{55}\b/; +const HOST_ERROR_RE = /Error\(\s*(\w+)\s*,\s*(#?\w+)\s*\)/g; + +@Injectable() +export class DiagnosticParserService { + private readonly logger = new Logger(DiagnosticParserService.name); + + /** contractId (C...) -> crate name in CONTRACT_ERROR_REGISTRY. */ + private readonly contractKinds = new Map(); + + constructor() { + this.loadContractKindsFromEnv(); + } + + // ─── Contract ID → contract crate mapping ───────────────────────────────── + + /** Register which contract crate a deployed contract ID runs. */ + registerContract(contractId: string, contract: string): void { + if (!contractId || !CONTRACT_ERROR_REGISTRY[contract]) { + this.logger.warn({ + msg: 'Ignoring contract mapping for unknown contract crate', + contractId, + contract, + }); + return; + } + this.contractKinds.set(contractId, contract); + } + + /** + * Seeds mappings from the environment: + * - SOROBAN_CONTRACT_ERROR_MAP="CID1:call_registry,CID2:outcome_manager" + * - SOROBAN_CONTRACT_ID (the indexed contract) as SOROBAN_CONTRACT_KIND, + * defaulting to call_registry + * - OUTCOME_MANAGER_CONTRACT_ADDRESS as outcome_manager + */ + private loadContractKindsFromEnv(): void { + const pairs: Array<[string | undefined, string]> = [ + [ + process.env.SOROBAN_CONTRACT_ID, + process.env.SOROBAN_CONTRACT_KIND ?? 'call_registry', + ], + [process.env.OUTCOME_MANAGER_CONTRACT_ADDRESS, 'outcome_manager'], + ]; + + for (const entry of (process.env.SOROBAN_CONTRACT_ERROR_MAP ?? '').split( + ',', + )) { + const [id, kind] = entry.split(':').map((s) => s.trim()); + if (id && kind) pairs.push([id, kind]); + } + + for (const [id, kind] of pairs) { + if (id) this.registerContract(id, kind); + } + } + + // ─── Contract error translation ─────────────────────────────────────────── + + /** + * Translate a numeric `Error(Contract, #code)` into the contract's + * `#[contracterror]` variant and message. + */ + translateContractError( + code: number, + contractId?: string | null, + contractHint?: string | null, + ): ContractErrorTranslation { + const kind = + (contractHint && CONTRACT_ERROR_REGISTRY[contractHint] + ? contractHint + : null) ?? (contractId ? this.contractKinds.get(contractId) : null); + + if (kind) { + const registry: ContractErrorEnum = CONTRACT_ERROR_REGISTRY[kind]; + const def = registry.errors[code]; + if (def) { + return { + contract: kind, + enumName: registry.enumName, + name: def.name, + message: def.message, + candidates: [kind], + }; + } + return { + contract: kind, + enumName: registry.enumName, + name: null, + message: `Contract ${kind} failed with unrecognised error code #${code}.`, + candidates: [kind], + }; + } + + // Unknown contract: search every registry for this code. + const matches = Object.values(CONTRACT_ERROR_REGISTRY).filter( + (r) => r.errors[code], + ); + const names = new Set(matches.map((r) => r.errors[code].name)); + if (matches.length > 0 && names.size === 1) { + // Unambiguous by name (e.g. AlreadyInitialized = 1 everywhere). + const def = matches[0].errors[code]; + return { + contract: matches.length === 1 ? matches[0].contract : null, + enumName: matches.length === 1 ? matches[0].enumName : null, + name: def.name, + message: def.message, + candidates: matches.map((r) => r.contract), + }; + } + + return { + contract: null, + enumName: null, + name: null, + message: + `Contract${contractId ? ` ${contractId}` : ''} failed with error code #${code}` + + (matches.length > 0 + ? ` (possible: ${matches + .map((r) => `${r.enumName}::${r.errors[code].name}`) + .join(', ')}).` + : '.'), + candidates: matches.map((r) => r.contract), + }; + } + + // ─── Single diagnostic event ────────────────────────────────────────────── + + /** + * Decode one diagnostic event. Accepts an `xdr.DiagnosticEvent`, a base64 + * XDR string or a raw buffer. Returns null for anything undecodable. + */ + parseDiagnosticEvent( + input: unknown, + contractHint?: string | null, + ): DecodedDiagnosticEvent | null { + const event = this.toDiagnosticEvent(input); + if (!event) return null; + + try { + const contractEvent = event.event(); + const contractId = this.safe(() => { + const raw = contractEvent.contractId(); + return raw ? StrKey.encodeContract(Buffer.from(raw)) : null; + }, null); + const eventType = this.safe(() => contractEvent.type().name, 'unknown'); + const body = contractEvent.body().v0(); + const rawTopics = body.topics(); + const rawData = body.data(); + + const topics = rawTopics.map((t) => this.scValToJson(t)); + const data = this.scValToJson(rawData); + + let error: HostErrorDetail | null = null; + const errorVal = + rawTopics.find((t) => this.isScvError(t)) ?? + (this.isScvError(rawData) ? rawData : undefined); + if (errorVal) { + const { hostMessage, args } = this.extractHostMessage( + rawData, + errorVal === rawData, + ); + error = this.parseScError( + errorVal.error(), + contractId, + hostMessage, + args, + contractHint, + ); + } + + const fnCall = + topics[0] === 'fn_call' && typeof topics[2] === 'string' + ? topics[2] + : null; + + return { + inSuccessfulContractCall: this.safe( + () => event.inSuccessfulContractCall(), + false, + ), + contractId, + eventType, + topics, + data, + error, + fnCall, + }; + } catch (err) { + this.logger.debug({ + msg: 'Failed to decode diagnostic event body', + error: (err as Error).message, + }); + return null; + } + } + + // ─── Error strings (simulation / HostError) ─────────────────────────────── + + /** + * Parse host error strings such as the `error` field of a failed + * simulation: `HostError: Error(Contract, #6) ... contract:C...`. + */ + parseErrorString( + message: unknown, + contractId?: string | null, + contractHint?: string | null, + ): HostErrorDetail[] { + if (typeof message !== 'string' || !message) return []; + + const inferredContract = + contractId ?? message.match(CONTRACT_ID_RE)?.[0] ?? null; + const seen = new Set(); + const out: HostErrorDetail[] = []; + + for (const m of message.matchAll(HOST_ERROR_RE)) { + const [, errorType, rawCode] = m; + const key = `${errorType}:${rawCode}`; + if (seen.has(key)) continue; + seen.add(key); + + if (rawCode.startsWith('#') || /^\d+$/.test(rawCode)) { + const code = Number(rawCode.replace('#', '')); + if (errorType === 'Contract') { + out.push( + this.buildContractError( + code, + inferredContract, + null, + [], + contractHint, + ), + ); + continue; + } + } + out.push( + this.buildHostError( + errorType, + rawCode.replace(/^#/, ''), + inferredContract, + null, + [], + ), + ); + } + + return out; + } + + // ─── XDR results ────────────────────────────────────────────────────────── + + /** Extract result codes from an `xdr.TransactionResult` (or base64). */ + parseTransactionResult(input: unknown): { + resultCode: string | null; + operationResultCode: string | null; + } { + const empty = { resultCode: null, operationResultCode: null }; + const result = this.decode(input, (s) => + xdr.TransactionResult.fromXDR(s, 'base64'), + ); + if (!result) return empty; + + try { + const outer = result.result(); + const outerCode = outer.switch().name; + + // Fee-bump wrappers carry the real result one level down. + const inner: + | xdr.TransactionResultResult + | xdr.InnerTransactionResultResult = + outerCode === 'txFeeBumpInnerFailed' || + outerCode === 'txFeeBumpInnerSuccess' + ? outer.innerResultPair().result().result() + : outer; + + const opResults = this.safe( + () => inner.results(), + [], + ); + let operationResultCode: string | null = null; + for (const op of opResults) { + const code = this.safe(() => { + if (op.switch().name !== 'opInner') return op.switch().name; + const tr = op.tr(); + if (tr.switch().name === 'invokeHostFunction') { + return tr.invokeHostFunctionResult().switch().name; + } + return null; + }, null); + if (code && !/Success$/.test(code)) { + operationResultCode = code; + break; + } + operationResultCode ??= code; + } + + return { resultCode: inner.switch().name, operationResultCode }; + } catch { + return empty; + } + } + + // ─── Whole-transaction analysis ─────────────────────────────────────────── + + analyzeTransaction( + input: AnalyzeTransactionInput, + ): TransactionDiagnosticReport { + const txHash = input.txHash ?? null; + const contractHint = input.contractHint ?? null; + + let rawEvents: unknown[] = Array.isArray(input.diagnosticEvents) + ? input.diagnosticEvents + : []; + if (rawEvents.length === 0 && input.resultMetaXdr) { + rawEvents = this.diagnosticEventsFromMeta(input.resultMetaXdr); + } + + const errors: HostErrorDetail[] = []; + const callStack: string[] = []; + let malformedEventCount = 0; + let contractId = input.contractId ?? null; + + for (const raw of rawEvents) { + const decoded = this.parseDiagnosticEvent(raw, contractHint); + if (!decoded) { + malformedEventCount++; + continue; + } + contractId ??= decoded.contractId; + if (decoded.fnCall) callStack.push(decoded.fnCall); + if (decoded.error) errors.push(decoded.error); + } + + errors.push( + ...this.parseErrorString(input.errorMessage, contractId, contractHint), + ); + + const { resultCode, operationResultCode } = this.parseTransactionResult( + input.resultXdr, + ); + + const deduped = this.dedupe(errors); + const primaryError = this.pickPrimary(deduped); + const contractError = + deduped.find((e) => e.category === DiagnosticErrorCategory.CONTRACT) ?? + null; + + const authFailure = deduped.some( + (e) => + e.category === DiagnosticErrorCategory.AUTH || + (e.category === DiagnosticErrorCategory.CONTRACT && + /^(Unauthori[sz]ed|NotAuthori[sz]ed)/.test(e.errorName ?? '')), + ); + const footprintExhausted = deduped.some( + (e) => e.category === DiagnosticErrorCategory.FOOTPRINT, + ); + const budgetExhausted = deduped.some( + (e) => e.category === DiagnosticErrorCategory.BUDGET, + ); + const resourceLimitExceeded = + operationResultCode === 'invokeHostFunctionResourceLimitExceeded' || + footprintExhausted || + budgetExhausted; + const entryArchived = + operationResultCode === 'invokeHostFunctionEntryArchived'; + + const failed = + deduped.length > 0 || + (resultCode !== null && !/Success$/.test(resultCode)) || + (operationResultCode !== null && !/Success$/.test(operationResultCode)); + + const report: TransactionDiagnosticReport = { + txHash, + contractId, + failed, + resultCode, + operationResultCode, + errors: deduped, + primaryError, + contractError, + authFailure, + footprintExhausted, + budgetExhausted, + resourceLimitExceeded, + entryArchived, + callStack, + eventCount: rawEvents.length, + malformedEventCount, + summary: '', + }; + report.summary = this.summarize(report); + return report; + } + + /** Analyze a `getTransaction` response (any status). */ + analyzeGetTransactionResponse( + txHash: string, + response: unknown, + contractHint?: string | null, + ): TransactionDiagnosticReport { + const r = (response ?? {}) as Record; + return this.analyzeTransaction({ + txHash, + diagnosticEvents: r.diagnosticEventsXdr, + resultXdr: r.resultXdr, + resultMetaXdr: r.resultMetaXdr, + contractHint, + }); + } + + /** Analyze a `sendTransaction` response whose status is ERROR. */ + analyzeSendTransactionResponse( + response: unknown, + contractHint?: string | null, + ): TransactionDiagnosticReport { + const r = (response ?? {}) as Record; + return this.analyzeTransaction({ + txHash: typeof r.hash === 'string' ? r.hash : null, + diagnosticEvents: r.diagnosticEvents ?? r.diagnosticEventsXdr, + resultXdr: r.errorResult ?? r.errorResultXdr, + contractHint, + }); + } + + /** Analyze a failed `simulateTransaction` response. */ + analyzeSimulationResponse( + response: unknown, + contractHint?: string | null, + ): TransactionDiagnosticReport { + const r = (response ?? {}) as Record; + return this.analyzeTransaction({ + diagnosticEvents: r.events, + errorMessage: r.error, + contractHint, + }); + } + + /** + * Source account (G... / M...) of an `xdr.TransactionEnvelope` (or + * base64). For fee-bumps this is the inner transaction's source — the user + * who signed the invocation, not the fee sponsor. + */ + extractSourceAccount(envelopeInput: unknown): string | null { + const envelope = this.decode(envelopeInput, (s) => + xdr.TransactionEnvelope.fromXDR(s, 'base64'), + ); + if (!envelope) return null; + return this.safe(() => { + switch (envelope.switch().name) { + case 'envelopeTypeTx': + return encodeMuxedAccountToAddress( + envelope.v1().tx().sourceAccount(), + true, + ); + case 'envelopeTypeTxFeeBump': + return encodeMuxedAccountToAddress( + envelope.feeBump().tx().innerTx().v1().tx().sourceAccount(), + true, + ); + case 'envelopeTypeTxV0': + return StrKey.encodeEd25519PublicKey( + Buffer.from(envelope.v0().tx().sourceAccountEd25519()), + ); + default: + return null; + } + }, null); + } + + /** Structured log of a failed transaction's diagnostics for debugging. */ + logReport( + report: TransactionDiagnosticReport, + context: Record = {}, + ): void { + if (!report.failed) return; + this.logger.warn({ + msg: 'Soroban transaction failed', + ...context, + txHash: report.txHash, + contractId: report.contractId, + resultCode: report.resultCode, + operationResultCode: report.operationResultCode, + summary: report.summary, + primaryError: report.primaryError, + authFailure: report.authFailure, + footprintExhausted: report.footprintExhausted, + budgetExhausted: report.budgetExhausted, + resourceLimitExceeded: report.resourceLimitExceeded, + entryArchived: report.entryArchived, + callStack: report.callStack, + errorCount: report.errors.length, + eventCount: report.eventCount, + malformedEventCount: report.malformedEventCount, + }); + if (report.errors.length > 1) { + this.logger.debug({ + msg: 'Soroban transaction diagnostic errors', + txHash: report.txHash, + errors: report.errors, + }); + } + } + + // ─── Internals ──────────────────────────────────────────────────────────── + + private parseScError( + scError: xdr.ScError, + contractId: string | null, + hostMessage: string | null, + args: unknown[], + contractHint?: string | null, + ): HostErrorDetail { + const typeName = this.safe(() => scError.switch().name, 'sceUnknown'); + const errorType = typeName.replace(/^sce/, ''); + + if (errorType === 'Contract') { + const code = this.safe(() => Number(scError.contractCode()), NaN); + if (Number.isFinite(code)) { + return this.buildContractError( + code, + contractId, + hostMessage, + args, + contractHint, + ); + } + } + + const codeName = this.safe( + () => scError.code().name.replace(/^scec/, ''), + null, + ); + return this.buildHostError( + errorType, + codeName, + contractId, + hostMessage, + args, + ); + } + + private buildContractError( + code: number, + contractId: string | null, + hostMessage: string | null, + args: unknown[], + contractHint?: string | null, + ): HostErrorDetail { + const t = this.translateContractError(code, contractId, contractHint); + return { + category: DiagnosticErrorCategory.CONTRACT, + errorType: 'Contract', + code, + codeName: null, + contractId, + contract: t.contract, + enumName: t.enumName, + errorName: t.name, + message: t.message, + hostMessage, + args, + }; + } + + private buildHostError( + errorType: string, + codeName: string | null, + contractId: string | null, + hostMessage: string | null, + args: unknown[], + ): HostErrorDetail { + let category = + ERROR_TYPE_CATEGORY[errorType] ?? DiagnosticErrorCategory.UNKNOWN; + if ( + category === DiagnosticErrorCategory.STORAGE && + (codeName === 'ExceededLimit' || /footprint/i.test(hostMessage ?? '')) + ) { + category = DiagnosticErrorCategory.FOOTPRINT; + } + + return { + category, + errorType, + code: null, + codeName, + contractId, + contract: contractId + ? (this.contractKinds.get(contractId) ?? null) + : null, + enumName: null, + errorName: null, + message: this.hostErrorMessage(category, errorType, codeName), + hostMessage, + args, + }; + } + + private hostErrorMessage( + category: DiagnosticErrorCategory, + errorType: string, + codeName: string | null, + ): string { + switch (category) { + case DiagnosticErrorCategory.AUTH: + return 'Authorization failed: a required signature is missing or the signed authorization does not match this invocation.'; + case DiagnosticErrorCategory.FOOTPRINT: + return 'Footprint exhausted: the transaction touched a ledger entry outside its declared footprint or exceeded its storage limits. Re-simulate and resubmit.'; + case DiagnosticErrorCategory.BUDGET: + return codeName === 'ExceededLimit' + ? 'Resource budget exceeded: the transaction ran out of CPU instructions or memory.' + : `Resource budget error: ${HOST_CODE_MESSAGES[codeName ?? ''] ?? 'unknown budget failure'}.`; + case DiagnosticErrorCategory.STORAGE: + return codeName === 'MissingValue' + ? 'A required ledger entry was not found (it may be archived or was never created).' + : `Storage error: ${HOST_CODE_MESSAGES[codeName ?? ''] ?? 'unknown storage failure'}.`; + default: { + const detail = HOST_CODE_MESSAGES[codeName ?? '']; + return detail + ? `Host ${errorType} error: ${detail}.` + : `Host ${errorType} error${codeName ? ` (${codeName})` : ''}.`; + } + } + } + + /** + * Host error events carry `data` as either a message string or a vec of + * `[message, ...args]`. + */ + private extractHostMessage( + data: xdr.ScVal, + dataIsError: boolean, + ): { hostMessage: string | null; args: unknown[] } { + if (dataIsError) return { hostMessage: null, args: [] }; + const native = this.scValToJson(data); + if (typeof native === 'string') return { hostMessage: native, args: [] }; + if (Array.isArray(native) && typeof native[0] === 'string') { + return { hostMessage: native[0], args: native.slice(1) }; + } + return { + hostMessage: null, + args: native === null || native === undefined ? [] : [native], + }; + } + + private diagnosticEventsFromMeta(input: unknown): unknown[] { + const meta = this.decode(input, (s) => + xdr.TransactionMeta.fromXDR(s, 'base64'), + ); + if (!meta) return []; + // Only TransactionMeta v3 carries Soroban diagnostics in this SDK. + return this.safe( + () => + meta.switch() === 3 + ? (meta.v3().sorobanMeta()?.diagnosticEvents() ?? []) + : [], + [], + ); + } + + private toDiagnosticEvent(input: unknown): xdr.DiagnosticEvent | null { + const decoded = this.decode(input, (s) => + xdr.DiagnosticEvent.fromXDR(s, 'base64'), + ); + if (!decoded) return null; + // Duck-type check: a DiagnosticEvent must expose event().body(). + return typeof (decoded as { event?: unknown }).event === 'function' + ? decoded + : null; + } + + /** + * Normalise XDR input: passes XDR instances through, decodes base64 strings + * and raw buffers. Returns null for anything that fails to decode. + */ + private decode(input: unknown, fromBase64: (s: string) => T): T | null { + if (input === null || input === undefined) return null; + try { + if (typeof input === 'string') { + if (!input.trim()) return null; + return fromBase64(input.trim()); + } + if (input instanceof Uint8Array) { + return fromBase64(Buffer.from(input).toString('base64')); + } + if (typeof input === 'object') return input as T; + return null; + } catch (err) { + this.logger.debug({ + msg: 'Malformed XDR input', + error: (err as Error).message, + }); + return null; + } + } + + private isScvError(val: unknown): val is xdr.ScVal { + return this.safe( + () => (val as xdr.ScVal).switch() === xdr.ScValType.scvError(), + false, + ); + } + + /** scValToNative, made JSON-safe (bigint → string, bytes → hex). */ + private scValToJson(val: xdr.ScVal): unknown { + const native = this.safe(() => { + if (this.isScvError(val)) { + const e = val.error(); + const type = e.switch().name.replace(/^sce/, ''); + return type === 'Contract' + ? `Error(Contract, #${e.contractCode()})` + : `Error(${type}, ${e.code().name.replace(/^scec/, '')})`; + } + return scValToNative(val); + }, null); + return this.jsonSafe(native, 0); + } + + private jsonSafe(value: unknown, depth: number): unknown { + if (depth > 8) return '[depth-limit]'; + if (typeof value === 'bigint') return value.toString(); + if (value instanceof Uint8Array) return Buffer.from(value).toString('hex'); + if (Array.isArray(value)) + return value.map((v) => this.jsonSafe(v, depth + 1)); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, this.jsonSafe(v, depth + 1)]), + ); + } + return value; + } + + private dedupe(errors: HostErrorDetail[]): HostErrorDetail[] { + // Soroban re-emits the same error as it escalates up the call stack; + // keep the first (innermost) occurrence, preferring ones with a message. + const byKey = new Map(); + for (const e of errors) { + const key = `${e.errorType}:${e.code ?? e.codeName}:${e.contractId ?? ''}`; + const existing = byKey.get(key); + if (!existing) byKey.set(key, e); + else if (!existing.hostMessage && e.hostMessage) { + byKey.set(key, { + ...existing, + hostMessage: e.hostMessage, + args: e.args, + }); + } + } + return [...byKey.values()]; + } + + private pickPrimary(errors: HostErrorDetail[]): HostErrorDetail | null { + let best: HostErrorDetail | null = null; + for (const e of errors) { + if ( + !best || + CATEGORY_PRIORITY[e.category] < CATEGORY_PRIORITY[best.category] + ) { + best = e; + } + } + return best; + } + + private summarize(report: TransactionDiagnosticReport): string { + if (!report.failed) return 'Transaction succeeded.'; + const p = report.primaryError; + if (p?.category === DiagnosticErrorCategory.CONTRACT) { + const label = + p.enumName && p.errorName + ? `${p.enumName}::${p.errorName}` + : `#${p.code}`; + return `${p.message} [${label}]`; + } + if (p) return p.message; + if (report.entryArchived) { + return 'A ledger entry required by the transaction is archived and must be restored first.'; + } + if (report.resourceLimitExceeded) { + return 'The transaction exceeded its declared resource limits. Re-simulate and resubmit.'; + } + const code = report.operationResultCode ?? report.resultCode; + return `Transaction failed${code ? ` (${code})` : ''}.`; + } + + private safe(fn: () => T, fallback: T): T { + try { + const v = fn(); + return v === undefined ? fallback : v; + } catch { + return fallback; + } + } +} diff --git a/packages/backend/src/indexer/entities/failed-transaction.entity.ts b/packages/backend/src/indexer/entities/failed-transaction.entity.ts new file mode 100644 index 00000000..a9eeb1a7 --- /dev/null +++ b/packages/backend/src/indexer/entities/failed-transaction.entity.ts @@ -0,0 +1,68 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import type { TransactionDiagnosticReport } from '../diagnostic-parser.service'; + +/** + * A failed Soroban transaction plus its parsed diagnostics (BE-004). + * Indexed so users can be notified with a human-readable reason and + * operators can debug failures after the RPC's retention window expires. + */ +@Entity('failed_transactions') +export class FailedTransaction { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 64, unique: true }) + txHash: string; + + @Column({ type: 'varchar', length: 64, nullable: true }) + @Index() + contractId: string | null; + + /** Stellar account the failure should be reported to (tx source). */ + @Column({ type: 'varchar', length: 64, nullable: true }) + @Index() + userAddress: string | null; + + @Column({ type: 'bigint', nullable: true }) + ledger: number | null; + + @Column({ type: 'varchar', length: 64, nullable: true }) + resultCode: string | null; + + @Column({ type: 'varchar', length: 64, nullable: true }) + operationResultCode: string | null; + + /** DiagnosticErrorCategory of the primary error. */ + @Column({ type: 'varchar', length: 32, nullable: true }) + @Index() + errorCategory: string | null; + + /** e.g. `CallRegistryError::CallEnded`. */ + @Column({ type: 'varchar', length: 128, nullable: true }) + errorName: string | null; + + @Column({ type: 'integer', nullable: true }) + errorCode: number | null; + + @Column({ type: 'text' }) + message: string; + + @Column({ type: 'jsonb' }) + diagnostics: TransactionDiagnosticReport; + + @Column({ type: 'boolean', default: false }) + notified: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/packages/backend/src/indexer/event-log.entity.ts b/packages/backend/src/indexer/event-log.entity.ts index 4da3bd19..39360b0a 100644 --- a/packages/backend/src/indexer/event-log.entity.ts +++ b/packages/backend/src/indexer/event-log.entity.ts @@ -2,6 +2,7 @@ import { Entity, Column, PrimaryGeneratedColumn, + PrimaryColumn, CreateDateColumn, Index, } from 'typeorm'; @@ -18,32 +19,55 @@ export enum EventType { INITIALIZED = 'initialized', } +/** Ledgers per `event_logs` range partition (see PartitionEventStore migration). */ +export const EVENT_LOG_PARTITION_SIZE = 100_000; + +/** + * Raw contract event log, range-partitioned by ledger sequence into + * 100,000-ledger partitions (BE-003). + * + * The PartitionEventStore migration owns the partitioned layout. This entity + * mirrors it exactly (column types, (id, ledger) primary key, index names), so + * dev-mode `synchronize` is a no-op against the partitioned table, and on an + * empty database it creates a plain table the migration later converts. + * The primary key is (id, ledger) because Postgres requires the partition key + * in every unique constraint on a partitioned table. + */ @Entity('event_logs') +@Index('IDX_event_logs_contract_topic0_ledger', [ + 'contractId', + 'topic0', + 'ledger', +]) +@Index('IDX_event_logs_eventType_ledger', ['eventType', 'ledger']) export class EventLog { @PrimaryGeneratedColumn() id: number; @Column() - @Index() + @Index('IDX_event_logs_eventId') eventId: string; @Column() pagingToken: string; @Column({ type: 'varchar', length: 64 }) - @Index() contractId: string; + /** First event topic symbol as emitted by the contract, e.g. `AdminParamsChanged`. */ + @Column({ type: 'varchar', length: 64, nullable: true }) + topic0?: string | null; + @Column({ type: 'varchar', length: 50 }) - @Index() eventType: EventType; - @Column({ type: 'bigint' }) - @Index() + /** Ledger sequence — the partition key. */ + @PrimaryColumn({ type: 'bigint' }) + @Index('IDX_event_logs_ledger') ledger: number; @Column({ type: 'varchar', length: 64 }) - @Index() + @Index('IDX_event_logs_txHash') txHash: string; @Column({ type: 'integer' }) @@ -53,7 +77,7 @@ export class EventLog { eventData: any; @Column({ type: 'timestamp' }) - @Index() + @Index('IDX_event_logs_timestamp') timestamp: Date; @CreateDateColumn() diff --git a/packages/backend/src/indexer/indexer.module.ts b/packages/backend/src/indexer/indexer.module.ts index ea55c6f6..726ec0a0 100644 --- a/packages/backend/src/indexer/indexer.module.ts +++ b/packages/backend/src/indexer/indexer.module.ts @@ -12,10 +12,12 @@ import { PlatformConfigModule } from '../config/config.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { PayoutsModule } from '../payouts/payouts.module'; import { TreasuryModule } from '../treasury/treasury.module'; +import { FailedTransaction } from './entities/failed-transaction.entity'; +import { DiagnosticParserService } from './diagnostic-parser.service'; @Module({ imports: [ - TypeOrmModule.forFeature([EventLog, PlatformSettings]), + TypeOrmModule.forFeature([EventLog, PlatformSettings, FailedTransaction]), ScheduleModule.forRoot(), PlatformConfigModule, NotificationsModule, @@ -27,6 +29,7 @@ import { TreasuryModule } from '../treasury/treasury.module'; IndexerService, EventParser, PlatformSettingsService, + DiagnosticParserService, { provide: SorobanRpc.Server, useFactory: () => { @@ -36,6 +39,6 @@ import { TreasuryModule } from '../treasury/treasury.module'; }, }, ], - exports: [IndexerService], + exports: [IndexerService, DiagnosticParserService], }) export class IndexerModule {} diff --git a/packages/backend/src/indexer/indexer.service.spec.ts b/packages/backend/src/indexer/indexer.service.spec.ts index 3a5a128e..f500365d 100644 --- a/packages/backend/src/indexer/indexer.service.spec.ts +++ b/packages/backend/src/indexer/indexer.service.spec.ts @@ -1,12 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { SorobanRpc, xdr } from '@stellar/stellar-sdk'; +import { Keypair, SorobanRpc, StrKey, xdr } from '@stellar/stellar-sdk'; import { IndexerService } from './indexer.service'; import { EventLog } from './event-log.entity'; import { PlatformSettings } from './entities/platform-settings.entity'; import { ConfigService } from '../config/config.service'; import { PayoutsService } from '../payouts/payouts.service'; import { TreasuryService } from '../treasury/treasury.service'; +import { FailedTransaction } from './entities/failed-transaction.entity'; +import { DiagnosticParserService } from './diagnostic-parser.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../notifications/notification-type.enum'; jest.mock('./parsers/admin-params.parser', () => ({ parseAdminParamsChanged: jest.fn(), @@ -27,6 +31,7 @@ describe('IndexerService', () => { getLatestLedger: jest.fn(), getLedgerEntries: jest.fn(), sendTransaction: jest.fn(), + getTransaction: jest.fn(), }; const eventLogRepo = { findOne: jest.fn(), @@ -49,6 +54,15 @@ describe('IndexerService', () => { const treasuryService = { recordFeeFromPayoutClaimed: jest.fn(), }; + const failedTxRepo = { + findOne: jest.fn(), + create: jest.fn((v) => v), + save: jest.fn(async (v) => v), + }; + const notificationsService = { + notify: jest.fn(), + }; + const diagnosticParser = new DiagnosticParserService(); beforeEach(async () => { jest.clearAllMocks(); @@ -66,6 +80,12 @@ describe('IndexerService', () => { { provide: ConfigService, useValue: configService }, { provide: PayoutsService, useValue: payoutsService }, { provide: TreasuryService, useValue: treasuryService }, + { + provide: getRepositoryToken(FailedTransaction), + useValue: failedTxRepo, + }, + { provide: DiagnosticParserService, useValue: diagnosticParser }, + { provide: NotificationsService, useValue: notificationsService }, ], }).compile(); @@ -329,6 +349,269 @@ describe('IndexerService', () => { ); }); + // ─── Latest event lookup on the partitioned table (BE-003) ────────────── + + describe('latest event lookup', () => { + afterEach(() => { + delete (eventLogRepo as any).query; + }); + + it('uses event_logs_max_ledger() then a point lookup when available', async () => { + (eventLogRepo as any).query = jest + .fn() + .mockResolvedValue([{ ledger: '4200' }]); + eventLogRepo.findOne.mockResolvedValueOnce({ id: 9, ledger: '4200' }); + rpcServer.getEvents.mockResolvedValue({ events: [] }); + + await service.processNewEvents(); + + expect(eventLogRepo.findOne).toHaveBeenCalledWith({ + where: { ledger: 4200 }, + order: { id: 'DESC' }, + }); + // bigint strings from pg must be treated numerically, not concatenated. + expect(rpcServer.getEvents).toHaveBeenCalledWith( + expect.objectContaining({ startLedger: 4201 }), + ); + }); + + it('returns null status when the helper reports an empty table', async () => { + (eventLogRepo as any).query = jest + .fn() + .mockResolvedValue([{ ledger: null }]); + eventLogRepo.count.mockResolvedValue(0); + + const status = await service.getStatus(); + expect(status.latestEventLedger).toBeNull(); + expect(eventLogRepo.findOne).not.toHaveBeenCalled(); + }); + + it('falls back to ORDER BY ledger once when the helper is missing', async () => { + (eventLogRepo as any).query = jest + .fn() + .mockRejectedValue( + new Error('function event_logs_max_ledger() does not exist'), + ); + eventLogRepo.findOne.mockResolvedValue({ ledger: 7 }); + eventLogRepo.count.mockResolvedValue(1); + + await service.getStatus(); + await service.getStatus(); + + expect((eventLogRepo as any).query).toHaveBeenCalledTimes(1); + expect(eventLogRepo.findOne).toHaveBeenCalledWith({ + where: {}, + order: { ledger: 'DESC' }, + }); + }); + }); + + // ─── Failed transaction indexing (BE-004) ──────────────────────────────── + + describe('failed transactions', () => { + const CONTRACT = StrKey.encodeContract(Buffer.alloc(32, 7)); + const USER = Keypair.random().publicKey(); + + const errorEvent = (code: number) => + new xdr.DiagnosticEvent({ + inSuccessfulContractCall: false, + event: new xdr.ContractEvent({ + ext: new (xdr.ExtensionPoint as any)(0), + contractId: StrKey.decodeContract(CONTRACT) as any, + type: xdr.ContractEventType.diagnostic(), + body: new (xdr.ContractEventBody as any)( + 0, + new xdr.ContractEventV0({ + topics: [ + xdr.ScVal.scvSymbol('error'), + xdr.ScVal.scvError(xdr.ScError.sceContract(code)), + ], + data: xdr.ScVal.scvString('failed'), + }), + ), + }), + }); + + beforeAll(() => { + diagnosticParser.registerContract(CONTRACT, 'call_registry'); + jest + .spyOn((diagnosticParser as any).logger, 'warn') + .mockImplementation(() => {}); + }); + + beforeEach(() => { + failedTxRepo.findOne.mockResolvedValue(null); + }); + + it('indexes and notifies when sendTransaction returns ERROR', async () => { + rpcServer.sendTransaction.mockResolvedValueOnce({ + status: 'ERROR', + hash: 'hash-err', + latestLedger: 555, + diagnosticEvents: [errorEvent(6)], + }); + + const res = await service.submitTransaction({ source: USER } as any); + + expect(res.status).toBe('ERROR'); + expect(failedTxRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + txHash: 'hash-err', + contractId: CONTRACT, + userAddress: USER, + ledger: 555, + errorCategory: 'contract', + errorName: 'CallRegistryError::CallEnded', + errorCode: 6, + }), + ); + expect(notificationsService.notify).toHaveBeenCalledWith( + USER, + NotificationType.TRANSACTION_FAILED, + expect.stringContaining('staking is no longer allowed'), + 'hash-err', + ); + expect(failedTxRepo.save).toHaveBeenLastCalledWith( + expect.objectContaining({ notified: true }), + ); + }); + + it('uses the inner transaction source for fee-bumps', async () => { + rpcServer.sendTransaction.mockResolvedValueOnce({ + status: 'ERROR', + hash: 'hash-fb', + diagnosticEvents: [errorEvent(9)], + }); + await service.submitTransaction({ + source: 'GSPONSOR', + innerTransaction: { source: USER }, + } as any); + expect(failedTxRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ userAddress: USER, ledger: null }), + ); + }); + + it('does not index successful submissions', async () => { + rpcServer.sendTransaction.mockResolvedValueOnce({ status: 'PENDING' }); + await service.submitTransaction({} as any); + expect(failedTxRepo.save).not.toHaveBeenCalled(); + }); + + it('does not re-notify for an already notified transaction', async () => { + failedTxRepo.findOne.mockResolvedValueOnce({ + id: 1, + txHash: 'hash-dup', + userAddress: USER, + notified: true, + }); + rpcServer.sendTransaction.mockResolvedValueOnce({ + status: 'ERROR', + hash: 'hash-dup', + diagnosticEvents: [errorEvent(6)], + }); + await service.submitTransaction({ source: USER } as any); + expect(failedTxRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ id: 1, notified: true }), + ); + expect(notificationsService.notify).not.toHaveBeenCalled(); + }); + + it('persists without notifying when the user is unknown', async () => { + rpcServer.sendTransaction.mockResolvedValueOnce({ + status: 'ERROR', + hash: 'hash-anon', + diagnosticEvents: [errorEvent(6)], + }); + await service.submitTransaction({} as any); + expect(failedTxRepo.save).toHaveBeenCalledTimes(1); + expect(notificationsService.notify).not.toHaveBeenCalled(); + }); + + it('truncates long notification messages to 255 chars', async () => { + jest + .spyOn(diagnosticParser, 'analyzeSendTransactionResponse') + .mockReturnValueOnce({ + ...diagnosticParser.analyzeTransaction({ + txHash: 'hash-long', + diagnosticEvents: [errorEvent(6)], + }), + summary: 'x'.repeat(400), + }); + rpcServer.sendTransaction.mockResolvedValueOnce({ + status: 'ERROR', + hash: 'hash-long', + }); + await service.submitTransaction({ source: USER } as any); + const message = notificationsService.notify.mock.calls[0][2] as string; + expect(message.length).toBe(255); + expect(message.endsWith('…')).toBe(true); + }); + + it('swallows persistence errors so submission still returns', async () => { + failedTxRepo.findOne.mockRejectedValueOnce(new Error('db down')); + rpcServer.sendTransaction.mockResolvedValueOnce({ + status: 'ERROR', + hash: 'hash-db', + diagnosticEvents: [errorEvent(6)], + }); + await expect( + service.submitTransaction({ source: USER } as any), + ).resolves.toMatchObject({ status: 'ERROR' }); + }); + + it('indexFailedTransaction parses a FAILED getTransaction response', async () => { + rpcServer.getTransaction.mockResolvedValueOnce({ + status: SorobanRpc.Api.GetTransactionStatus.FAILED, + ledger: 777, + diagnosticEventsXdr: [errorEvent(15)], + envelopeXdr: undefined, + }); + + const report = await service.indexFailedTransaction('hash-get', USER); + + expect(report!.contractError!.errorName).toBe('StakingCutoffActive'); + expect(failedTxRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + txHash: 'hash-get', + ledger: 777, + userAddress: USER, + }), + ); + }); + + it('indexFailedTransaction derives the user from the envelope', async () => { + const spy = jest + .spyOn(diagnosticParser, 'extractSourceAccount') + .mockReturnValueOnce(USER); + rpcServer.getTransaction.mockResolvedValueOnce({ + status: SorobanRpc.Api.GetTransactionStatus.FAILED, + diagnosticEventsXdr: [errorEvent(6)], + envelopeXdr: 'ENV', + }); + await service.indexFailedTransaction('hash-env'); + expect(spy).toHaveBeenCalledWith('ENV'); + expect(notificationsService.notify).toHaveBeenCalledWith( + USER, + NotificationType.TRANSACTION_FAILED, + expect.any(String), + 'hash-env', + ); + }); + + it('indexFailedTransaction ignores non-failed transactions', async () => { + rpcServer.getTransaction.mockResolvedValueOnce({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + }); + await expect(service.indexFailedTransaction('ok')).resolves.toBeNull(); + expect(failedTxRepo.save).not.toHaveBeenCalled(); + }); + + it('indexFailedTransaction never throws on RPC errors', async () => { + rpcServer.getTransaction.mockRejectedValueOnce(new Error('rpc down')); + await expect(service.indexFailedTransaction('boom')).resolves.toBeNull(); + }); + }); + it('catches payout handler errors and does not throw', async () => { payoutsService.markClaimed.mockRejectedValueOnce(new Error('nope')); await expect( diff --git a/packages/backend/src/indexer/indexer.service.ts b/packages/backend/src/indexer/indexer.service.ts index 3c035c84..162a7a8f 100644 --- a/packages/backend/src/indexer/indexer.service.ts +++ b/packages/backend/src/indexer/indexer.service.ts @@ -10,11 +10,23 @@ import { ConfigService } from '../config/config.service'; import { parseAdminParamsChanged } from './parsers/admin-params.parser'; import { PayoutsService } from '../payouts/payouts.service'; import { TreasuryService } from '../treasury/treasury.service'; +import { FailedTransaction } from './entities/failed-transaction.entity'; +import { + DiagnosticParserService, + TransactionDiagnosticReport, +} from './diagnostic-parser.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationType } from '../notifications/notification-type.enum'; + +/** notifications.message is varchar(255). */ +const MAX_NOTIFICATION_LENGTH = 255; @Injectable() export class IndexerService { private readonly logger = new Logger(IndexerService.name); private readonly contractId = process.env.SOROBAN_CONTRACT_ID ?? ''; + /** Whether event_logs_max_ledger() (PartitionEventStore migration) exists. */ + private maxLedgerHelperAvailable = true; constructor( private readonly rpcServer: SorobanRpc.Server, @@ -25,6 +37,10 @@ export class IndexerService { private readonly configService: ConfigService, private readonly payoutsService: PayoutsService, private readonly treasuryService: TreasuryService, + @InjectRepository(FailedTransaction) + private readonly failedTxRepository: Repository, + private readonly diagnosticParser: DiagnosticParserService, + private readonly notificationsService: NotificationsService, ) {} // ─── Status ─────────────────────────────────────────────────────────────── @@ -32,10 +48,7 @@ export class IndexerService { async getStatus() { const isRunning = true; const totalEventsIndexed = await this.eventLogRepository.count(); - const latestEvent = await this.eventLogRepository.findOne({ - where: {}, - order: { ledger: 'DESC' }, - }); + const latestEvent = await this.findLatestEvent(); return { isRunning, @@ -141,6 +154,7 @@ export class IndexerService { eventId: `${txHash}-admin-params`, pagingToken: `${ledger}-${txHash}`, contractId: this.contractId, + topic0: 'AdminParamsChanged', eventType: EventType.ADMIN_PARAMS_CHANGED, ledger, txHash, @@ -294,27 +308,176 @@ export class IndexerService { async submitTransaction( tx: Parameters[0], ): Promise { - return retryWithBackoff( + const response = await retryWithBackoff( () => this.rpcServer.sendTransaction(tx), 4, 1000, 'submitTransaction', ); + + if (response?.status === 'ERROR') { + const report = + this.diagnosticParser.analyzeSendTransactionResponse(response); + const source = tx as { + source?: string; + innerTransaction?: { source?: string }; + }; + await this.recordFailedTransaction(report, { + userAddress: source.innerTransaction?.source ?? source.source ?? null, + ledger: response.latestLedger ?? null, + }); + } + + return response; + } + + // ─── Failed Transactions (BE-004) ───────────────────────────────────────── + + /** + * Fetch a transaction by hash and, if it failed on-chain, parse its + * diagnostics, persist them to `failed_transactions` and notify the + * submitting user. Returns the report, or null if the transaction did not + * fail (or could not be fetched). Never throws. + */ + async indexFailedTransaction( + txHash: string, + userAddress?: string | null, + ): Promise { + try { + const response = await retryWithBackoff( + () => this.rpcServer.getTransaction(txHash), + 4, + 1000, + `getTransaction(${txHash})`, + ); + if (response.status !== SorobanRpc.Api.GetTransactionStatus.FAILED) { + return null; + } + + const report = this.diagnosticParser.analyzeGetTransactionResponse( + txHash, + response, + ); + await this.recordFailedTransaction(report, { + userAddress: + userAddress ?? + this.diagnosticParser.extractSourceAccount(response.envelopeXdr), + ledger: response.ledger ?? null, + }); + return report; + } catch (err) { + this.logger.warn( + `indexFailedTransaction(${txHash}) failed: ${(err as Error).message}`, + ); + return null; + } + } + + private async recordFailedTransaction( + report: TransactionDiagnosticReport, + ctx: { userAddress: string | null; ledger: number | null }, + ): Promise { + this.diagnosticParser.logReport(report, { userAddress: ctx.userAddress }); + if (!report.failed || !report.txHash) return; + + try { + const primary = report.primaryError; + const existing = await this.failedTxRepository.findOne({ + where: { txHash: report.txHash }, + }); + + const row = await this.failedTxRepository.save( + this.failedTxRepository.create({ + ...(existing ?? {}), + txHash: report.txHash, + contractId: report.contractId ?? existing?.contractId ?? null, + userAddress: ctx.userAddress ?? existing?.userAddress ?? null, + ledger: ctx.ledger ?? existing?.ledger ?? null, + resultCode: report.resultCode, + operationResultCode: report.operationResultCode, + errorCategory: primary?.category ?? null, + errorName: + primary?.enumName && primary.errorName + ? `${primary.enumName}::${primary.errorName}` + : (primary?.codeName ?? null), + errorCode: primary?.code ?? null, + message: report.summary, + diagnostics: report, + notified: existing?.notified ?? false, + }), + ); + + if (row.userAddress && !row.notified) { + await this.notificationsService.notify( + row.userAddress, + NotificationType.TRANSACTION_FAILED, + this.truncate(`Transaction failed: ${report.summary}`), + report.txHash, + ); + row.notified = true; + await this.failedTxRepository.save(row); + } + } catch (err) { + // Diagnostics are best-effort: never let them break the caller. + this.logger.warn({ + msg: 'Failed to persist failed transaction diagnostics', + txHash: report.txHash, + error: (err as Error).message, + }); + } + } + + private truncate(message: string): string { + return message.length <= MAX_NOTIFICATION_LENGTH + ? message + : `${message.slice(0, MAX_NOTIFICATION_LENGTH - 1)}…`; } // ─── Private Helpers ────────────────────────────────────────────────────── private async resolveStartLedger(): Promise { - const latestEvent = await this.eventLogRepository.findOne({ - where: {}, - order: { ledger: 'DESC' }, - }); + const latestEvent = await this.findLatestEvent(); if (latestEvent?.ledger) { - return latestEvent.ledger + 1; + // bigint columns come back from pg as strings. + return Number(latestEvent.ledger) + 1; } const latest = await this.getLatestLedger(); return Math.max(latest.sequence - 5, 1); } + + /** + * Latest indexed event. On the partitioned table a bare + * `ORDER BY ledger DESC LIMIT 1` plans a Merge Append over every + * partition, so resolve the max ledger via event_logs_max_ledger() (probes + * newest partitions first) and then do a single-partition point lookup. + * Falls back to the plain query where the helper is absent (unmigrated + * databases, tests). + */ + private async findLatestEvent(): Promise { + if (this.maxLedgerHelperAvailable) { + try { + const rows = await this.eventLogRepository.query< + Array<{ ledger: string | number | null }> + >('SELECT event_logs_max_ledger() AS ledger'); + const ledger = rows?.[0]?.ledger; + if (ledger === null || ledger === undefined) return null; + return await this.eventLogRepository.findOne({ + where: { ledger: Number(ledger) }, + order: { id: 'DESC' }, + }); + } catch { + this.maxLedgerHelperAvailable = false; + this.logger.debug( + 'event_logs_max_ledger() unavailable — using unpartitioned latest-event query', + ); + } + } + + return this.eventLogRepository.findOne({ + where: {}, + order: { ledger: 'DESC' }, + }); + } } diff --git a/packages/backend/src/notifications/notification-type.enum.ts b/packages/backend/src/notifications/notification-type.enum.ts index cc87f410..811e21af 100644 --- a/packages/backend/src/notifications/notification-type.enum.ts +++ b/packages/backend/src/notifications/notification-type.enum.ts @@ -8,4 +8,6 @@ export enum NotificationType { /** A call/market the user staked on is about to close (#375). */ CALL_CLOSING = 'CALL_CLOSING', PRICE_ALERT_TRIGGERED = 'PRICE_ALERT_TRIGGERED', + /** A Soroban transaction the user submitted failed on-chain (BE-004). */ + TRANSACTION_FAILED = 'TRANSACTION_FAILED', } diff --git a/packages/backend/src/reconciliation/reconciliation.service.ts b/packages/backend/src/reconciliation/reconciliation.service.ts index 8322dab0..e4a37de8 100644 --- a/packages/backend/src/reconciliation/reconciliation.service.ts +++ b/packages/backend/src/reconciliation/reconciliation.service.ts @@ -7,7 +7,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, EntityManager } from 'typeorm'; +import { Between, Repository, DataSource, EntityManager } from 'typeorm'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InjectQueue } from '@nestjs/bullmq'; @@ -651,16 +651,14 @@ export class ReconciliationService { // Query event logs stored locally in eventLogRepo or Soroban RPC for (const contractId of contractIds) { try { - // First check locally indexed EventLog for the ledger range - const localLogs = await this.eventLogRepo.find({ - where: { contractId }, + // First check locally indexed EventLog for the ledger range. The + // range is pushed into SQL so Postgres prunes event_logs partitions + // instead of loading the contract's whole history into memory. + const filtered = await this.eventLogRepo.find({ + where: { contractId, ledger: Between(fromLedger, toLedger) }, order: { ledger: 'ASC', txOrder: 'ASC' }, }); - const filtered = localLogs.filter( - (l) => l.ledger >= fromLedger && l.ledger <= toLedger, - ); - if (filtered.length > 0) { filtered.forEach((log, index) => { parsedEvents.push({