From beb3bea514031d520684096a13a1707474c818ca Mon Sep 17 00:00:00 2001 From: "Ikenga Ifeanyi .M." Date: Sat, 27 Jun 2026 12:48:57 +0100 Subject: [PATCH 1/3] Deploy SDK docs to gh-pages --- .github/workflows/docs.yml | 54 ++++++++++---------------------------- README.md | 2 ++ sdk/typedoc.json | 27 ++++++++++--------- 3 files changed, 30 insertions(+), 53 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 693e7f16..23f93648 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -4,31 +4,15 @@ on: push: branches: - master - - main - paths: - - "sdk/src/**" - - "sdk/typedoc.json" - - "sdk/package.json" - - "sdk/tsconfig.json" - - ".github/workflows/docs.yml" - - # Allow manual trigger from the Actions tab - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -# Only one deployment at a time; don't cancel in-progress runs -concurrency: - group: pages - cancel-in-progress: false + tags: + - "*" jobs: - build: - name: Build TypeDoc + deploy: + name: Build and deploy TypeDoc runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout @@ -38,7 +22,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: "20" - cache: "npm" + cache: npm cache-dependency-path: sdk/package-lock.json - name: Install SDK dependencies @@ -49,21 +33,11 @@ jobs: working-directory: sdk run: npm run docs - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + - name: Deploy to gh-pages + uses: peaceiris/actions-gh-pages@v4 with: - path: sdk/docs - - deploy: - name: Deploy to GitHub Pages - needs: build - runs-on: ubuntu-latest - - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: sdk/docs + enable_jekyll: false + force_orphan: true diff --git a/README.md b/README.md index 62bd1ceb..0039e743 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Tikka — Decentralized Raffle Platform on Stellar +[![Deploy SDK Docs](https://github.com/crackedstudio/tikka/actions/workflows/docs.yml/badge.svg)](https://github.com/crackedstudio/tikka/actions/workflows/docs.yml) + This repository is the **Tikka ecosystem**: frontend, SDK, backend, indexer, and oracle. Soroban smart contracts (Rust) live in a **separate repo/folder** and are not included here. ## Packages diff --git a/sdk/typedoc.json b/sdk/typedoc.json index 339c1d41..df1f9edd 100644 --- a/sdk/typedoc.json +++ b/sdk/typedoc.json @@ -1,28 +1,29 @@ { "plugin": ["typedoc-plugin-markdown"], - "entryPoints": [ - "src/index.ts", - "src/index.read.ts", - "src/index.write.ts" - ], + "entryPoints": ["src/index.ts", "src/index.read.ts", "src/index.write.ts"], "entryPointStrategy": "resolve", - "out": "docs", + "out": "./docs", "name": "Tikka SDK", "readme": "README.md", "includeVersion": true, "categorizeByGroup": true, - "groupOrder": ["Raffle", "Ticket", "User", "Admin", "Wallet", "Network", "Fee", "Utils", "*"], + "groupOrder": [ + "Raffle", + "Ticket", + "User", + "Admin", + "Wallet", + "Network", + "Fee", + "Utils", + "*" + ], "excludeProtected": true, "excludeInternal": true, "navigationLinks": { "GitHub": "https://github.com/crackedstudio/tikka" }, - "exclude": [ - "**/*.spec.ts", - "**/test/**", - "**/node_modules/**", - "**/dist/**" - ], + "exclude": ["**/*.spec.ts", "**/test/**", "**/node_modules/**", "**/dist/**"], "excludePrivate": true, "excludeExternals": true, "skipErrorChecking": true, From 94f54f3799310dc3b1da7f419264f60b9a9cd18f Mon Sep 17 00:00:00 2001 From: "Ikenga Ifeanyi .M." Date: Sat, 27 Jun 2026 13:04:59 +0100 Subject: [PATCH 2/3] Validate cursor integrity at startup --- indexer/src/health/health.controller.spec.ts | 91 +++--- indexer/src/health/health.service.spec.ts | 294 ++++++++++-------- indexer/src/health/health.service.ts | 150 ++++----- indexer/src/ingestor/cursor-integrity.ts | 98 ++++-- .../src/ingestor/cursor-manager.service.ts | 219 ++++++------- indexer/src/main.ts | 32 +- ...artup-cursor-integrity.integration.spec.ts | 66 ++++ 7 files changed, 545 insertions(+), 405 deletions(-) create mode 100644 indexer/src/test/integration/startup-cursor-integrity.integration.spec.ts diff --git a/indexer/src/health/health.controller.spec.ts b/indexer/src/health/health.controller.spec.ts index f5482413..30bfe336 100644 --- a/indexer/src/health/health.controller.spec.ts +++ b/indexer/src/health/health.controller.spec.ts @@ -1,9 +1,9 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ServiceUnavailableException } from '@nestjs/common'; -import { HealthController } from './health.controller'; -import { HealthService, HealthResult } from './health.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { ServiceUnavailableException } from "@nestjs/common"; +import { HealthController } from "./health.controller"; +import { HealthService, HealthResult } from "./health.service"; -describe('HealthController', () => { +describe("HealthController", () => { let controller: HealthController; let healthService: { getHealth: jest.Mock }; @@ -18,21 +18,22 @@ describe('HealthController', () => { controller = module.get(HealthController); }); - it('should be defined', () => { + it("should be defined", () => { expect(controller).toBeDefined(); }); - it('should return the health result when status is ok (HTTP 200)', async () => { + it("should return the health result when status is ok (HTTP 200)", async () => { const okResult: HealthResult = { - status: 'ok', + status: "ok", lag_ledgers: 5, - lagStatus: 'healthy', - db: 'ok', - redis: 'ok', + lagStatus: "healthy", + db: "ok", + redis: "ok", redis_latency_ms: 0, - cursor: 'ok', + cursor: "ok", + cursor_integrity: "ok", dlq_size: 0, - dlqPressure: 'ok', + dlqPressure: "ok", }; healthService.getHealth.mockResolvedValue(okResult); @@ -40,17 +41,18 @@ describe('HealthController', () => { expect(result).toEqual(okResult); }); - it('should throw ServiceUnavailableException when status is degraded (HTTP 503)', async () => { + it("should throw ServiceUnavailableException when status is degraded (HTTP 503)", async () => { const degradedResult: HealthResult = { - status: 'degraded', + status: "degraded", lag_ledgers: 250, - lagStatus: 'critical', - db: 'ok', - redis: 'ok', + lagStatus: "critical", + db: "ok", + redis: "ok", redis_latency_ms: 0, - cursor: 'ok', + cursor: "ok", + cursor_integrity: "ok", dlq_size: 0, - dlqPressure: 'ok', + dlqPressure: "ok", }; healthService.getHealth.mockResolvedValue(degradedResult); @@ -59,17 +61,18 @@ describe('HealthController', () => { ); }); - it('should embed the HealthResult body inside the ServiceUnavailableException', async () => { + it("should embed the HealthResult body inside the ServiceUnavailableException", async () => { const degradedResult: HealthResult = { - status: 'degraded', + status: "degraded", lag_ledgers: 150, - lagStatus: 'degraded', - db: 'error', - redis: 'ok', + lagStatus: "degraded", + db: "error", + redis: "ok", redis_latency_ms: 0, - cursor: 'ok', + cursor: "ok", + cursor_integrity: "ok", dlq_size: 0, - dlqPressure: 'ok', + dlqPressure: "ok", }; healthService.getHealth.mockResolvedValue(degradedResult); @@ -84,17 +87,18 @@ describe('HealthController', () => { expect(thrown!.getResponse()).toMatchObject(degradedResult); }); - it('should call healthService.getHealth exactly once per request', async () => { + it("should call healthService.getHealth exactly once per request", async () => { const okResult: HealthResult = { - status: 'ok', + status: "ok", lag_ledgers: 0, - lagStatus: 'healthy', - db: 'ok', - redis: 'ok', + lagStatus: "healthy", + db: "ok", + redis: "ok", redis_latency_ms: 0, - cursor: 'ok', + cursor: "ok", + cursor_integrity: "ok", dlq_size: 0, - dlqPressure: 'ok', + dlqPressure: "ok", }; healthService.getHealth.mockResolvedValue(okResult); @@ -102,17 +106,18 @@ describe('HealthController', () => { expect(healthService.getHealth).toHaveBeenCalledTimes(1); }); - it('should include lagStatus field in health response', async () => { + it("should include lagStatus field in health response", async () => { const criticalResult: HealthResult = { - status: 'degraded', + status: "degraded", lag_ledgers: 75, - lagStatus: 'critical', - db: 'ok', - redis: 'ok', + lagStatus: "critical", + db: "ok", + redis: "ok", redis_latency_ms: 10, - cursor: 'ok', + cursor: "ok", + cursor_integrity: "ok", dlq_size: 0, - dlqPressure: 'ok', + dlqPressure: "ok", }; healthService.getHealth.mockResolvedValue(criticalResult); @@ -120,8 +125,8 @@ describe('HealthController', () => { ServiceUnavailableException, ); - const thrown = await controller.getHealth().catch(e => e); + const thrown = await controller.getHealth().catch((e) => e); expect(thrown.getResponse()).toMatchObject(criticalResult); - expect(criticalResult.lagStatus).toBe('critical'); + expect(criticalResult.lagStatus).toBe("critical"); }); }); diff --git a/indexer/src/health/health.service.spec.ts b/indexer/src/health/health.service.spec.ts index 52f71d7f..7d7b4ef4 100644 --- a/indexer/src/health/health.service.spec.ts +++ b/indexer/src/health/health.service.spec.ts @@ -1,25 +1,31 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { DataSource } from 'typeorm'; -import { HealthService, LAG_THRESHOLD_DEFAULT, DLQ_PRESSURE_THRESHOLD_DEFAULT } from './health.service'; -import { CacheService } from '../cache/cache.service'; -import { CursorManagerService } from '../ingestor/cursor-manager.service'; -import { DlqService } from '../ingestor/dlq.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; +import { + HealthService, + LAG_THRESHOLD_DEFAULT, + DLQ_PRESSURE_THRESHOLD_DEFAULT, +} from "./health.service"; +import { CacheService } from "../cache/cache.service"; +import { CursorManagerService } from "../ingestor/cursor-manager.service"; +import { DlqService } from "../ingestor/dlq.service"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function makeMocks(overrides: { - dbQueryOk?: boolean; - dbInitialized?: boolean; - redisPingOk?: boolean; - cursorLastLedger?: number | null; - horizonLatestLedger?: number | null; - lagThreshold?: number; - dlqSize?: number; - dlqPressureThreshold?: number; -} = {}) { +function makeMocks( + overrides: { + dbQueryOk?: boolean; + dbInitialized?: boolean; + redisPingOk?: boolean; + cursorLastLedger?: number | null; + horizonLatestLedger?: number | null; + lagThreshold?: number; + dlqSize?: number; + dlqPressureThreshold?: number; + } = {}, +) { const { dbQueryOk = true, dbInitialized = true, @@ -34,8 +40,8 @@ function makeMocks(overrides: { const mockDataSource: Partial = { isInitialized: dbInitialized, query: dbQueryOk - ? jest.fn().mockResolvedValue([{ '?column?': 1 }]) - : jest.fn().mockRejectedValue(new Error('DB error')), + ? jest.fn().mockResolvedValue([{ "?column?": 1 }]) + : jest.fn().mockRejectedValue(new Error("DB error")), }; const mockCacheService = { @@ -43,9 +49,12 @@ function makeMocks(overrides: { }; const mockCursorManager = { - getCursor: jest.fn().mockResolvedValue( - cursorLastLedger != null ? { lastLedger: cursorLastLedger } : null, - ), + getCursor: jest + .fn() + .mockResolvedValue( + cursorLastLedger != null ? { lastLedger: cursorLastLedger } : null, + ), + getStatus: jest.fn().mockReturnValue({ startupIntegrityPassed: true }), }; const mockDlqService = { @@ -54,38 +63,36 @@ function makeMocks(overrides: { const mockConfigService = { get: jest.fn((key: string, defaultValue?: unknown) => { - if (key === 'HORIZON_URL') return 'https://horizon.stellar.org'; - if (key === 'LAG_THRESHOLD') return lagThreshold; - if (key === 'DLQ_PRESSURE_THRESHOLD') return dlqPressureThreshold; + if (key === "HORIZON_URL") return "https://horizon.stellar.org"; + if (key === "LAG_THRESHOLD") return lagThreshold; + if (key === "DLQ_PRESSURE_THRESHOLD") return dlqPressureThreshold; return defaultValue; }), }; // Stub global fetch — return a Horizon-shaped response - const fetchSpy = jest - .spyOn(global, 'fetch') - .mockImplementation(() => { - if (horizonLatestLedger == null) { - return Promise.reject(new Error('Network error')); - } - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - _embedded: { - records: [{ sequence: String(horizonLatestLedger) }], - }, - }), - } as Response); - }); + const fetchSpy = jest.spyOn(global, "fetch").mockImplementation(() => { + if (horizonLatestLedger == null) { + return Promise.reject(new Error("Network error")); + } + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + _embedded: { + records: [{ sequence: String(horizonLatestLedger) }], + }, + }), + } as Response); + }); - return { - mockDataSource, - mockCacheService, - mockCursorManager, + return { + mockDataSource, + mockCacheService, + mockCursorManager, mockDlqService, - mockConfigService, - fetchSpy + mockConfigService, + fetchSpy, }; } @@ -93,16 +100,16 @@ function makeMocks(overrides: { // Tests // --------------------------------------------------------------------------- -describe('HealthService', () => { +describe("HealthService", () => { let service: HealthService; async function build(overrides: Parameters[0] = {}) { - const { - mockDataSource, - mockCacheService, - mockCursorManager, + const { + mockDataSource, + mockCacheService, + mockCursorManager, mockDlqService, - mockConfigService + mockConfigService, } = makeMocks(overrides); const module: TestingModule = await Test.createTestingModule({ @@ -125,221 +132,260 @@ describe('HealthService', () => { // ── Healthy path ────────────────────────────────────────────────────────── - it('should return status ok when DB, Redis are up and lag is within threshold', async () => { + it("should return status ok when DB, Redis are up and lag is within threshold", async () => { await build({ horizonLatestLedger: 1012, cursorLastLedger: 1000 }); // lag = 12 const result = await service.getHealth(); - expect(result.status).toBe('ok'); - expect(result.db).toBe('ok'); - expect(result.redis).toBe('ok'); + expect(result.status).toBe("ok"); + expect(result.db).toBe("ok"); + expect(result.redis).toBe("ok"); + expect(result.cursor_integrity).toBe("ok"); expect(result.lag_ledgers).toBe(12); }); // ── DB failures ─────────────────────────────────────────────────────────── - it('should return status degraded when DB query throws', async () => { + it("should return status degraded when DB query throws", async () => { await build({ dbQueryOk: false }); const result = await service.getHealth(); - expect(result.status).toBe('degraded'); - expect(result.db).toBe('error'); + expect(result.status).toBe("degraded"); + expect(result.db).toBe("error"); }); - it('should return status degraded when DataSource is not initialized', async () => { + it("should return status degraded when DataSource is not initialized", async () => { await build({ dbInitialized: false }); const result = await service.getHealth(); - expect(result.status).toBe('degraded'); - expect(result.db).toBe('error'); + expect(result.status).toBe("degraded"); + expect(result.db).toBe("error"); }); // ── Redis failures ──────────────────────────────────────────────────────── - it('should return status degraded when Redis ping fails', async () => { + it("should return status degraded when Redis ping fails", async () => { await build({ redisPingOk: false }); const result = await service.getHealth(); - expect(result.status).toBe('degraded'); - expect(result.redis).toBe('error'); + expect(result.status).toBe("degraded"); + expect(result.redis).toBe("error"); }); // ── Lag scenarios ───────────────────────────────────────────────────────── - it('should return status degraded when lag exceeds threshold', async () => { + it("should return status degraded when lag exceeds threshold", async () => { await build({ horizonLatestLedger: 1200, cursorLastLedger: 1000 }); // lag = 200 > 100 const result = await service.getHealth(); - expect(result.status).toBe('degraded'); + expect(result.status).toBe("degraded"); expect(result.lag_ledgers).toBe(200); }); - it('should return status ok when lag equals exactly the threshold', async () => { + it("should return status ok when lag equals exactly the threshold", async () => { await build({ horizonLatestLedger: 1100, cursorLastLedger: 1000 }); // lag = 100, not > 100 const result = await service.getHealth(); - expect(result.status).toBe('ok'); + expect(result.status).toBe("ok"); expect(result.lag_ledgers).toBe(100); }); - it('should return lag_ledgers null when Horizon is unreachable', async () => { + it("should return lag_ledgers null when Horizon is unreachable", async () => { await build({ horizonLatestLedger: null, cursorLastLedger: 1000 }); const result = await service.getHealth(); expect(result.lag_ledgers).toBeNull(); // Status should still be ok if DB and Redis are fine - expect(result.status).toBe('ok'); + expect(result.status).toBe("ok"); }); - it('should return lag_ledgers null when cursor has not been set yet', async () => { + it("should return lag_ledgers null when cursor has not been set yet", async () => { await build({ cursorLastLedger: null, horizonLatestLedger: 1000 }); const result = await service.getHealth(); expect(result.lag_ledgers).toBeNull(); }); - it('should clamp lag to 0 if cursor is ahead of Horizon (e.g. clock skew)', async () => { + it("should clamp lag to 0 if cursor is ahead of Horizon (e.g. clock skew)", async () => { await build({ horizonLatestLedger: 900, cursorLastLedger: 1000 }); // cursor ahead const result = await service.getHealth(); expect(result.lag_ledgers).toBe(0); - expect(result.status).toBe('ok'); + expect(result.status).toBe("ok"); }); // ── Cursor sanity checks ────────────────────────────────────────────────── - it('should return degraded when cursor is not initialized (null)', async () => { + it("should return degraded when cursor is not initialized (null)", async () => { await build({ cursorLastLedger: null, horizonLatestLedger: 1012 }); const result = await service.getHealth(); - expect(result.status).toBe('degraded'); - expect(result.cursor).toBe('error'); + expect(result.status).toBe("degraded"); + expect(result.cursor).toBe("error"); expect(result.lag_ledgers).toBeNull(); }); - it('should return degraded when cursor lastLedger is 0', async () => { + it("should return degraded when cursor lastLedger is 0", async () => { await build({ cursorLastLedger: 0, horizonLatestLedger: 1012 }); const result = await service.getHealth(); - expect(result.status).toBe('degraded'); - expect(result.cursor).toBe('error'); + expect(result.status).toBe("degraded"); + expect(result.cursor).toBe("error"); }); - it('should return degraded when cursor lastLedger is negative', async () => { + it("should return degraded when cursor lastLedger is negative", async () => { await build({ cursorLastLedger: -5, horizonLatestLedger: 1012 }); const result = await service.getHealth(); - expect(result.status).toBe('degraded'); - expect(result.cursor).toBe('error'); + expect(result.status).toBe("degraded"); + expect(result.cursor).toBe("error"); }); - it('should return degraded when cursor lastLedger is impossibly large', async () => { + it("should return degraded when cursor lastLedger is impossibly large", async () => { await build({ cursorLastLedger: 1_000_000_001, horizonLatestLedger: 1012 }); const result = await service.getHealth(); - expect(result.status).toBe('degraded'); - expect(result.cursor).toBe('error'); + expect(result.status).toBe("degraded"); + expect(result.cursor).toBe("error"); }); - it('should return ok when cursor is valid', async () => { + it("should return ok when cursor is valid", async () => { await build({ cursorLastLedger: 1000, horizonLatestLedger: 1012 }); const result = await service.getHealth(); - expect(result.cursor).toBe('ok'); - expect(result.status).toBe('ok'); + expect(result.cursor).toBe("ok"); + expect(result.status).toBe("ok"); }); // ── DLQ pressure checks ─────────────────────────────────────────────────── - it('should return dlqPressure ok when DLQ size is below threshold', async () => { + it("should return dlqPressure ok when DLQ size is below threshold", async () => { await build({ dlqSize: 50, dlqPressureThreshold: 100 }); const result = await service.getHealth(); expect(result.dlq_size).toBe(50); - expect(result.dlqPressure).toBe('ok'); - expect(result.status).toBe('ok'); + expect(result.dlqPressure).toBe("ok"); + expect(result.status).toBe("ok"); }); - it('should return dlqPressure high when DLQ size exceeds threshold', async () => { + it("should return dlqPressure high when DLQ size exceeds threshold", async () => { await build({ dlqSize: 150, dlqPressureThreshold: 100 }); const result = await service.getHealth(); expect(result.dlq_size).toBe(150); - expect(result.dlqPressure).toBe('high'); - expect(result.status).toBe('degraded'); + expect(result.dlqPressure).toBe("high"); + expect(result.status).toBe("degraded"); }); - it('should return dlqPressure ok when DLQ size equals threshold exactly', async () => { + it("should return dlqPressure ok when DLQ size equals threshold exactly", async () => { await build({ dlqSize: 100, dlqPressureThreshold: 100 }); const result = await service.getHealth(); expect(result.dlq_size).toBe(100); - expect(result.dlqPressure).toBe('ok'); - expect(result.status).toBe('ok'); + expect(result.dlqPressure).toBe("ok"); + expect(result.status).toBe("ok"); }); - it('should return dlqPressure high when DLQ size is 1 over threshold', async () => { + it("should return dlqPressure high when DLQ size is 1 over threshold", async () => { await build({ dlqSize: 101, dlqPressureThreshold: 100 }); const result = await service.getHealth(); expect(result.dlq_size).toBe(101); - expect(result.dlqPressure).toBe('high'); - expect(result.status).toBe('degraded'); + expect(result.dlqPressure).toBe("high"); + expect(result.status).toBe("degraded"); }); // ── Combined failures ──────────────────────────────────────────────────── - it('should return degraded when both DB and Redis fail', async () => { + it("should return degraded when both DB and Redis fail", async () => { await build({ dbQueryOk: false, redisPingOk: false }); const result = await service.getHealth(); - expect(result.status).toBe('degraded'); - expect(result.db).toBe('error'); - expect(result.redis).toBe('error'); + expect(result.status).toBe("degraded"); + expect(result.db).toBe("error"); + expect(result.redis).toBe("error"); + }); + + it("should return degraded when cursor fails and DLQ pressure is high", async () => { + await build({ + cursorLastLedger: 0, + dlqSize: 200, + dlqPressureThreshold: 100, + }); + const result = await service.getHealth(); + + expect(result.status).toBe("degraded"); + expect(result.cursor).toBe("error"); + expect(result.dlqPressure).toBe("high"); }); - it('should return degraded when cursor fails and DLQ pressure is high', async () => { - await build({ cursorLastLedger: 0, dlqSize: 200, dlqPressureThreshold: 100 }); + it("should return degraded when startup cursor integrity failed", async () => { + const { + mockDataSource, + mockCacheService, + mockCursorManager, + mockDlqService, + mockConfigService, + } = makeMocks({ cursorLastLedger: 1000, horizonLatestLedger: 1012 }); + + mockCursorManager.getStatus.mockReturnValue({ + startupIntegrityPassed: false, + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + HealthService, + { provide: DataSource, useValue: mockDataSource }, + { provide: CacheService, useValue: mockCacheService }, + { provide: CursorManagerService, useValue: mockCursorManager }, + { provide: DlqService, useValue: mockDlqService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(HealthService); + const result = await service.getHealth(); - expect(result.status).toBe('degraded'); - expect(result.cursor).toBe('error'); - expect(result.dlqPressure).toBe('high'); + expect(result.cursor_integrity).toBe("error"); + expect(result.status).toBe("degraded"); }); - it('should return degraded when lag is high AND DLQ pressure is high', async () => { - await build({ - horizonLatestLedger: 1500, - cursorLastLedger: 1000, + it("should return degraded when lag is high AND DLQ pressure is high", async () => { + await build({ + horizonLatestLedger: 1500, + cursorLastLedger: 1000, lagThreshold: 100, - dlqSize: 150, - dlqPressureThreshold: 100 + dlqSize: 150, + dlqPressureThreshold: 100, }); const result = await service.getHealth(); - expect(result.status).toBe('degraded'); + expect(result.status).toBe("degraded"); expect(result.lag_ledgers).toBe(500); - expect(result.dlqPressure).toBe('high'); + expect(result.dlqPressure).toBe("high"); }); // ── getLagThreshold and DLQ threshold getters ─────────────────────────── - it('should expose the configured lag threshold', async () => { + it("should expose the configured lag threshold", async () => { await build({ lagThreshold: 50 }); expect(service.getLagThreshold()).toBe(50); }); - it('should use the default lag threshold when not configured', async () => { + it("should use the default lag threshold when not configured", async () => { await build(); // uses LAG_THRESHOLD_DEFAULT = 100 expect(service.getLagThreshold()).toBe(LAG_THRESHOLD_DEFAULT); }); - it('should expose the configured DLQ pressure threshold', async () => { + it("should expose the configured DLQ pressure threshold", async () => { await build({ dlqPressureThreshold: 250 }); expect(service.getDlqPressureThreshold()).toBe(250); }); - it('should use the default DLQ pressure threshold when not configured', async () => { + it("should use the default DLQ pressure threshold when not configured", async () => { await build(); // uses DLQ_PRESSURE_THRESHOLD_DEFAULT = 100 - expect(service.getDlqPressureThreshold()).toBe(DLQ_PRESSURE_THRESHOLD_DEFAULT); + expect(service.getDlqPressureThreshold()).toBe( + DLQ_PRESSURE_THRESHOLD_DEFAULT, + ); }); }); diff --git a/indexer/src/health/health.service.ts b/indexer/src/health/health.service.ts index cd6e29cf..f5133197 100644 --- a/indexer/src/health/health.service.ts +++ b/indexer/src/health/health.service.ts @@ -1,29 +1,30 @@ -import { Injectable, Optional } from '@nestjs/common'; -import { EventEmitter } from 'events'; -import { ConfigService } from '@nestjs/config'; -import { DataSource } from 'typeorm'; -import { CacheService } from '../cache/cache.service'; -import { CursorManagerService } from '../ingestor/cursor-manager.service'; -import { DlqService } from '../ingestor/dlq.service'; +import { Injectable, Optional } from "@nestjs/common"; +import { EventEmitter } from "events"; +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; +import { CacheService } from "../cache/cache.service"; +import { CursorManagerService } from "../ingestor/cursor-manager.service"; +import { DlqService } from "../ingestor/dlq.service"; import { PipelineStateMachine, PipelineStateSnapshot, -} from '../ingestor/pipeline-state'; +} from "../ingestor/pipeline-state"; export const LAG_THRESHOLD_DEFAULT = 100; export const LAG_ALERT_THRESHOLD_DEFAULT = 50; export const DLQ_PRESSURE_THRESHOLD_DEFAULT = 100; export interface HealthResult { - status: 'ok' | 'degraded'; + status: "ok" | "degraded"; lag_ledgers: number | null; - lagStatus: 'healthy' | 'degraded' | 'critical'; - db: 'ok' | 'error'; - redis: 'ok' | 'error'; + lagStatus: "healthy" | "degraded" | "critical"; + db: "ok" | "error"; + redis: "ok" | "error"; redis_latency_ms: number | null; - cursor: 'ok' | 'error'; + cursor: "ok" | "error"; + cursor_integrity: "ok" | "error"; dlq_size: number; - dlqPressure: 'ok' | 'high'; + dlqPressure: "ok" | "high"; pipeline?: PipelineStateSnapshot | null; } @@ -33,7 +34,7 @@ export class HealthService { private readonly lagThreshold: number; private readonly lagAlertThreshold: number; private readonly dlqPressureThreshold: number; - private previousLagStatus: 'healthy' | 'degraded' | 'critical' = 'healthy'; + private previousLagStatus: "healthy" | "degraded" | "critical" = "healthy"; private eventEmitter: EventEmitter; constructor( @@ -44,58 +45,61 @@ export class HealthService { @Optional() private readonly dlqService?: DlqService, @Optional() private readonly pipeline?: PipelineStateMachine, ) { - this.horizonUrl = this.configService.get( - 'HORIZON_URL', - 'https://horizon.stellar.org', - ).replace(/\/$/, ''); + this.horizonUrl = this.configService + .get("HORIZON_URL", "https://horizon.stellar.org") + .replace(/\/$/, ""); this.lagThreshold = this.configService.get( - 'LAG_THRESHOLD', + "LAG_THRESHOLD", LAG_THRESHOLD_DEFAULT, ); this.lagAlertThreshold = this.configService.get( - 'INDEXER_LAG_ALERT_THRESHOLD_LEDGERS', + "INDEXER_LAG_ALERT_THRESHOLD_LEDGERS", LAG_ALERT_THRESHOLD_DEFAULT, ); this.dlqPressureThreshold = this.configService.get( - 'DLQ_PRESSURE_THRESHOLD', + "DLQ_PRESSURE_THRESHOLD", DLQ_PRESSURE_THRESHOLD_DEFAULT, ); this.eventEmitter = new EventEmitter(); } async getHealth(): Promise { - const [dbOk, redisLatency, latestLedger, cursor, dlq_size] = await Promise.all([ - this.checkDb(), - this.cacheService.latency(), - this.fetchLatestLedger(), - this.cursorManagerService.getCursor(), - this.dlqService ? this.dlqService.count() : Promise.resolve(0), - ]); - - const db: 'ok' | 'error' = dbOk ? 'ok' : 'error'; - const redis: 'ok' | 'error' = redisLatency !== null ? 'ok' : 'error'; + const [dbOk, redisLatency, latestLedger, cursor, dlq_size] = + await Promise.all([ + this.checkDb(), + this.cacheService.latency(), + this.fetchLatestLedger(), + this.cursorManagerService.getCursor(), + this.dlqService ? this.dlqService.count() : Promise.resolve(0), + ]); + + const db: "ok" | "error" = dbOk ? "ok" : "error"; + const redis: "ok" | "error" = redisLatency !== null ? "ok" : "error"; const cursorSanity = this.checkCursorSanity(cursor); - const cursor_status: 'ok' | 'error' = cursorSanity.ok ? 'ok' : 'error'; - const dlqPressure: 'ok' | 'high' = dlq_size > this.dlqPressureThreshold ? 'high' : 'ok'; + const cursor_status: "ok" | "error" = cursorSanity.ok ? "ok" : "error"; + const cursor_integrity: "ok" | "error" = + this.cursorManagerService.getStatus().startupIntegrityPassed + ? "ok" + : "error"; + const dlqPressure: "ok" | "high" = + dlq_size > this.dlqPressureThreshold ? "high" : "ok"; let lag_ledgers: number | null = null; if (latestLedger != null && cursor != null && cursor.lastLedger > 0) { lag_ledgers = Math.max(0, latestLedger - cursor.lastLedger); } - // Calculate lag status based on alert threshold - let lagStatus: 'healthy' | 'degraded' | 'critical' = 'healthy'; + let lagStatus: "healthy" | "degraded" | "critical" = "healthy"; if (lag_ledgers !== null) { if (lag_ledgers > this.lagAlertThreshold) { - lagStatus = 'critical'; + lagStatus = "critical"; } else if (lag_ledgers > this.lagThreshold) { - lagStatus = 'degraded'; + lagStatus = "degraded"; } } - // Emit alert when crossing into critical status - if (lagStatus === 'critical' && this.previousLagStatus !== 'critical') { - this.eventEmitter.emit('indexer_lag_alert', { + if (lagStatus === "critical" && this.previousLagStatus !== "critical") { + this.eventEmitter.emit("indexer_lag_alert", { lag_ledgers, threshold: this.lagAlertThreshold, timestamp: new Date().toISOString(), @@ -105,22 +109,17 @@ export class HealthService { const degradedByLag = lag_ledgers != null && lag_ledgers > this.lagThreshold; - const degradedByDlq = dlqPressure === 'high'; - const status: 'ok' | 'degraded' = - db === 'error' || redis === 'error' || cursor_status === 'error' || degradedByLag || degradedByDlq ? 'degraded' : 'ok'; - - return { - status, - lag_ledgers, - lagStatus, - db, - redis, - redis_latency_ms: redisLatency, - cursor: cursor_status, - dlq_size, - dlqPressure, - }; - db === 'error' || redis === 'error' || degradedByLag ? 'degraded' : 'ok'; + const degradedByDlq = dlqPressure === "high"; + const degradedByCursorIntegrity = cursor_integrity === "error"; + const status: "ok" | "degraded" = + db === "error" || + redis === "error" || + cursor_status === "error" || + degradedByCursorIntegrity || + degradedByLag || + degradedByDlq + ? "degraded" + : "ok"; const pipeline = this.pipeline ? this.pipeline.snapshot() : null; @@ -131,12 +130,14 @@ export class HealthService { db, redis, redis_latency_ms: redisLatency, + cursor: cursor_status, + cursor_integrity, dlq_size, + dlqPressure, pipeline, }; } - /** Returns the current pipeline state snapshot, or null when unavailable. */ getPipelineState(): PipelineStateSnapshot | null { return this.pipeline ? this.pipeline.snapshot() : null; } @@ -144,48 +145,37 @@ export class HealthService { private async checkDb(): Promise { try { if (!this.dataSource.isInitialized) return false; - await this.dataSource.query('SELECT 1'); + await this.dataSource.query("SELECT 1"); return true; } catch { return false; } } - /** - * Validates cursor sanity: - * - Cursor must exist - * - lastLedger must be > 0 (not null or 0) - * - lastLedger should be reasonable (not impossibly large) - */ private checkCursorSanity(cursor: any): { ok: boolean; reason?: string } { if (!cursor) { - return { ok: false, reason: 'Cursor not initialized' }; + return { ok: false, reason: "Cursor not initialized" }; } if (!Number.isInteger(cursor.lastLedger) || cursor.lastLedger <= 0) { return { ok: false, reason: `Invalid lastLedger: ${cursor.lastLedger}` }; } - // Cursor ledger should not be unreasonably far in the future - // Stellar ledgers close roughly every 5 seconds, so ~17k per day - // Allow up to 1 million as a reasonable upper bound if (cursor.lastLedger > 1_000_000_000) { - return { ok: false, reason: `Cursor ledger impossibly large: ${cursor.lastLedger}` }; + return { + ok: false, + reason: `Cursor ledger impossibly large: ${cursor.lastLedger}`, + }; } return { ok: true }; } - /** - * Fetches the latest closed ledger sequence from Horizon. - * Returns null if the request fails (e.g. network or Horizon down). - */ private async fetchLatestLedger(): Promise { try { - const res = await fetch( - `${this.horizonUrl}/ledgers?order=desc&limit=1`, - { signal: AbortSignal.timeout(5000) }, - ); + const res = await fetch(`${this.horizonUrl}/ledgers?order=desc&limit=1`, { + signal: AbortSignal.timeout(5000), + }); if (!res.ok) return null; const json = (await res.json()) as { _embedded?: { records?: Array<{ sequence: string }> }; @@ -197,22 +187,18 @@ export class HealthService { } } - /** Lag above this value is considered degraded. */ getLagThreshold(): number { return this.lagThreshold; } - /** Lag above this value triggers critical alerts. */ getLagAlertThreshold(): number { return this.lagAlertThreshold; } - /** DLQ size above this value is considered high pressure. */ getDlqPressureThreshold(): number { return this.dlqPressureThreshold; } - /** Get the event emitter for listening to lag alerts. */ getEventEmitter(): EventEmitter { return this.eventEmitter; } diff --git a/indexer/src/ingestor/cursor-integrity.ts b/indexer/src/ingestor/cursor-integrity.ts index 53de53c3..cace11f3 100644 --- a/indexer/src/ingestor/cursor-integrity.ts +++ b/indexer/src/ingestor/cursor-integrity.ts @@ -71,62 +71,71 @@ export interface CursorCheckpoint { */ export type IntegrityViolation = /** candidate.sequence is less than the previously saved sequence. */ - | { code: 'SEQUENCE_REGRESSION'; current: number; previous: number } + | { code: "SEQUENCE_REGRESSION"; current: number; previous: number } /** candidate.sequence equals the previously saved sequence (no-op write is not allowed). */ - | { code: 'SEQUENCE_DUPLICATE'; sequence: number } + | { code: "SEQUENCE_DUPLICATE"; sequence: number } /** The hash returned by the chain for this sequence differs from what was stored. */ - | { code: 'HASH_MISMATCH'; sequence: number; stored: string; actual: string } + | { code: "HASH_MISMATCH"; sequence: number; stored: string; actual: string } /** candidate.processedEventCount is less than the previously saved count. */ - | { code: 'EVENT_COUNT_REGRESSION'; current: number; previous: number } + | { code: "EVENT_COUNT_REGRESSION"; current: number; previous: number } /** savedAt is present but cannot be parsed as a valid ISO-8601 date. */ - | { code: 'INVALID_SAVED_AT'; value: string } + | { code: "INVALID_SAVED_AT"; value: string } /** The stored checkpoint version does not match CURSOR_CHECKPOINT_VERSION. */ - | { code: 'VERSION_MISMATCH'; stored: number; expected: number } + | { code: "VERSION_MISMATCH"; stored: number; expected: number } /** A required field is null or undefined in the stored checkpoint. */ - | { code: 'MISSING_REQUIRED_FIELD'; field: keyof CursorCheckpoint } + | { code: "MISSING_REQUIRED_FIELD"; field: keyof CursorCheckpoint } /** The stored value is not an object, or a field has the wrong primitive type. */ - | { code: 'CORRUPTED_CHECKPOINT'; detail: string }; + | { code: "CORRUPTED_CHECKPOINT"; detail: string }; // ── Internal helpers ────────────────────────────────────────────────────────── function checkStructure(candidate: unknown): IntegrityViolation | null { - if (candidate === null || typeof candidate !== 'object') { - return { code: 'CORRUPTED_CHECKPOINT', detail: 'not an object' }; + if (candidate === null || typeof candidate !== "object") { + return { code: "CORRUPTED_CHECKPOINT", detail: "not an object" }; } const c = candidate as Record; const required: Array = [ - 'sequence', - 'ledgerHash', - 'processedEventCount', - 'savedAt', - 'version', + "sequence", + "ledgerHash", + "processedEventCount", + "savedAt", + "version", ]; for (const field of required) { if (c[field] === undefined || c[field] === null) { - return { code: 'MISSING_REQUIRED_FIELD', field }; + return { code: "MISSING_REQUIRED_FIELD", field }; } } - if (typeof c.sequence !== 'number') { - return { code: 'CORRUPTED_CHECKPOINT', detail: 'sequence must be a number' }; + if (typeof c.sequence !== "number") { + return { + code: "CORRUPTED_CHECKPOINT", + detail: "sequence must be a number", + }; } - if (typeof c.ledgerHash !== 'string') { - return { code: 'CORRUPTED_CHECKPOINT', detail: 'ledgerHash must be a string' }; + if (typeof c.ledgerHash !== "string") { + return { + code: "CORRUPTED_CHECKPOINT", + detail: "ledgerHash must be a string", + }; } - if (typeof c.processedEventCount !== 'number') { - return { code: 'CORRUPTED_CHECKPOINT', detail: 'processedEventCount must be a number' }; + if (typeof c.processedEventCount !== "number") { + return { + code: "CORRUPTED_CHECKPOINT", + detail: "processedEventCount must be a number", + }; } - if (typeof c.savedAt !== 'string') { - return { code: 'CORRUPTED_CHECKPOINT', detail: 'savedAt must be a string' }; + if (typeof c.savedAt !== "string") { + return { code: "CORRUPTED_CHECKPOINT", detail: "savedAt must be a string" }; } - if (typeof c.version !== 'number') { - return { code: 'CORRUPTED_CHECKPOINT', detail: 'version must be a number' }; + if (typeof c.version !== "number") { + return { code: "CORRUPTED_CHECKPOINT", detail: "version must be a number" }; } return null; } function checkSavedAt(value: string): IntegrityViolation | null { const d = new Date(value); - if (isNaN(d.getTime())) return { code: 'INVALID_SAVED_AT', value }; + if (isNaN(d.getTime())) return { code: "INVALID_SAVED_AT", value }; return null; } @@ -164,19 +173,27 @@ export function validateBeforeSave( if (savedAtViolation) return savedAtViolation; if (candidate.version !== CURSOR_CHECKPOINT_VERSION) { - return { code: 'VERSION_MISMATCH', stored: candidate.version, expected: CURSOR_CHECKPOINT_VERSION }; + return { + code: "VERSION_MISMATCH", + stored: candidate.version, + expected: CURSOR_CHECKPOINT_VERSION, + }; } if (previous !== null) { if (candidate.sequence === previous.sequence) { - return { code: 'SEQUENCE_DUPLICATE', sequence: candidate.sequence }; + return { code: "SEQUENCE_DUPLICATE", sequence: candidate.sequence }; } if (candidate.sequence < previous.sequence) { - return { code: 'SEQUENCE_REGRESSION', current: candidate.sequence, previous: previous.sequence }; + return { + code: "SEQUENCE_REGRESSION", + current: candidate.sequence, + previous: previous.sequence, + }; } if (candidate.processedEventCount < previous.processedEventCount) { return { - code: 'EVENT_COUNT_REGRESSION', + code: "EVENT_COUNT_REGRESSION", current: candidate.processedEventCount, previous: previous.processedEventCount, }; @@ -214,16 +231,29 @@ export function validateOnLoad(stored: unknown): IntegrityViolation | null { if (savedAtViolation) return savedAtViolation; if (c.version !== CURSOR_CHECKPOINT_VERSION) { - return { code: 'VERSION_MISMATCH', stored: c.version, expected: CURSOR_CHECKPOINT_VERSION }; + return { + code: "VERSION_MISMATCH", + stored: c.version, + expected: CURSOR_CHECKPOINT_VERSION, + }; } if (c.processedEventCount < 0) { - return { code: 'CORRUPTED_CHECKPOINT', detail: 'processedEventCount must be >= 0' }; + return { + code: "CORRUPTED_CHECKPOINT", + detail: "processedEventCount must be >= 0", + }; } return null; } +export class CursorIntegrity { + static validate(stored: unknown): IntegrityViolation | null { + return validateOnLoad(stored); + } +} + /** * Validate the ledger hash reported by the chain against the hash stored in * the checkpoint for the same sequence. @@ -241,7 +271,7 @@ export function validateLedgerHash( ): IntegrityViolation | null { if (checkpoint.ledgerHash !== actualHash) { return { - code: 'HASH_MISMATCH', + code: "HASH_MISMATCH", sequence: checkpoint.sequence, stored: checkpoint.ledgerHash, actual: actualHash, diff --git a/indexer/src/ingestor/cursor-manager.service.ts b/indexer/src/ingestor/cursor-manager.service.ts index b483b74c..e18c59bb 100644 --- a/indexer/src/ingestor/cursor-manager.service.ts +++ b/indexer/src/ingestor/cursor-manager.service.ts @@ -2,65 +2,58 @@ * cursor-manager.service.ts * * Manages the singleton cursor row (id=1) in the indexer_cursor table. - * - * Changes in issue #560: - * - Added IngestorMode ("RUNNING" | "DEGRADED" | "STOPPED") - * - validateOnLoad() called in getCursor(); violation → DEGRADED, no loop start - * - validateBeforeSave() called in saveCursor(); violation → DEGRADED + throws - * - validateLedgerHash() exposed via checkForReorg() (existing callers unchanged) - * - getStatus() exposes mode, lastCheckpoint, lastViolation, uptimeMs - * - CursorIntegrityError typed error class for callers to catch - * - * Storage: TypeORM upsert on IndexerCursorEntity (PostgreSQL, singleton row id=1). - * The existing IndexerCursor interface and saveCursor/getCursor signatures are - * preserved for backward compatibility with LedgerPollerService. */ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, EntityManager, QueryRunner } from 'typeorm'; -import { IndexerCursorEntity } from '../database/entities/indexer-cursor.entity'; +import { Injectable, Logger, Optional } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, QueryRunner, Repository } from "typeorm"; +import { IndexerCursorEntity } from "../database/entities/indexer-cursor.entity"; import { CursorCheckpoint, CURSOR_CHECKPOINT_VERSION, + CursorIntegrity, IntegrityViolation, validateBeforeSave, validateLedgerHash, - validateOnLoad, -} from './cursor-integrity'; +} from "./cursor-integrity"; +import { PipelineStateMachine } from "./pipeline-state"; -// ── Public types ────────────────────────────────────────────────────────────── +export type IngestorMode = "RUNNING" | "DEGRADED" | "STOPPED"; -/** - * Operational mode of the ingestion pipeline. - * - * RUNNING — normal operation. - * DEGRADED — integrity violation detected; ingestion paused, no writes. - * Operator action required to clear or reset. - * STOPPED — clean shutdown. - */ -export type IngestorMode = 'RUNNING' | 'DEGRADED' | 'STOPPED'; - -/** Legacy shape returned by getCursor() — unchanged for backward compat. */ export interface IndexerCursor { lastLedger: number; lastPagingToken?: string; ledgerHashes: Array<{ ledger: number; hash: string }>; } -// ── Constants ───────────────────────────────────────────────────────────────── +export interface CursorManagerStatus { + mode: IngestorMode; + lastCheckpoint: CursorCheckpoint | null; + lastViolation: IntegrityViolation | null; + startupIntegrityPassed: boolean; + uptimeMs: number; +} -const HASH_RING_SIZE = 200; +export class CursorIntegrityError extends Error { + constructor( + public readonly violation: IntegrityViolation, + public readonly checkpoint: CursorCheckpoint, + ) { + super(`Cursor integrity validation failed: ${violation.code}`); + this.name = "CursorIntegrityError"; + } +} -// ── Service ─────────────────────────────────────────────────────────────────── +const HASH_RING_SIZE = 200; @Injectable() export class CursorManagerService { private readonly logger = new Logger(CursorManagerService.name); - private mode: IngestorMode = 'RUNNING'; + private mode: IngestorMode = "RUNNING"; private lastCheckpoint: CursorCheckpoint | null = null; private lastViolation: IntegrityViolation | null = null; + private startupIntegrityPassed = true; private readonly startedAt = Date.now(); constructor( @@ -69,51 +62,49 @@ export class CursorManagerService { @Optional() private readonly pipeline?: PipelineStateMachine, ) {} - // ── Public API ───────────────────────────────────────────────────────────── - - /** - * Returns a snapshot of the cursor manager's current state. - * - * - `mode` — RUNNING (normal), DEGRADED (integrity violation, writes - * blocked), or STOPPED (clean shutdown). - * - `lastCheckpoint` — the last checkpoint successfully validated and written; - * use this to determine the safe resume point. - * - `lastViolation` — the violation that triggered DEGRADED, if any; inspect - * `violation.code` and the accompanying fields to diagnose - * the root cause before attempting recovery. - * - `uptimeMs` — milliseconds since this service instance was created; - * useful for correlating log timestamps. - */ getStatus(): CursorManagerStatus { return { mode: this.mode, lastCheckpoint: this.lastCheckpoint, lastViolation: this.lastViolation, + startupIntegrityPassed: this.startupIntegrityPassed, uptimeMs: Date.now() - this.startedAt, }; } - /** - * Load the cursor from storage. - * Runs validateOnLoad(); on violation transitions to DEGRADED and returns null. - * Callers (LedgerPollerService) must check getStatus().mode before starting. - */ + async validateStartupIntegrity(): Promise { + this.logger.debug("Validating cursor integrity on startup..."); + + const row = await this.cursorRepo.findOne({ where: { id: 1 } }); + if (!row || row.lastLedger === 0) { + this.startupIntegrityPassed = true; + this.lastViolation = null; + return; + } + + const checkpoint = this.toCheckpoint(row); + const violation = CursorIntegrity.validate(checkpoint); + + if (violation) { + this.startupIntegrityPassed = false; + this.transitionDegraded(violation); + throw new CursorIntegrityError(violation, checkpoint); + } + + this.startupIntegrityPassed = true; + this.lastCheckpoint = checkpoint; + this.lastViolation = null; + } + async getCursor(): Promise { - this.logger.debug('Fetching cursor from storage...'); + this.logger.debug("Fetching cursor from storage..."); const row = await this.cursorRepo.findOne({ where: { id: 1 } }); if (!row || row.lastLedger === 0) return null; - // Build a CursorCheckpoint from the stored row for validation - const stored: CursorCheckpoint = { - sequence: row.lastLedger, - ledgerHash: row.ledgerHashes[row.ledgerHashes.length - 1]?.hash ?? '', - processedEventCount: Number(row.processedEventCount), - savedAt: row.savedAt instanceof Date ? row.savedAt.toISOString() : String(row.savedAt), - version: row.checkpointVersion, - }; - - const violation = validateOnLoad(stored); + const stored = this.toCheckpoint(row); + const violation = CursorIntegrity.validate(stored); if (violation) { + this.startupIntegrityPassed = false; this.transitionDegraded(violation); return null; } @@ -122,21 +113,10 @@ export class CursorManagerService { return { lastLedger: row.lastLedger, lastPagingToken: row.lastPagingToken, - ledgerHashes: row.ledgerHashes, + ledgerHashes: row.ledgerHashes ?? [], }; } - /** - * Persist a new checkpoint. - * Runs validateBeforeSave(); on violation transitions to DEGRADED and throws - * CursorIntegrityError — the caller must not continue processing. - * - * @param ledger Ledger sequence number being checkpointed. - * @param ledgerHash Hash of this ledger from the chain. - * @param token Horizon paging token (optional). - * @param processedCount Cumulative event count up to this ledger. - * @param queryRunner Optional QueryRunner for transactional saves. - */ async saveCursor( ledger: number, ledgerHash: string, @@ -144,13 +124,16 @@ export class CursorManagerService { processedCount?: number, queryRunner?: QueryRunner, ): Promise { - if (this.mode === 'DEGRADED') { - this.logger.warn('saveCursor called while in DEGRADED mode — write suppressed'); + if (this.mode === "DEGRADED") { + this.logger.warn( + "saveCursor called while in DEGRADED mode — write suppressed", + ); return; } const savedAt = new Date().toISOString(); - const eventCount = processedCount ?? (this.lastCheckpoint?.processedEventCount ?? 0); + const eventCount = + processedCount ?? this.lastCheckpoint?.processedEventCount ?? 0; const candidate: CursorCheckpoint = { sequence: ledger, @@ -166,61 +149,47 @@ export class CursorManagerService { throw new CursorIntegrityError(violation, candidate); } - this.logger.debug( - `Saving cursor: ledger=${ledger}, hash=${ledgerHash}, events=${eventCount}, token=${token}`, - ); - const manager: EntityManager = queryRunner ? queryRunner.manager : this.cursorRepo.manager; - - const existing = await manager.findOne(IndexerCursorEntity, { where: { id: 1 } }); - const hashes = existing?.ledgerHashes ?? []; - hashes.push({ ledger, hash: ledgerHash }); - if (hashes.length > HASH_RING_SIZE) hashes.shift(); - - this.logger.debug( - `Saving cursor: ledger=${ledger}, hash=${ledgerHash}, events=${eventCount}, token=${token}`, - ); - - const manager: EntityManager = queryRunner - ? queryRunner.manager - : this.cursorRepo.manager; - - const existing = await manager.findOne(IndexerCursorEntity, { where: { id: 1 } }); - const hashes = existing?.ledgerHashes ?? []; - hashes.push({ ledger, hash: ledgerHash }); - if (hashes.length > HASH_RING_SIZE) hashes.shift(); + const existing = await manager.findOne(IndexerCursorEntity, { + where: { id: 1 }, + }); + const hashes = [ + ...(existing?.ledgerHashes ?? []), + { ledger, hash: ledgerHash }, + ]; + while (hashes.length > HASH_RING_SIZE) { + hashes.shift(); + } await manager.upsert( IndexerCursorEntity, { id: 1, lastLedger: ledger, - lastPagingToken: token ?? '', + lastPagingToken: token ?? "", ledgerHashes: hashes, processedEventCount: eventCount, savedAt: new Date(savedAt), checkpointVersion: CURSOR_CHECKPOINT_VERSION, }, - ['id'], + ["id"], ); this.lastCheckpoint = candidate; } - /** - * Check whether the chain-reported hash for a ledger matches the stored hash. - * If the checkpoint for this sequence is the lastCheckpoint, also runs - * validateLedgerHash() and transitions to DEGRADED on mismatch. - * - * Returns the divergence ledger number on reorg/mismatch, null otherwise. - */ - async checkForReorg(ledger: number, expectedHash: string): Promise { + async checkForReorg( + ledger: number, + expectedHash: string, + ): Promise { const cursor = await this.cursorRepo.findOne({ where: { id: 1 } }); if (!cursor) return null; - const stored = cursor.ledgerHashes.find((h) => h.ledger === ledger); + const stored = (cursor.ledgerHashes ?? []).find( + (entry: { ledger: number; hash: string }) => entry.ledger === ledger, + ); if (!stored) return null; if (stored.hash !== expectedHash) { @@ -228,10 +197,11 @@ export class CursorManagerService { `Reorg detected at ledger ${ledger}: expected ${expectedHash}, got ${stored.hash}`, ); - // If this is the current checkpoint, run the typed integrity check too if (this.lastCheckpoint?.sequence === ledger) { const violation = validateLedgerHash(this.lastCheckpoint, expectedHash); - if (violation) this.transitionDegraded(violation); + if (violation) { + this.transitionDegraded(violation); + } } return ledger; @@ -240,15 +210,26 @@ export class CursorManagerService { return null; } - // ── Private helpers ──────────────────────────────────────────────────────── + private toCheckpoint(row: IndexerCursorEntity): CursorCheckpoint { + return { + sequence: row.lastLedger, + ledgerHash: row.ledgerHashes?.[row.ledgerHashes.length - 1]?.hash ?? "", + processedEventCount: Number(row.processedEventCount), + savedAt: + row.savedAt instanceof Date + ? row.savedAt.toISOString() + : String(row.savedAt), + version: row.checkpointVersion, + }; + } private transitionDegraded(violation: IntegrityViolation): void { - this.mode = 'DEGRADED'; + this.mode = "DEGRADED"; this.lastViolation = violation; - this.logger.error('cursor_integrity_violation', { + this.logger.error("cursor_integrity_violation", { violation: violation.code, detail: violation, - action: 'ingestion_paused_awaiting_operator', + action: "ingestion_paused_awaiting_operator", }); } } diff --git a/indexer/src/main.ts b/indexer/src/main.ts index 68de2a9e..357aad4e 100644 --- a/indexer/src/main.ts +++ b/indexer/src/main.ts @@ -1,8 +1,34 @@ -import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; +import { Logger } from "@nestjs/common"; +import { NestFactory } from "@nestjs/core"; +import { AppModule } from "./app.module"; +import { + CursorIntegrityError, + CursorManagerService, +} from "./ingestor/cursor-manager.service"; -async function bootstrap() { +const logger = new Logger("Bootstrap"); + +export async function bootstrap() { const app = await NestFactory.create(AppModule); + + try { + const cursorManager = app.get(CursorManagerService); + await cursorManager.validateStartupIntegrity(); + } catch (error) { + if (error instanceof CursorIntegrityError) { + logger.error( + `Cursor integrity validation failed during startup (${error.violation.code}). Halting startup before ledger ingestion begins.`, + ); + } else { + logger.error( + `Unexpected startup validation failure: ${error instanceof Error ? error.message : String(error)}`, + ); + } + await app.close(); + throw error; + } + await app.listen(process.env.PORT ?? 3002); } + bootstrap(); diff --git a/indexer/src/test/integration/startup-cursor-integrity.integration.spec.ts b/indexer/src/test/integration/startup-cursor-integrity.integration.spec.ts new file mode 100644 index 00000000..98288cec --- /dev/null +++ b/indexer/src/test/integration/startup-cursor-integrity.integration.spec.ts @@ -0,0 +1,66 @@ +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { IndexerCursorEntity } from '../../database/entities/indexer-cursor.entity'; +import { + CursorIntegrityError, + CursorManagerService, +} from '../../ingestor/cursor-manager.service'; +import { CURSOR_CHECKPOINT_VERSION } from '../../ingestor/cursor-integrity'; +import { + CONTAINER_STARTUP_MS, + DbContainerContext, + startDb, + stopDb, +} from './helpers/db-container'; + +describe('Startup cursor integrity integration', () => { + let ctx: DbContainerContext; + + beforeAll(async () => { + ctx = await startDb(); + }, CONTAINER_STARTUP_MS); + + afterAll(async () => stopDb(ctx)); + + beforeEach(async () => { + await ctx.dataSource.query('TRUNCATE TABLE indexer_cursor RESTART IDENTITY'); + }); + + it('halts startup when the persisted cursor is corrupted', async () => { + await ctx.dataSource.query( + `INSERT INTO indexer_cursor + (id, last_ledger, last_paging_token, ledger_hashes, + processed_event_count, saved_at, checkpoint_version) + VALUES (1, $1, $2, $3::jsonb, $4, $5, $6)`, + [ + 100, + '', + JSON.stringify([{ ledger: 100, hash: 'abc' }]), + 10, + new Date(), + CURSOR_CHECKPOINT_VERSION + 1, + ], + ); + + const moduleRef = await Test.createTestingModule({ + providers: [ + CursorManagerService, + { + provide: getRepositoryToken(IndexerCursorEntity), + useValue: ctx.dataSource.getRepository(IndexerCursorEntity), + }, + ], + }).compile(); + + const cursorManager = moduleRef.get(CursorManagerService); + + await expect(cursorManager.validateStartupIntegrity()).rejects.toBeInstanceOf( + CursorIntegrityError, + ); + expect(cursorManager.getStatus().mode).toBe('DEGRADED'); + expect(cursorManager.getStatus().startupIntegrityPassed).toBe(false); + expect(cursorManager.getStatus().lastViolation?.code).toBe('VERSION_MISMATCH'); + + await moduleRef.close(); + }); +}); From 4cce2c39f3adda22df91060f7992a314bdca8ef2 Mon Sep 17 00:00:00 2001 From: "Ikenga Ifeanyi .M." Date: Sat, 27 Jun 2026 13:14:38 +0100 Subject: [PATCH 3/3] Add typed SDK contract errors --- sdk/src/contract/contract.service.ts | 216 +++++++---- sdk/src/contract/lifecycle.ts | 139 ++++--- sdk/src/contract/response.ts | 12 +- sdk/src/modules/raffle/raffle.service.ts | 187 +++++++--- sdk/src/modules/ticket/ticket.service.ts | 110 +++--- sdk/src/utils/errors.spec.ts | 164 +++++++-- sdk/src/utils/errors.ts | 451 +++++++++++++++-------- 7 files changed, 853 insertions(+), 426 deletions(-) diff --git a/sdk/src/contract/contract.service.ts b/sdk/src/contract/contract.service.ts index 7b16c68e..dd287db1 100644 --- a/sdk/src/contract/contract.service.ts +++ b/sdk/src/contract/contract.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, Optional } from '@nestjs/common'; +import { Injectable, Inject, Optional } from "@nestjs/common"; import { TransactionBuilder, rpc, @@ -8,20 +8,31 @@ import { nativeToScVal, scValToNative, BASE_FEE, -} from '@stellar/stellar-sdk'; -import { RpcService } from '../network/rpc.service'; -import { HorizonService } from '../network/horizon.service'; -import { NetworkConfig } from '../network/network.config'; -import { WalletAdapter } from '../wallet/wallet.interface'; -import { getRaffleContractId } from './constants'; -import { ContractFn, ContractFnName } from './bindings'; -import { TikkaSdkError, TikkaSdkErrorCode } from '../utils/errors'; -import { TransactionLifecycle } from './lifecycle'; -import type { TxMemo, PollConfig, SimulateResult, SubmitResult, InvokeLifecycleOptions } from './lifecycle'; -export type { TxMemo } from './lifecycle'; -export type { SimulateResult, SubmitResult, PollConfig } from './lifecycle'; - -import { TxResponse } from './response'; +} from "@stellar/stellar-sdk"; +import { RpcService } from "../network/rpc.service"; +import { HorizonService } from "../network/horizon.service"; +import { NetworkConfig } from "../network/network.config"; +import { WalletAdapter } from "../wallet/wallet.interface"; +import { getRaffleContractId } from "./constants"; +import { ContractFn, ContractFnName } from "./bindings"; +import { + TikkaSdkError, + TikkaSdkErrorCode, + toTypedContractError, + toTypedSdkError, +} from "../utils/errors"; +import { TransactionLifecycle } from "./lifecycle"; +import type { + TxMemo, + PollConfig, + SimulateResult, + SubmitResult, + InvokeLifecycleOptions, +} from "./lifecycle"; +export type { TxMemo } from "./lifecycle"; +export type { SimulateResult, SubmitResult, PollConfig } from "./lifecycle"; + +import { ContractResponse, TxResponse } from "./response"; export interface InvokeOptions { sourcePublicKey?: string; @@ -53,7 +64,6 @@ export interface UnsignedTxResult { networkPassphrase: string; } - /** * Detects if an error message indicates a failure in an external contract * (e.g., a SEP-41 token contract rejecting a transfer). @@ -77,11 +87,17 @@ export class ContractService { constructor( private readonly rpc: RpcService, private readonly horizon: HorizonService, - @Inject('NETWORK_CONFIG') private readonly networkConfig: NetworkConfig, - @Optional() @Inject('WALLET_ADAPTER') private wallet?: WalletAdapter, + @Inject("NETWORK_CONFIG") private readonly networkConfig: NetworkConfig, + @Optional() @Inject("WALLET_ADAPTER") private wallet?: WalletAdapter, ) { this.contractId = getRaffleContractId(networkConfig.network); - this.lifecycle = new TransactionLifecycle(rpc, horizon, networkConfig, wallet, this.contractId); + this.lifecycle = new TransactionLifecycle( + rpc, + horizon, + networkConfig, + wallet, + this.contractId, + ); } setContractId(id: string): void { @@ -104,7 +120,10 @@ export class ContractService { async simulate( method: ContractFnName | string, params: any[], - options: Pick = {}, + options: Pick< + InvokeLifecycleOptions, + "sourcePublicKey" | "fee" | "memo" + > = {}, ): Promise> { return this.lifecycle.simulate(method, params, options); } @@ -113,7 +132,10 @@ export class ContractService { * Phase 2 — Sign an assembled transaction XDR via the connected wallet. * Returns the signed XDR string. */ - async sign(assembledXdr: string, networkPassphrase?: string): Promise { + async sign( + assembledXdr: string, + networkPassphrase?: string, + ): Promise { return this.lifecycle.sign(assembledXdr, networkPassphrase); } @@ -129,19 +151,25 @@ export class ContractService { * Phase 4 — Poll for transaction confirmation. * Returns the on-chain return value, tx hash, and ledger. */ - async poll(txHash: string, config?: PollConfig): Promise> { + async poll( + txHash: string, + config?: PollConfig, + ): Promise> { return this.lifecycle.poll(txHash, config); } /* ---------------- READ ONLY ---------------- */ - async simulateReadOnly(method: ContractFnName | string, params: any[]): Promise> { + async simulateReadOnly( + method: ContractFnName | string, + params: any[], + ): Promise> { const sourceKey = this.wallet ? await this.wallet.getPublicKey() - : 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + : "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; const account = await this.horizon.loadAccount(sourceKey).catch(() => { - return { accountId: () => sourceKey, sequenceNumber: () => '0' } as any; + return { accountId: () => sourceKey, sequenceNumber: () => "0" } as any; }); const contract = new Contract(this.contractId); @@ -149,24 +177,34 @@ export class ContractService { fee: BASE_FEE, networkPassphrase: this.networkConfig.networkPassphrase, }) - .addOperation(contract.call(method, ...params.map((p) => this.toScVal(p)))) + .addOperation( + contract.call(method, ...params.map((p) => this.toScVal(p))), + ) .setTimeout(30) .build(); const simResponse = await this.rpc.simulateTransaction(tx); if (rpc.Api.isSimulationError(simResponse)) { - const errMsg = (simResponse as any).error ?? ''; - const code = isExternalSimulationError(errMsg) - ? TikkaSdkErrorCode.ExternalContractError - : TikkaSdkErrorCode.SimulationFailed; - throw new TikkaSdkError( - code, - `Read-only simulation of ${method} failed: ${errMsg}`, + const errMsg = (simResponse as any).error ?? ""; + const message = `Read-only simulation of ${method} failed: ${errMsg}`; + + if (isExternalSimulationError(errMsg)) { + throw new TikkaSdkError( + TikkaSdkErrorCode.ExternalContractError, + message, + errMsg, + ); + } + + throw ( + toTypedContractError(message, errMsg) ?? + new TikkaSdkError(TikkaSdkErrorCode.SimulationFailed, message, errMsg) ); } - const successResp = simResponse as rpc.Api.SimulateTransactionSuccessResponse; + const successResp = + simResponse as rpc.Api.SimulateTransactionSuccessResponse; const result = successResp.result?.retval; if (result === undefined) { @@ -177,7 +215,7 @@ export class ContractService { } return { - status: 'SUCCESS', + status: "SUCCESS", value: scValToNative(result) as T, }; } @@ -191,7 +229,10 @@ export class ContractService { ): Promise> { try { if (!this.wallet && !options.simulateOnly) { - throw new TikkaSdkError(TikkaSdkErrorCode.WalletNotInstalled, 'Wallet required'); + throw new TikkaSdkError( + TikkaSdkErrorCode.WalletNotInstalled, + "Wallet required", + ); } const sim = await this.lifecycle.simulate(method, params, { @@ -201,24 +242,29 @@ export class ContractService { }); if (options.simulateOnly) { - return { status: 'SUCCESS', value: sim.returnValue as T, txHash: '', ledger: 0 }; + return { + status: "SUCCESS", + value: sim.returnValue as T, + txHash: "", + ledger: 0, + }; } - const signedXdr = await this.lifecycle.sign(sim.assembledXdr, sim.networkPassphrase); - const txHash = await this.lifecycle.submit(signedXdr); + const signedXdr = await this.lifecycle.sign( + sim.assembledXdr, + sim.networkPassphrase, + ); + const txHash = await this.lifecycle.submit(signedXdr); const polled = await this.lifecycle.poll(txHash, options.poll); return { - status: 'SUCCESS', + status: "SUCCESS", value: polled.returnValue as T, txHash: polled.txHash, ledger: polled.ledger, }; - } catch (error: any) { - return { - status: 'ERROR', - error: error.message || String(error), - }; + } catch (error: unknown) { + throw toTypedSdkError(error); } } @@ -236,18 +282,18 @@ export class ContractService { if (!sourcePublicKey) { throw new TikkaSdkError( TikkaSdkErrorCode.InvalidParams, - 'sourcePublicKey is required for buildUnsigned', + "sourcePublicKey is required for buildUnsigned", ); } - const sim = await this.lifecycle.simulate(method, params, { - sourcePublicKey, - fee: feeOverride ? String(feeOverride) : undefined + const sim = await this.lifecycle.simulate(method, params, { + sourcePublicKey, + fee: feeOverride ? String(feeOverride) : undefined, }); return { - unsignedXdr: sim.assembledXdr, - simulatedResult: { status: 'SUCCESS', value: sim.returnValue as T }, - fee: sim.minResourceFee, + unsignedXdr: sim.assembledXdr, + simulatedResult: { status: "SUCCESS", value: sim.returnValue as T }, + fee: sim.minResourceFee, networkPassphrase: sim.networkPassphrase, }; } @@ -259,13 +305,18 @@ export class ContractService { if (!signedXdr) { throw new TikkaSdkError( TikkaSdkErrorCode.InvalidParams, - 'signedXdr is required for submitSigned', + "signedXdr is required for submitSigned", ); } const txHash = await this.lifecycle.submit(signedXdr); const polled = await this.lifecycle.poll(txHash); - return { status: 'SUCCESS', value: polled.returnValue as T, txHash: polled.txHash, ledger: polled.ledger }; + return { + status: "SUCCESS", + value: polled.returnValue as T, + txHash: polled.txHash, + ledger: polled.ledger, + }; } /* ---------------- BATCH INVOKE ---------------- */ @@ -274,11 +325,11 @@ export class ContractService { raffleId: number, count: number, options: InvokeOptions = {}, - ): Promise> { + ): Promise> { if (!this.wallet && !options.simulateOnly) { throw new TikkaSdkError( TikkaSdkErrorCode.WalletNotInstalled, - 'Wallet required' + "Wallet required", ); } @@ -289,13 +340,13 @@ export class ContractService { if (!sourceKey) { throw new TikkaSdkError( TikkaSdkErrorCode.InvalidParams, - 'Missing source public key' + "Missing source public key", ); } const account = await this.horizon.loadAccount(sourceKey); const contract = new Contract(this.contractId); - + let txBuilder = new TransactionBuilder(account, { fee: options.fee ?? BASE_FEE, networkPassphrase: this.networkConfig.networkPassphrase, @@ -303,21 +354,29 @@ export class ContractService { const params = [raffleId]; for (let i = 0; i < count; i++) { - txBuilder = txBuilder.addOperation(contract.call(ContractFn.BUY_TICKET, ...params.map((p) => this.toScVal(p)))); + txBuilder = txBuilder.addOperation( + contract.call( + ContractFn.BUY_TICKET, + ...params.map((p) => this.toScVal(p)), + ), + ); } - + const tx = txBuilder.setTimeout(30).build(); const simResponse = await this.rpc.simulateTransaction(tx); if (rpc.Api.isSimulationError(simResponse)) { - throw new TikkaSdkError( - TikkaSdkErrorCode.SimulationFailed, - `Batch simulation failed` + const errMsg = (simResponse as any).error ?? ""; + const message = `Batch simulation failed${errMsg ? `: ${errMsg}` : ""}`; + throw ( + toTypedContractError(message, errMsg) ?? + new TikkaSdkError(TikkaSdkErrorCode.SimulationFailed, message, errMsg) ); } - const successSim = simResponse as rpc.Api.SimulateTransactionSuccessResponse; + const successSim = + simResponse as rpc.Api.SimulateTransactionSuccessResponse; const preparedTx = rpc.assembleTransaction(tx, successSim).build(); // With multiple ops, result is typically an array of results, but for now we just handle it generically. @@ -326,34 +385,41 @@ export class ContractService { : []; if (options.simulateOnly) { - return { success: true, value: simResult as any, transactionHash: '', ledger: 0 }; + return { + success: true, + value: simResult as any, + transactionHash: "", + ledger: 0, + }; } const { signedXdr } = await this.wallet!.signTransaction( preparedTx.toXDR(), - { networkPassphrase: this.networkConfig.networkPassphrase } + { networkPassphrase: this.networkConfig.networkPassphrase }, ); const signedTx = TransactionBuilder.fromXDR( signedXdr, - this.networkConfig.networkPassphrase + this.networkConfig.networkPassphrase, ); const sendResp = await this.rpc.sendTransaction(signedTx); - if (sendResp.status === 'ERROR') { + if (sendResp.status === "ERROR") { throw new TikkaSdkError( TikkaSdkErrorCode.SubmissionFailed, - 'Batch submission failed' + "Batch submission failed", ); } const txResp = await this.rpc.getTransaction(sendResp.hash); if (txResp.status === rpc.Api.GetTransactionStatus.FAILED) { - throw new TikkaSdkError( - TikkaSdkErrorCode.ContractError, - 'Batch transaction failed' + const resultXdr = (txResp as any).resultXdr ?? ""; + const message = "Batch transaction failed"; + throw ( + toTypedContractError(message, resultXdr) ?? + new TikkaSdkError(TikkaSdkErrorCode.ContractError, message, resultXdr) ); } @@ -361,7 +427,9 @@ export class ContractService { return { success: true, - value: (successTx.returnValue ? [scValToNative(successTx.returnValue)] : simResult) as any, + value: (successTx.returnValue + ? [scValToNative(successTx.returnValue)] + : simResult) as any, transactionHash: sendResp.hash, ledger: successTx.ledger, }; @@ -371,7 +439,7 @@ export class ContractService { private toScVal(val: any): xdr.ScVal { if (val instanceof xdr.ScVal) return val; - if (typeof val === 'string' && val.length === 56) { + if (typeof val === "string" && val.length === 56) { return new Address(val).toScVal(); } return nativeToScVal(val); diff --git a/sdk/src/contract/lifecycle.ts b/sdk/src/contract/lifecycle.ts index ece53378..ac0573c1 100644 --- a/sdk/src/contract/lifecycle.ts +++ b/sdk/src/contract/lifecycle.ts @@ -25,12 +25,16 @@ import { xdr, scValToNative, Memo, -} from '@stellar/stellar-sdk'; -import { RpcService } from '../network/rpc.service'; -import { HorizonService } from '../network/horizon.service'; -import { NetworkConfig } from '../network/network.config'; -import { WalletAdapter } from '../wallet/wallet.interface'; -import { TikkaSdkError, TikkaSdkErrorCode } from '../utils/errors'; +} from "@stellar/stellar-sdk"; +import { RpcService } from "../network/rpc.service"; +import { HorizonService } from "../network/horizon.service"; +import { NetworkConfig } from "../network/network.config"; +import { WalletAdapter } from "../wallet/wallet.interface"; +import { + TikkaSdkError, + TikkaSdkErrorCode, + toTypedContractError, +} from "../utils/errors"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -39,9 +43,9 @@ import { TikkaSdkError, TikkaSdkErrorCode } from '../utils/errors'; * Mirrors the three Stellar memo types the protocol supports. */ export type TxMemo = - | { type: 'text'; value: string } - | { type: 'id'; value: string } - | { type: 'hash'; value: Buffer }; + | { type: "text"; value: string } + | { type: "id"; value: string } + | { type: "hash"; value: Buffer }; /** Successful simulation result — everything needed to decide whether to sign. */ export interface SimulateResult { @@ -108,7 +112,11 @@ export interface InvokeLifecycleOptions { /** Detects Soroban contract errors in error messages / XDR. */ function isExternalContractFailure(msg: string): boolean { - return msg.includes('HostError') || msg.includes('WASM') || msg.includes('cross-contract'); + return ( + msg.includes("HostError") || + msg.includes("WASM") || + msg.includes("cross-contract") + ); } // ─── Class ─────────────────────────────────────────────────────────────────── @@ -161,18 +169,29 @@ export class TransactionLifecycle { async simulate( method: string, params: any[], - options: Pick = {}, + options: Pick< + InvokeLifecycleOptions, + "sourcePublicKey" | "fee" | "memo" + > = {}, ): Promise> { - const sourceKey = options.sourcePublicKey ?? await this.resolveSourceKey(); - const tx = await this.buildTx(method, params, sourceKey, options.fee, options.memo); + const sourceKey = + options.sourcePublicKey ?? (await this.resolveSourceKey()); + const tx = await this.buildTx( + method, + params, + sourceKey, + options.fee, + options.memo, + ); const simResponse = await this.rpc.simulateTransaction(tx); if (rpc.Api.isSimulationError(simResponse)) { - const errMsg = (simResponse as any).error ?? ''; - throw new TikkaSdkError( - TikkaSdkErrorCode.SimulationFailed, - `Simulation failed for "${method}": ${errMsg}`, + const errMsg = (simResponse as any).error ?? ""; + const message = `Simulation failed for "${method}": ${errMsg}`; + throw ( + toTypedContractError(message, errMsg) ?? + new TikkaSdkError(TikkaSdkErrorCode.SimulationFailed, message, errMsg) ); } @@ -206,26 +225,29 @@ export class TransactionLifecycle { if (!this.wallet) { throw new TikkaSdkError( TikkaSdkErrorCode.WalletNotInstalled, - 'No wallet adapter set — cannot sign the transaction', + "No wallet adapter set — cannot sign the transaction", ); } let signedXdr: string; try { const result = await this.wallet.signTransaction(assembledXdr, { - networkPassphrase: networkPassphrase ?? this.networkConfig.networkPassphrase, + networkPassphrase: + networkPassphrase ?? this.networkConfig.networkPassphrase, }); signedXdr = result.signedXdr; } catch (err: any) { const msg: string = err?.message ?? String(err); const isRejection = - msg.toLowerCase().includes('reject') || - msg.toLowerCase().includes('denied') || - msg.toLowerCase().includes('cancel') || - msg.toLowerCase().includes('user declined'); + msg.toLowerCase().includes("reject") || + msg.toLowerCase().includes("denied") || + msg.toLowerCase().includes("cancel") || + msg.toLowerCase().includes("user declined"); throw new TikkaSdkError( - isRejection ? TikkaSdkErrorCode.UserRejected : TikkaSdkErrorCode.Unknown, + isRejection + ? TikkaSdkErrorCode.UserRejected + : TikkaSdkErrorCode.Unknown, `Wallet sign failed: ${msg}`, err, ); @@ -250,8 +272,8 @@ export class TransactionLifecycle { const sendResp = await this.rpc.sendTransaction(signedTx); - if (sendResp.status === 'ERROR') { - const detail = (sendResp as any).errorResultXdr ?? ''; + if (sendResp.status === "ERROR") { + const detail = (sendResp as any).errorResultXdr ?? ""; throw new TikkaSdkError( TikkaSdkErrorCode.SubmissionFailed, `Transaction submission failed: ${detail}`, @@ -277,9 +299,9 @@ export class TransactionLifecycle { txHash: string, config: PollConfig = {}, ): Promise> { - const timeoutMs = config.timeoutMs ?? 60_000; - const intervalMs = config.intervalMs ?? 2_000; - const backoff = config.backoffFactor ?? 1.5; + const timeoutMs = config.timeoutMs ?? 60_000; + const intervalMs = config.intervalMs ?? 2_000; + const backoff = config.backoffFactor ?? 1.5; const maxInterval = config.maxIntervalMs ?? 10_000; // Requirement 3.10: timeoutMs === 0 must throw immediately without any RPC calls @@ -303,7 +325,8 @@ export class TransactionLifecycle { // Treat NetworkError and Timeout from RpcService as transient — apply backoff and retry if ( err instanceof TikkaSdkError && - (err.code === TikkaSdkErrorCode.NetworkError || err.code === TikkaSdkErrorCode.Timeout) + (err.code === TikkaSdkErrorCode.NetworkError || + err.code === TikkaSdkErrorCode.Timeout) ) { if (Date.now() + currentInterval >= deadline) break; await this.sleep(currentInterval); @@ -322,20 +345,28 @@ export class TransactionLifecycle { : null, txHash, ledger: ok.ledger, - resultXdr: typeof ok.resultXdr?.toXDR === 'function' - ? ok.resultXdr.toXDR('base64') - : String(ok.resultXdr ?? ''), + resultXdr: + typeof ok.resultXdr?.toXDR === "function" + ? ok.resultXdr.toXDR("base64") + : String(ok.resultXdr ?? ""), }; } if (resp.status === rpc.Api.GetTransactionStatus.FAILED) { - const resultXdr = (resp as any).resultXdr ?? ''; - const code = isExternalContractFailure(resultXdr) - ? TikkaSdkErrorCode.ExternalContractError - : TikkaSdkErrorCode.ContractError; - throw new TikkaSdkError( - code, - `Transaction ${txHash} failed on-chain (attempt ${attempts})`, + const resultXdr = (resp as any).resultXdr ?? ""; + const message = `Transaction ${txHash} failed on-chain (attempt ${attempts})`; + + if (isExternalContractFailure(String(resultXdr))) { + throw new TikkaSdkError( + TikkaSdkErrorCode.ExternalContractError, + message, + resultXdr, + ); + } + + throw ( + toTypedContractError(message, resultXdr) ?? + new TikkaSdkError(TikkaSdkErrorCode.ContractError, message, resultXdr) ); } @@ -367,7 +398,7 @@ export class TransactionLifecycle { if (!this.wallet) { throw new TikkaSdkError( TikkaSdkErrorCode.WalletNotInstalled, - 'Wallet required for invoke()', + "Wallet required for invoke()", ); } @@ -387,7 +418,7 @@ export class TransactionLifecycle { // fall through } } - return 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + return "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; } private async buildTx( @@ -397,11 +428,14 @@ export class TransactionLifecycle { fee?: string, memo?: TxMemo, ) { - const account = await this.horizon.loadAccount(sourceKey).catch(() => ({ - accountId: () => sourceKey, - sequenceNumber: () => '0', - incrementSequenceNumber: () => {}, - } as any)); + const account = await this.horizon.loadAccount(sourceKey).catch( + () => + ({ + accountId: () => sourceKey, + sequenceNumber: () => "0", + incrementSequenceNumber: () => {}, + }) as any, + ); let finalFee = fee; if (!finalFee) { @@ -426,15 +460,18 @@ export class TransactionLifecycle { private buildMemo(memo: TxMemo): Memo { switch (memo.type) { - case 'text': return Memo.text(memo.value); - case 'id': return Memo.id(memo.value); - case 'hash': return Memo.hash(memo.value); + case "text": + return Memo.text(memo.value); + case "id": + return Memo.id(memo.value); + case "hash": + return Memo.hash(memo.value); } } private toScVal(val: any): xdr.ScVal { if (val instanceof xdr.ScVal) return val; - if (typeof val === 'string' && val.length === 56) { + if (typeof val === "string" && val.length === 56) { return new Address(val).toScVal(); } return nativeToScVal(val); diff --git a/sdk/src/contract/response.ts b/sdk/src/contract/response.ts index bdc2b8f1..23d3eb7b 100644 --- a/sdk/src/contract/response.ts +++ b/sdk/src/contract/response.ts @@ -22,21 +22,29 @@ * ``` */ export interface ContractResponse { - /** Whether the operation succeeded */ - success: boolean; + /** Legacy boolean success flag used by parts of the SDK. */ + success?: boolean; + /** Legacy string status used by parts of the SDK. */ + status?: "SUCCESS" | "ERROR"; /** The result value on success (undefined if failed) */ value?: T; /** Error message describing what went wrong (undefined if succeeded) */ error?: string; /** Transaction hash if this was a write operation */ transactionHash?: string; + /** Legacy transaction hash alias used by write flows. */ + txHash?: string; /** Ledger number where transaction was confirmed if applicable */ ledger?: number; + /** Fee aliases used by different SDK modules. */ feeCharged?: string; + feePaid?: string; resultXdr?: string; warnings?: string[]; } +export type TxResponse = ContractResponse; + export type TicketTxResponse = TxResponse; export type RaffleTxResponse = TxResponse; export type AdminTxResponse = TxResponse; diff --git a/sdk/src/modules/raffle/raffle.service.ts b/sdk/src/modules/raffle/raffle.service.ts index f6fe88df..1f1b4166 100644 --- a/sdk/src/modules/raffle/raffle.service.ts +++ b/sdk/src/modules/raffle/raffle.service.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@nestjs/common'; -import { ContractService } from '../../contract/contract.service'; -import { ContractFn } from '../../contract/bindings'; +import { Injectable } from "@nestjs/common"; +import { ContractService } from "../../contract/contract.service"; +import { ContractFn } from "../../contract/bindings"; import { RaffleParams, RaffleData, @@ -10,19 +10,26 @@ import { TriggerDrawResult, WinnerResult, RaffleStateError, -} from './raffle.types'; -import { RaffleStatus } from '../../contract/bindings'; -import { ContractResponse } from '../../contract/response'; -import { assertPositiveInt } from '../../utils/validation'; -import { xlmToStroops, assertSafeAmount } from '../../utils/formatting'; -import { nativeToScVal } from '@stellar/stellar-sdk'; + CreateRaffleEstimate, +} from "./raffle.types"; +import { RaffleStatus } from "../../contract/bindings"; +import { + ContractResponse, + RaffleTxResponse, + TxResponse, +} from "../../contract/response"; +import { assertPositiveInt, assertNonEmpty } from "../../utils/validation"; +import { xlmToStroops } from "../../utils/formatting"; +import { nativeToScVal } from "@stellar/stellar-sdk"; +import { FeeEstimatorService } from "../../fee-estimator/fee-estimator.service"; +import { toTypedSdkError } from "../../utils/errors"; /** * Normalises the `asset` field from `RaffleParams` into a plain `AssetDescriptor`. * Accepts either a legacy string code ("XLM") or a structured descriptor. */ function normaliseAsset(asset: string | AssetDescriptor): AssetDescriptor { - if (typeof asset === 'string') return { code: asset }; + if (typeof asset === "string") return { code: asset }; return asset; } @@ -34,12 +41,29 @@ function normaliseAsset(asset: string | AssetDescriptor): AssetDescriptor { */ @Injectable() export class RaffleService { - constructor(private readonly contract: ContractService) {} + constructor( + private readonly contract: ContractService, + private readonly feeEstimator: FeeEstimatorService, + ) {} /* ------------------------------------------------------------------ */ /* create */ /* ------------------------------------------------------------------ */ + /** + * Pre-confirmation fee preview for raffle creation. + * Simulates the transaction via {@link FeeEstimatorService} without submitting. + */ + async estimateCreate(params: RaffleParams): Promise { + const contractParams = this.buildCreateContractParams(params); + const fee = await this.feeEstimator.estimateFee({ + method: ContractFn.CREATE_RAFFLE, + params: contractParams, + }); + + return { xlm: fee.xlm, stroops: fee.stroops }; + } + /** * Creates a new raffle on-chain. * @@ -50,41 +74,39 @@ export class RaffleService { * @returns The on-chain raffle ID, transaction hash, and ledger. */ async create(params: RaffleParams): Promise> { - assertNonEmpty(params.ticketPrice, 'ticketPrice'); - assertPositiveInt(params.maxTickets, 'maxTickets'); + assertNonEmpty(params.ticketPrice, "ticketPrice"); + assertPositiveInt(params.maxTickets, "maxTickets"); - const asset = normaliseAsset(params.asset); + const contractParams = this.buildCreateContractParams(params); - const contractParams = [ - nativeToScVal( - { - ticket_price: BigInt(xlmToStroops(params.ticketPrice)), - max_tickets: params.maxTickets, - end_time: BigInt(Math.floor(params.endTime / 1000)), // contract expects seconds - allow_multiple: params.allowMultiple, - asset: asset.code, - asset_issuer: asset.issuer ?? '', - metadata_cid: params.metadataCid ?? '', - }, - { - type: { - ticket_price: ['symbol', 'i128'], - max_tickets: ['symbol', 'u32'], - end_time: ['symbol', 'u64'], - allow_multiple: ['symbol', 'bool'], - asset: ['symbol', 'string'], - asset_issuer: ['symbol', 'string'], - metadata_cid: ['symbol', 'string'], - } as any, - }, - ), - ]; + try { + const sim = await this.contract.simulate( + ContractFn.CREATE_RAFFLE, + contractParams, + { memo: params.memo }, + ); - return await this.contract.invoke( - ContractFn.CREATE_RAFFLE, - contractParams, - { memo: params.memo }, - ); + const feeEstimate = this.feeEstimator.estimateFromResourceFee( + sim.minResourceFee, + ); + + const signedXdr = await this.contract.sign( + sim.assembledXdr, + sim.networkPassphrase, + ); + const txHash = await this.contract.submit(signedXdr); + const polled = await this.contract.poll(txHash); + + return { + status: "SUCCESS", + value: polled.returnValue as number, + txHash: polled.txHash, + ledger: polled.ledger, + feeCharged: feeEstimate.stroops, + }; + } catch (error: unknown) { + throw toTypedSdkError(error); + } } /* ------------------------------------------------------------------ */ @@ -95,17 +117,17 @@ export class RaffleService { * Fetches on-chain data for a single raffle (read-only). */ async get(raffleId: number): Promise> { - assertPositiveInt(raffleId, 'raffleId'); + assertPositiveInt(raffleId, "raffleId"); const res = await this.contract.simulateReadOnly( ContractFn.GET_RAFFLE_DATA, [raffleId], ); - if (res.status !== 'SUCCESS') return res as any; - + if (res.status !== "SUCCESS") return res as any; + return { - status: 'SUCCESS', + status: "SUCCESS", value: this.mapRaffleData(raffleId, res.value), }; } @@ -147,11 +169,15 @@ export class RaffleService { * Throws `RaffleStateError` if the raffle is not in the Open state. */ async cancel(params: CancelRaffleParams): Promise> { - assertPositiveInt(params.raffleId, 'raffleId'); + assertPositiveInt(params.raffleId, "raffleId"); const current = await this.get(params.raffleId); if (current.success && current.value!.status !== RaffleStatus.Open) { - throw new RaffleStateError(params.raffleId, current.value!.status, 'open→cancelled'); + throw new RaffleStateError( + params.raffleId, + current.value!.status, + "open→cancelled", + ); } return await this.contract.invoke( @@ -172,12 +198,18 @@ export class RaffleService { * Requires the caller to be authorised (oracle or protocol admin). * Throws `RaffleStateError` if the raffle is not currently Open. */ - async triggerDraw(params: TriggerDrawParams): Promise> { - assertPositiveInt(params.raffleId, 'raffleId'); + async triggerDraw( + params: TriggerDrawParams, + ): Promise> { + assertPositiveInt(params.raffleId, "raffleId"); const current = await this.get(params.raffleId); if (current.success && current.value!.status !== RaffleStatus.Open) { - throw new RaffleStateError(params.raffleId, current.value!.status, 'open→drawing'); + throw new RaffleStateError( + params.raffleId, + current.value!.status, + "open→drawing", + ); } return await this.contract.invoke( @@ -197,8 +229,10 @@ export class RaffleService { * * Source of truth: contract RPC (`get_raffle_data`). */ - async getWinner(raffleId: number): Promise> { - assertPositiveInt(raffleId, 'raffleId'); + async getWinner( + raffleId: number, + ): Promise> { + assertPositiveInt(raffleId, "raffleId"); const res = await this.get(raffleId); if (!res.success) return res as any; @@ -214,7 +248,7 @@ export class RaffleService { raffleId, winner: data.winner, winningTicketId: data.winningTicketId!, - prizeAmount: data.prizeAmount ?? '0', + prizeAmount: data.prizeAmount ?? "0", }, }; } @@ -223,22 +257,55 @@ export class RaffleService { /* Private helpers */ /* ------------------------------------------------------------------ */ + private buildCreateContractParams(params: RaffleParams): any[] { + assertNonEmpty(params.ticketPrice, "ticketPrice"); + assertPositiveInt(params.maxTickets, "maxTickets"); + + const asset = normaliseAsset(params.asset); + + return [ + nativeToScVal( + { + ticket_price: BigInt(xlmToStroops(params.ticketPrice)), + max_tickets: params.maxTickets, + end_time: BigInt(Math.floor(params.endTime / 1000)), // contract expects seconds + allow_multiple: params.allowMultiple, + asset: asset.code, + asset_issuer: asset.issuer ?? "", + metadata_cid: params.metadataCid ?? "", + }, + { + type: { + ticket_price: ["symbol", "i128"], + max_tickets: ["symbol", "u32"], + end_time: ["symbol", "u64"], + allow_multiple: ["symbol", "bool"], + asset: ["symbol", "string"], + asset_issuer: ["symbol", "string"], + metadata_cid: ["symbol", "string"], + } as any, + }, + ), + ]; + } + private mapRaffleData(raffleId: number, raw: any): RaffleData { return { raffleId, - creator: raw.creator ?? raw.Creator ?? '', + creator: raw.creator ?? raw.Creator ?? "", status: raw.status ?? raw.Status ?? 0, - ticketPrice: String(raw.ticket_price ?? raw.ticketPrice ?? '0'), + ticketPrice: String(raw.ticket_price ?? raw.ticketPrice ?? "0"), maxTickets: Number(raw.max_tickets ?? raw.maxTickets ?? 0), ticketsSold: Number(raw.tickets_sold ?? raw.ticketsSold ?? 0), endTime: Number(raw.end_time ?? raw.endTime ?? 0) * 1000, // back to ms - asset: raw.asset ?? 'XLM', + asset: raw.asset ?? "XLM", assetIssuer: raw.asset_issuer || raw.assetIssuer || undefined, allowMultiple: Boolean(raw.allow_multiple ?? raw.allowMultiple), - metadataCid: raw.metadata_cid ?? raw.metadataCid ?? '', + metadataCid: raw.metadata_cid ?? raw.metadataCid ?? "", winner: raw.winner, winningTicketId: raw.winning_ticket_id ?? raw.winningTicketId, - prizeAmount: raw.prize_amount != null ? String(raw.prize_amount) : undefined, + prizeAmount: + raw.prize_amount != null ? String(raw.prize_amount) : undefined, }; } } diff --git a/sdk/src/modules/ticket/ticket.service.ts b/sdk/src/modules/ticket/ticket.service.ts index 585a0e1c..c8b8fc8f 100644 --- a/sdk/src/modules/ticket/ticket.service.ts +++ b/sdk/src/modules/ticket/ticket.service.ts @@ -1,17 +1,24 @@ -import { Injectable } from '@nestjs/common'; -import { ContractService } from '../../contract/contract.service'; -import { ContractFn } from '../../contract/bindings'; +import { Injectable } from "@nestjs/common"; +import { ContractService } from "../../contract/contract.service"; +import { ContractFn } from "../../contract/bindings"; import { BuyTicketParams, + BuyTicketResult, RefundTicketParams, + RefundTicketResult, GetUserTicketsParams, BuyBatchParams, + BuyBatchResult, BatchPurchaseResult, TICKET_CONSTRAINTS, -} from './ticket.types'; -import { TicketTxResponse, TxResponse } from '../../contract/response'; -import { assertPositiveInt } from '../../utils/validation'; -import { TikkaSdkError, TikkaSdkErrorCode } from '../../utils/errors'; +} from "./ticket.types"; +import { ContractResponse } from "../../contract/response"; +import { assertPositiveInt } from "../../utils/validation"; +import { + TikkaSdkError, + TikkaSdkErrorCode, + toTypedSdkError, +} from "../../utils/errors"; @Injectable() export class TicketService { @@ -23,7 +30,7 @@ export class TicketService { * Validates ticket purchase quantity constraints. * @throws TikkaSdkError if quantity is invalid */ - private validateQuantity(quantity: number, fieldName = 'quantity'): void { + private validateQuantity(quantity: number, fieldName = "quantity"): void { if (!Number.isInteger(quantity)) { throw new TikkaSdkError( TikkaSdkErrorCode.InvalidParams, @@ -87,16 +94,18 @@ export class TicketService { * * @throws TikkaSdkError if validation fails or submission is duplicate */ - async buy(params: BuyTicketParams): Promise> { + async buy( + params: BuyTicketParams, + ): Promise> { const { raffleId, quantity } = params; - assertPositiveInt(raffleId, 'raffleId'); + assertPositiveInt(raffleId, "raffleId"); this.validateQuantity(quantity); - const publicKey = await this.contractService['wallet']?.getPublicKey(); + const publicKey = await this.contractService["wallet"]?.getPublicKey(); if (!publicKey) { throw new TikkaSdkError( TikkaSdkErrorCode.WalletNotInstalled, - 'Wallet required for ticket purchase', + "Wallet required for ticket purchase", ); } @@ -115,9 +124,9 @@ export class TicketService { success: result.success, value: { ticketIds: result.value || [], - transactionHash: result.transactionHash || '', + transactionHash: result.transactionHash || "", ledger: result.ledger || 0, - feePaid: result.feePaid || '0', + feePaid: result.feePaid || "0", } as BuyTicketResult, transactionHash: result.transactionHash, ledger: result.ledger, @@ -134,7 +143,7 @@ export class TicketService { err, ); } - throw err; + throw toTypedSdkError(err); } } @@ -150,8 +159,8 @@ export class TicketService { params: RefundTicketParams, ): Promise> { const { raffleId, ticketId } = params; - assertPositiveInt(raffleId, 'raffleId'); - assertPositiveInt(ticketId, 'ticketId'); + assertPositiveInt(raffleId, "raffleId"); + assertPositiveInt(ticketId, "ticketId"); try { const result = await this.contractService.invoke( @@ -164,9 +173,9 @@ export class TicketService { return { success: result.success, value: { - transactionHash: result.transactionHash || '', + transactionHash: result.transactionHash || "", ledger: result.ledger || 0, - feePaid: result.feePaid || '0', + feePaid: result.feePaid || "0", } as RefundTicketResult, transactionHash: result.transactionHash, ledger: result.ledger, @@ -183,7 +192,7 @@ export class TicketService { err, ); } - throw err; + throw toTypedSdkError(err); } } @@ -197,12 +206,12 @@ export class TicketService { params: GetUserTicketsParams, ): Promise> { const { raffleId, userAddress } = params; - assertPositiveInt(raffleId, 'raffleId'); + assertPositiveInt(raffleId, "raffleId"); - if (!userAddress || typeof userAddress !== 'string') { + if (!userAddress || typeof userAddress !== "string") { throw new TikkaSdkError( TikkaSdkErrorCode.InvalidParams, - 'userAddress must be a non-empty string', + "userAddress must be a non-empty string", ); } @@ -214,20 +223,20 @@ export class TicketService { /** * Purchases tickets for multiple raffles in a single operation. - * + * * This method handles batch ticket purchases with individual validation for each raffle. * Returns individual success/failure results for each purchase, allowing partial failures. - * + * * Constraints: * - Maximum 100 purchases per batch * - Each purchase quantity: 1-1000 tickets * - Follows same duplicate submission detection as single purchase - * + * * @param params - Batch purchase parameters containing array of raffle purchases * @returns Individual results for each raffle purchase attempt - * + * * @throws {TikkaSdkError} If validation fails or all purchases fail - * + * * @example * ```typescript * const result = await ticketService.buyBatch({ @@ -239,14 +248,16 @@ export class TicketService { * }); * ``` */ - async buyBatch(params: BuyBatchParams): Promise> { + async buyBatch( + params: BuyBatchParams, + ): Promise> { const { purchases, memo } = params; // Validate inputs if (!purchases || purchases.length === 0) { throw new TikkaSdkError( TikkaSdkErrorCode.InvalidParams, - 'Purchases array cannot be empty', + "Purchases array cannot be empty", ); } @@ -261,7 +272,10 @@ export class TicketService { purchases.forEach((purchase, index) => { try { assertPositiveInt(purchase.raffleId, `purchases[${index}].raffleId`); - this.validateQuantity(purchase.quantity, `purchases[${index}].quantity`); + this.validateQuantity( + purchase.quantity, + `purchases[${index}].quantity`, + ); } catch (err) { throw new TikkaSdkError( TikkaSdkErrorCode.InvalidParams, @@ -270,48 +284,48 @@ export class TicketService { } }); - const publicKey = await this.contractService['wallet']?.getPublicKey(); + const publicKey = await this.contractService["wallet"]?.getPublicKey(); if (!publicKey) { throw new TikkaSdkError( TikkaSdkErrorCode.WalletNotInstalled, - 'Wallet required for batch purchase', + "Wallet required for batch purchase", ); } // Simulate each purchase individually to check feasibility const simulationResults: BatchPurchaseResult[] = []; - + for (const purchase of purchases) { try { await this.contractService.simulateReadOnly( ContractFn.BUY_TICKET, [purchase.raffleId, publicKey, purchase.quantity], ); - + simulationResults.push({ raffleId: purchase.raffleId, ticketIds: [], - status: 'SUCCESS', + success: true, }); } catch (err) { simulationResults.push({ raffleId: purchase.raffleId, ticketIds: [], - status: 'ERROR', + success: false, error: err instanceof Error ? err.message : String(err), }); } } // Filter out failed simulations - const validPurchases = purchases.filter((_, index) => - simulationResults[index].status === 'SUCCESS' + const validPurchases = purchases.filter( + (_, index) => simulationResults[index].success, ); if (validPurchases.length === 0) { throw new TikkaSdkError( TikkaSdkErrorCode.SimulationFailed, - 'All batch purchases failed simulation', + "All batch purchases failed simulation", ); } @@ -319,14 +333,18 @@ export class TicketService { // Note: Soroban doesn't support true atomic multi-call in a single tx, // so we execute them individually but track results together const results: BatchPurchaseResult[] = []; - let lastTxHash = ''; + let lastTxHash = ""; let lastLedger = 0; let totalFeeLamports = 0; for (const purchase of validPurchases) { try { // Check for duplicate before executing - this.checkDuplicateSubmission(purchase.raffleId, purchase.quantity, publicKey); + this.checkDuplicateSubmission( + purchase.raffleId, + purchase.quantity, + publicKey, + ); const res = await this.contractService.invoke( ContractFn.BUY_TICKET, @@ -337,13 +355,13 @@ export class TicketService { results.push({ raffleId: purchase.raffleId, ticketIds: res.value || [], - status: 'SUCCESS', + success: true, }); - lastTxHash = res.txHash || ''; + lastTxHash = res.txHash || ""; lastLedger = res.ledger || 0; // Accumulate fees as integers (stroops) to avoid floating point issues - const feeLamports = parseInt(res.feePaid || '0', 10); + const feeLamports = parseInt(res.feePaid || "0", 10); totalFeeLamports += feeLamports; } catch (err) { results.push({ @@ -365,7 +383,7 @@ export class TicketService { let validIndex = 0; for (let i = 0; i < purchases.length; i++) { - if (simulationResults[i].status === 'SUCCESS') { + if (simulationResults[i].success) { finalResults.push(results[validIndex]); validIndex++; } else { diff --git a/sdk/src/utils/errors.spec.ts b/sdk/src/utils/errors.spec.ts index be9bfd4e..5eca4850 100644 --- a/sdk/src/utils/errors.spec.ts +++ b/sdk/src/utils/errors.spec.ts @@ -1,84 +1,170 @@ -import { TikkaSdkError, TikkaSdkErrorCode, RpcError } from './errors'; +import { + ContractErrorType, + InsufficientFundsError, + RaffleEndedError, + RaffleFullError, + RaffleNotFoundError, + RpcError, + TikkaSdkError, + TikkaSdkErrorCode, + UnauthorizedError, + parseSorobanContractErrorCode, + toTypedContractError, + toTypedSdkError, +} from "./errors"; -describe('TikkaSdkError', () => { - it('sets name, code, and message', () => { - const err = new TikkaSdkError(TikkaSdkErrorCode.UserRejected, 'user said no'); - expect(err.name).toBe('TikkaSdkError'); +describe("TikkaSdkError", () => { + it("sets name, code, and message", () => { + const err = new TikkaSdkError( + TikkaSdkErrorCode.UserRejected, + "user said no", + ); + expect(err.name).toBe("TikkaSdkError"); expect(err.code).toBe(TikkaSdkErrorCode.UserRejected); - expect(err.message).toBe('user said no'); + expect(err.message).toBe("user said no"); }); - it('is an instance of Error', () => { - const err = new TikkaSdkError(TikkaSdkErrorCode.Timeout, 'timed out'); + it("is an instance of Error", () => { + const err = new TikkaSdkError(TikkaSdkErrorCode.Timeout, "timed out"); expect(err).toBeInstanceOf(Error); expect(err).toBeInstanceOf(TikkaSdkError); }); - it('stores optional cause', () => { - const cause = new Error('root cause'); - const err = new TikkaSdkError(TikkaSdkErrorCode.Unknown, 'wrapped', cause); + it("stores optional cause", () => { + const cause = new Error("root cause"); + const err = new TikkaSdkError(TikkaSdkErrorCode.Unknown, "wrapped", cause); expect(err.cause).toBe(cause); }); - describe('wrap()', () => { - it('returns the same TikkaSdkError unchanged', () => { - const original = new TikkaSdkError(TikkaSdkErrorCode.SimulationFailed, 'sim failed'); + describe("wrap()", () => { + it("returns the same TikkaSdkError unchanged", () => { + const original = new TikkaSdkError( + TikkaSdkErrorCode.SimulationFailed, + "sim failed", + ); expect(TikkaSdkError.wrap(original)).toBe(original); }); - it('wraps a plain Error with default code Unknown', () => { - const plain = new Error('something broke'); + it("wraps a plain Error with default code Unknown", () => { + const plain = new Error("something broke"); const wrapped = TikkaSdkError.wrap(plain); expect(wrapped).toBeInstanceOf(TikkaSdkError); expect(wrapped.code).toBe(TikkaSdkErrorCode.Unknown); - expect(wrapped.message).toBe('something broke'); + expect(wrapped.message).toBe("something broke"); }); - it('wraps a plain Error with a custom code', () => { - const plain = new Error('rpc down'); + it("wraps a plain Error with a custom code", () => { + const plain = new Error("rpc down"); const wrapped = TikkaSdkError.wrap(plain, TikkaSdkErrorCode.NetworkError); expect(wrapped.code).toBe(TikkaSdkErrorCode.NetworkError); }); - it('wraps a non-Error value (string)', () => { - const wrapped = TikkaSdkError.wrap('oops'); - expect(wrapped.message).toBe('oops'); + it("wraps a non-Error value (string)", () => { + const wrapped = TikkaSdkError.wrap("oops"); + expect(wrapped.message).toBe("oops"); expect(wrapped.code).toBe(TikkaSdkErrorCode.Unknown); }); }); }); -describe('RpcError', () => { - it('sets name, message, endpoint, method, and statusCode', () => { - const err = new RpcError('bad request', 'https://rpc.example.com', 'sendTransaction', 400); - expect(err.name).toBe('RpcError'); - expect(err.message).toBe('bad request'); - expect(err.endpoint).toBe('https://rpc.example.com'); - expect(err.method).toBe('sendTransaction'); +describe("typed contract errors", () => { + it.each([ + [RaffleNotFoundError, ContractErrorType.RAFFLE_NOT_FOUND, "raffle missing"], + [RaffleEndedError, ContractErrorType.RAFFLE_ENDED, "raffle ended"], + [RaffleFullError, ContractErrorType.RAFFLE_FULL, "raffle full"], + [ + InsufficientFundsError, + ContractErrorType.INSUFFICIENT_FUNDS, + "low balance", + ], + [UnauthorizedError, ContractErrorType.UNAUTHORIZED, "no access"], + ])( + "creates %p with the expected contract code", + (ErrorType, code, message) => { + const err = new ErrorType(message); + expect(err).toBeInstanceOf(TikkaSdkError); + expect(err).toBeInstanceOf(ErrorType); + expect(err.code).toBe(code); + expect(err.message).toBe(message); + }, + ); + + it("parses Soroban contract error codes from known formats", () => { + expect(parseSorobanContractErrorCode("Error(Contract, #35)")).toBe(35); + expect(parseSorobanContractErrorCode("ScError::Contract(4)")).toBe(4); + expect(parseSorobanContractErrorCode("contract error code 5")).toBe(5); + }); + + it("maps known Soroban contract codes to typed errors", () => { + expect( + toTypedContractError("failed", "Error(Contract, #1)"), + ).toBeInstanceOf(RaffleNotFoundError); + expect( + toTypedContractError("failed", "Error(Contract, #3)"), + ).toBeInstanceOf(RaffleFullError); + expect( + toTypedContractError("failed", "Error(Contract, #4)"), + ).toBeInstanceOf(InsufficientFundsError); + expect( + toTypedContractError("failed", "Error(Contract, #5)"), + ).toBeInstanceOf(UnauthorizedError); + expect( + toTypedContractError("failed", "Error(Contract, #35)"), + ).toBeInstanceOf(RaffleEndedError); + }); + + it("wraps generic TikkaSdkError contract failures into typed errors when possible", () => { + const wrapped = toTypedSdkError( + new TikkaSdkError( + TikkaSdkErrorCode.ContractError, + "buy failed", + "Error(Contract, #35)", + ), + ); + expect(wrapped).toBeInstanceOf(RaffleEndedError); + }); +}); + +describe("RpcError", () => { + it("sets name, message, endpoint, method, and statusCode", () => { + const err = new RpcError( + "bad request", + "https://rpc.example.com", + "sendTransaction", + 400, + ); + expect(err.name).toBe("RpcError"); + expect(err.message).toBe("bad request"); + expect(err.endpoint).toBe("https://rpc.example.com"); + expect(err.method).toBe("sendTransaction"); expect(err.statusCode).toBe(400); }); - it('is an instance of Error', () => { - const err = new RpcError('fail', 'https://rpc.example.com'); + it("is an instance of Error", () => { + const err = new RpcError("fail", "https://rpc.example.com"); expect(err).toBeInstanceOf(Error); expect(err).toBeInstanceOf(RpcError); }); - describe('fromResponse()', () => { - it('builds an RpcError from a response object', () => { + describe("fromResponse()", () => { + it("builds an RpcError from a response object", () => { const err = RpcError.fromResponse( - 'https://rpc.example.com', - 'simulateTransaction', - { status: 503, statusText: 'Service Unavailable' }, + "https://rpc.example.com", + "simulateTransaction", + { status: 503, statusText: "Service Unavailable" }, ); expect(err).toBeInstanceOf(RpcError); expect(err.statusCode).toBe(503); - expect(err.message).toContain('Service Unavailable'); + expect(err.message).toContain("Service Unavailable"); }); it('falls back to "Unknown Error" when statusText is missing', () => { - const err = RpcError.fromResponse('https://rpc.example.com', 'getTransaction', {}); - expect(err.message).toContain('Unknown Error'); + const err = RpcError.fromResponse( + "https://rpc.example.com", + "getTransaction", + {}, + ); + expect(err.message).toContain("Unknown Error"); }); }); }); diff --git a/sdk/src/utils/errors.ts b/sdk/src/utils/errors.ts index 530b2519..93e36165 100644 --- a/sdk/src/utils/errors.ts +++ b/sdk/src/utils/errors.ts @@ -1,154 +1,297 @@ -/** - * Low-level RPC error (transport / HTTP / JSON-RPC failures) - */ -export class RpcError extends Error { - constructor( - message: string, - public readonly endpoint: string, - public readonly method?: string, - public readonly statusCode?: number, - public readonly response?: any, - ) { - super(message); - this.name = 'RpcError'; - Object.setPrototypeOf(this, RpcError.prototype); - } - - static fromResponse( - endpoint: string, - method: string, - response: any, - payload?: any, - ): RpcError { - return new RpcError( - `RPC request failed: ${response.statusText || 'Unknown Error'}`, - endpoint, - method, - response.status, - payload, - ); - } -} - -/** - * SDK-wide error codes exactly as required by Issue #154 - */ -export enum TikkaSdkErrorCode { - /** Wallet extension not installed */ - WalletNotInstalled = 'WALLET_NOT_INSTALLED', - /** User rejected the transaction / signature request */ - UserRejected = 'UserRejected', - /** Transaction simulation failed */ - SimulationFailed = 'SimulationFailed', - /** Transaction submission failed */ - SubmissionFailed = 'SUBMISSION_FAILED', - /** Invalid parameters supplied (General) */ - InvalidParams = 'INVALID_PARAMS', - /** Contract returned an error */ - ContractError = 'CONTRACT_ERROR', - /** Network / RPC unreachable */ - NetworkError = 'NetworkError', - /** Timeout while waiting for confirmation */ - Timeout = 'TIMEOUT', - /** Rate limit exceeded */ - RateLimit = 'RATE_LIMIT', - /** Service unavailable */ - Unavailable = 'UNAVAILABLE', - /** Invalid response format or payload */ - InvalidResponse = 'INVALID_RESPONSE', - /** Contract execution failed */ - ContractFailure = 'CONTRACT_FAILURE', - /** Unknown / catch-all */ - Unknown = 'UNKNOWN', - /** Contract is paused — write operations blocked */ - ContractPaused = 'CONTRACT_PAUSED', - /** Caller is not authorized for this operation */ - Unauthorized = 'UNAUTHORIZED', - /** Validation failed for input parameters (raffleId, quantity, etc.) */ - ValidationError = 'ValidationError', - /** An external/cross-contract call (e.g. SEP-41 token) failed */ - ExternalContractError = 'EXTERNAL_CONTRACT_ERROR', -} - -/** - * Structured SDK error (high-level, used across SDK) - * Allows consumers to handle failures predictably. - */ -export class TikkaSdkError extends Error { - constructor( - public readonly code: TikkaSdkErrorCode, - message: string, - public readonly cause?: unknown, - ) { - super(message); - this.name = 'TikkaSdkError'; - // Essential for custom errors in TypeScript to maintain prototype chain - Object.setPrototypeOf(this, TikkaSdkError.prototype); - } - - /** - * Static helper to wrap unknown errors into TikkaSdkError. - * Useful in service-level catch blocks. - */ - static wrap(error: unknown, defaultCode: TikkaSdkErrorCode = TikkaSdkErrorCode.Unknown): TikkaSdkError { - if (error instanceof TikkaSdkError) return error; - - const message = error instanceof Error ? error.message : String(error); - return new TikkaSdkError(defaultCode, message, error); - } -} - -/** - * Thrown when an RPC request times out. - */ -export class RpcTimeoutError extends TikkaSdkError { - constructor(message: string, cause?: unknown) { - super(TikkaSdkErrorCode.Timeout, message, cause); - this.name = 'RpcTimeoutError'; - Object.setPrototypeOf(this, RpcTimeoutError.prototype); - } -} - -/** - * Thrown when the RPC node returns a 429 Rate Limit status. - */ -export class RateLimitError extends TikkaSdkError { - constructor(message: string, cause?: unknown) { - super(TikkaSdkErrorCode.RateLimit, message, cause); - this.name = 'RateLimitError'; - Object.setPrototypeOf(this, RateLimitError.prototype); - } -} - -/** - * Thrown when the RPC node or transport is unavailable (502, 503, 504, or network issues). - */ -export class UnavailableError extends TikkaSdkError { - constructor(message: string, cause?: unknown) { - super(TikkaSdkErrorCode.Unavailable, message, cause); - this.name = 'UnavailableError'; - Object.setPrototypeOf(this, UnavailableError.prototype); - } -} - -/** - * Thrown when the response format is invalid or cannot be parsed. - */ -export class InvalidResponseError extends TikkaSdkError { - constructor(message: string, cause?: unknown) { - super(TikkaSdkErrorCode.InvalidResponse, message, cause); - this.name = 'InvalidResponseError'; - Object.setPrototypeOf(this, InvalidResponseError.prototype); - } -} - -/** - * Thrown when a contract invocation or simulation fails due to a smart contract-specific failure. - */ -export class ContractFailureError extends TikkaSdkError { - constructor(message: string, cause?: unknown) { - super(TikkaSdkErrorCode.ContractFailure, message, cause); - this.name = 'ContractFailureError'; - Object.setPrototypeOf(this, ContractFailureError.prototype); - } -} \ No newline at end of file +/** + * Low-level RPC error (transport / HTTP / JSON-RPC failures) + */ +export class RpcError extends Error { + constructor( + message: string, + public readonly endpoint: string, + public readonly method?: string, + public readonly statusCode?: number, + public readonly response?: any, + ) { + super(message); + this.name = "RpcError"; + Object.setPrototypeOf(this, RpcError.prototype); + } + + static fromResponse( + endpoint: string, + method: string, + response: any, + payload?: any, + ): RpcError { + return new RpcError( + `RPC request failed: ${response.statusText || "Unknown Error"}`, + endpoint, + method, + response.status, + payload, + ); + } +} + +export const ContractErrorType = { + NETWORK_ERROR: "NETWORK_ERROR", + CONTRACT_ERROR: "CONTRACT_ERROR", + WALLET_ERROR: "WALLET_ERROR", + VALIDATION_ERROR: "VALIDATION_ERROR", + INSUFFICIENT_FUNDS: "INSUFFICIENT_FUNDS", + RAFFLE_NOT_FOUND: "RAFFLE_NOT_FOUND", + RAFFLE_ENDED: "RAFFLE_ENDED", + RAFFLE_FULL: "RAFFLE_FULL", + UNAUTHORIZED: "UNAUTHORIZED", + CONTRACT_PAUSED: "CONTRACT_PAUSED", + UNKNOWN_ERROR: "UNKNOWN_ERROR", +} as const; + +export type ContractErrorType = + (typeof ContractErrorType)[keyof typeof ContractErrorType]; +export type SdkErrorCode = TikkaSdkErrorCode | ContractErrorType; + +/** + * SDK-wide error codes exactly as required by Issue #154 + */ +export enum TikkaSdkErrorCode { + /** Wallet extension not installed */ + WalletNotInstalled = "WALLET_NOT_INSTALLED", + /** User rejected the transaction / signature request */ + UserRejected = "UserRejected", + /** Transaction simulation failed */ + SimulationFailed = "SimulationFailed", + /** Transaction submission failed */ + SubmissionFailed = "SUBMISSION_FAILED", + /** Invalid parameters supplied (General) */ + InvalidParams = "INVALID_PARAMS", + /** Contract returned an error */ + ContractError = "CONTRACT_ERROR", + /** Network / RPC unreachable */ + NetworkError = "NetworkError", + /** Timeout while waiting for confirmation */ + Timeout = "TIMEOUT", + /** Rate limit exceeded */ + RateLimit = "RATE_LIMIT", + /** Service unavailable */ + Unavailable = "UNAVAILABLE", + /** Invalid response format or payload */ + InvalidResponse = "INVALID_RESPONSE", + /** Contract execution failed */ + ContractFailure = "CONTRACT_FAILURE", + /** Unknown / catch-all */ + Unknown = "UNKNOWN", + /** Contract is paused — write operations blocked */ + ContractPaused = "CONTRACT_PAUSED", + /** Caller is not authorized for this operation */ + Unauthorized = "UNAUTHORIZED", + /** Validation failed for input parameters (raffleId, quantity, etc.) */ + ValidationError = "ValidationError", + /** An external/cross-contract call (e.g. SEP-41 token) failed */ + ExternalContractError = "EXTERNAL_CONTRACT_ERROR", +} + +/** + * Structured SDK error (high-level, used across SDK) + * Allows consumers to handle failures predictably. + */ +export class TikkaSdkError extends Error { + constructor( + public readonly code: SdkErrorCode, + message: string, + public readonly cause?: unknown, + ) { + super(message); + this.name = "TikkaSdkError"; + // Essential for custom errors in TypeScript to maintain prototype chain + Object.setPrototypeOf(this, TikkaSdkError.prototype); + } + + /** + * Static helper to wrap unknown errors into TikkaSdkError. + * Useful in service-level catch blocks. + */ + static wrap( + error: unknown, + defaultCode: SdkErrorCode = TikkaSdkErrorCode.Unknown, + ): TikkaSdkError { + return toTypedSdkError(error, defaultCode); + } +} + +abstract class ContractSdkError extends TikkaSdkError { + constructor(code: ContractErrorType, message: string, cause?: unknown) { + super(code, message, cause); + this.name = new.target.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class RaffleNotFoundError extends ContractSdkError { + constructor(message = "Raffle not found", cause?: unknown) { + super(ContractErrorType.RAFFLE_NOT_FOUND, message, cause); + } +} + +export class RaffleEndedError extends ContractSdkError { + constructor(message = "Raffle has ended", cause?: unknown) { + super(ContractErrorType.RAFFLE_ENDED, message, cause); + } +} + +export class RaffleFullError extends ContractSdkError { + constructor(message = "Raffle is full", cause?: unknown) { + super(ContractErrorType.RAFFLE_FULL, message, cause); + } +} + +export class InsufficientFundsError extends ContractSdkError { + constructor(message = "Insufficient funds", cause?: unknown) { + super(ContractErrorType.INSUFFICIENT_FUNDS, message, cause); + } +} + +export class UnauthorizedError extends ContractSdkError { + constructor(message = "Unauthorized", cause?: unknown) { + super(ContractErrorType.UNAUTHORIZED, message, cause); + } +} + +const CONTRACT_ERROR_FACTORIES: Record< + number, + (message: string, cause?: unknown) => TikkaSdkError +> = { + 1: (message, cause) => new RaffleNotFoundError(message, cause), + 3: (message, cause) => new RaffleFullError(message, cause), + 4: (message, cause) => new InsufficientFundsError(message, cause), + 5: (message, cause) => new UnauthorizedError(message, cause), + 35: (message, cause) => new RaffleEndedError(message, cause), +}; + +function stringifyErrorSource(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string") return value; + if (value instanceof Error) { + return `${value.message} ${stringifyErrorSource((value as any).cause)}`.trim(); + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function parseSorobanContractErrorCode(value: unknown): number | null { + const source = stringifyErrorSource(value); + const patterns = [ + /Error\(Contract,\s*#?(\d+)\)/i, + /Contract\((\d+)\)/i, + /contract(?:\s+error)?(?:\s+code)?[^\d]{0,10}(\d+)/i, + ]; + + for (const pattern of patterns) { + const match = source.match(pattern); + if (match) { + return Number(match[1]); + } + } + + return null; +} + +export function toTypedContractError( + message: string, + source?: unknown, +): TikkaSdkError | null { + const contractErrorCode = parseSorobanContractErrorCode(source ?? message); + if (contractErrorCode == null) { + return null; + } + + const factory = CONTRACT_ERROR_FACTORIES[contractErrorCode]; + if (!factory) { + return new TikkaSdkError(TikkaSdkErrorCode.ContractError, message, source); + } + + return factory(message, source); +} + +export function toTypedSdkError( + error: unknown, + defaultCode: SdkErrorCode = TikkaSdkErrorCode.Unknown, +): TikkaSdkError { + if ( + error instanceof RaffleNotFoundError || + error instanceof RaffleEndedError || + error instanceof RaffleFullError || + error instanceof InsufficientFundsError || + error instanceof UnauthorizedError + ) { + return error; + } + + if (error instanceof TikkaSdkError) { + const typed = toTypedContractError( + error.message, + error.cause ?? error.message, + ); + return typed ?? error; + } + + const message = error instanceof Error ? error.message : String(error); + const typed = toTypedContractError(message, error); + return typed ?? new TikkaSdkError(defaultCode, message, error); +} + +/** + * Thrown when an RPC request times out. + */ +export class RpcTimeoutError extends TikkaSdkError { + constructor(message: string, cause?: unknown) { + super(TikkaSdkErrorCode.Timeout, message, cause); + this.name = "RpcTimeoutError"; + Object.setPrototypeOf(this, RpcTimeoutError.prototype); + } +} + +/** + * Thrown when the RPC node returns a 429 Rate Limit status. + */ +export class RateLimitError extends TikkaSdkError { + constructor(message: string, cause?: unknown) { + super(TikkaSdkErrorCode.RateLimit, message, cause); + this.name = "RateLimitError"; + Object.setPrototypeOf(this, RateLimitError.prototype); + } +} + +/** + * Thrown when the RPC node or transport is unavailable (502, 503, 504, or network issues). + */ +export class UnavailableError extends TikkaSdkError { + constructor(message: string, cause?: unknown) { + super(TikkaSdkErrorCode.Unavailable, message, cause); + this.name = "UnavailableError"; + Object.setPrototypeOf(this, UnavailableError.prototype); + } +} + +/** + * Thrown when the response format is invalid or cannot be parsed. + */ +export class InvalidResponseError extends TikkaSdkError { + constructor(message: string, cause?: unknown) { + super(TikkaSdkErrorCode.InvalidResponse, message, cause); + this.name = "InvalidResponseError"; + Object.setPrototypeOf(this, InvalidResponseError.prototype); + } +} + +/** + * Thrown when a contract invocation or simulation fails due to a smart contract-specific failure. + */ +export class ContractFailureError extends TikkaSdkError { + constructor(message: string, cause?: unknown) { + super(TikkaSdkErrorCode.ContractFailure, message, cause); + this.name = "ContractFailureError"; + Object.setPrototypeOf(this, ContractFailureError.prototype); + } +}