diff --git a/aastar/src/app.module.ts b/aastar/src/app.module.ts index d9d6049..c0a9e58 100644 --- a/aastar/src/app.module.ts +++ b/aastar/src/app.module.ts @@ -23,6 +23,7 @@ import { AdminModule } from "./admin/admin.module"; import { SaleModule } from "./sale/sale.module"; import { IndexerModule } from "./indexer/indexer.module"; import { SsoModule } from "./sso/sso.module"; +import { MyTaskIndexModule } from "./mytask-index/mytask-index.module"; @Module({ imports: [ @@ -48,6 +49,7 @@ import { SsoModule } from "./sso/sso.module"; SaleModule, IndexerModule, SsoModule, + MyTaskIndexModule, ], controllers: [AppController], providers: [AppService], diff --git a/aastar/src/mytask-index/mytask-index.constants.ts b/aastar/src/mytask-index/mytask-index.constants.ts new file mode 100644 index 0000000..84c9394 --- /dev/null +++ b/aastar/src/mytask-index/mytask-index.constants.ts @@ -0,0 +1,74 @@ +import type { AbiEvent } from "viem"; + +/** + * Indexer source key for MyTask challenge events. Stable public identifier used + * by IndexerService.registerSource / queryEvents and the /mytask/challenges read + * endpoints. Do not rename without a persistence migration (it keys the cursor). + */ +export const MYTASK_CHALLENGES_SOURCE = "mytask-challenges"; + +/** + * TaskEscrowV2 default address (MT-8 redeploy, Sepolia, verified on-chain). + * Overridable via env MYTASK_ESCROW_ADDRESS. Set the env to an empty string to + * disable MyTask indexing entirely (the source is then skipped at init). + * Source of truth: ~/Dev/mycelium/MyTask/contracts + broadcast/DeploySepolia. + */ +export const MYTASK_ESCROW_DEFAULT_ADDRESS = "0x171234DD282eF2909ec20dafC3F81deBa6761178"; + +/** + * Default first block to scan. This is the TaskEscrowV2 deployment block on + * Sepolia (from broadcast/DeploySepolia.s.sol/11155111/run-latest.json receipt), + * so the indexer never wastes RPC scanning pre-deployment history. Override via + * env MYTASK_INDEX_FROM_BLOCK; if a fresh redeploy moves the contract, update + * this to the new deployment block to keep catch-up cheap. + */ +export const MYTASK_INDEX_DEFAULT_FROM_BLOCK = 11269271; + +/** + * Event ABI fragments — copied 1:1 from TaskEscrowV2.sol so viem decodes the + * indexed topics and data correctly. A single wrong name/type/indexed flag + * silently breaks decoding, so these must mirror the contract exactly: + * + * event TaskChallenged(bytes32 indexed taskId, address indexed challenger, uint256 stake); + * event ChallengeResolved(bytes32 indexed taskId, bool challengeAccepted); + */ +export const TASK_CHALLENGED_EVENT = { + type: "event", + name: "TaskChallenged", + inputs: [ + { name: "taskId", type: "bytes32", indexed: true }, + { name: "challenger", type: "address", indexed: true }, + { name: "stake", type: "uint256", indexed: false }, + ], +} as const satisfies AbiEvent; + +export const CHALLENGE_RESOLVED_EVENT = { + type: "event", + name: "ChallengeResolved", + inputs: [ + { name: "taskId", type: "bytes32", indexed: true }, + { name: "challengeAccepted", type: "bool", indexed: false }, + ], +} as const satisfies AbiEvent; + +/** ABI passed to the indexer source (only its event items are used). */ +export const MYTASK_CHALLENGES_ABI = [TASK_CHALLENGED_EVENT, CHALLENGE_RESOLVED_EVENT]; + +export const MYTASK_CHALLENGES_EVENT_NAMES = ["TaskChallenged", "ChallengeResolved"]; + +/** bytes32 hex (0x + 64 hex chars) — the taskId query-param shape. */ +export const BYTES32_RE = /^0x[0-9a-fA-F]{64}$/; + +/** Upper bound for the recent-challenges list (mirrors indexer's clamp style). */ +export const MAX_RECENT_LIMIT = 100; +export const DEFAULT_RECENT_LIMIT = 20; + +/** Page size when draining the indexer (matches IndexerService MAX_QUERY_LIMIT). */ +export const INDEX_PAGE_SIZE = 200; + +/** + * Safety cap on how many events we drain from the indexer for an in-module + * filter/scan. A-8's getEvents has no arg-level (taskId) filter, so we page and + * filter here; this bounds the work if the source grows very large. + */ +export const MAX_SCAN_EVENTS = 5000; diff --git a/aastar/src/mytask-index/mytask-index.controller.ts b/aastar/src/mytask-index/mytask-index.controller.ts new file mode 100644 index 0000000..d8062c4 --- /dev/null +++ b/aastar/src/mytask-index/mytask-index.controller.ts @@ -0,0 +1,44 @@ +import { BadRequestException, Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { BYTES32_RE, DEFAULT_RECENT_LIMIT, MAX_RECENT_LIMIT } from "./mytask-index.constants"; +import { MyTaskIndexService } from "./mytask-index.service"; + +/** Strict decimal-integer parse (mirrors indexer.controller). */ +function parseStrictInt(name: string, value?: string): number | undefined { + if (value === undefined || value === "") return undefined; + if (!/^\d+$/.test(value)) { + throw new BadRequestException(`Query param "${name}" must be a non-negative integer`); + } + return Number.parseInt(value, 10); +} + +@ApiTags("mytask") +@Controller("mytask") +export class MyTaskIndexController { + constructor(private readonly service: MyTaskIndexService) {} + + @Get("challenges") + @ApiOperation({ + summary: "Challengers of a task (public read-only) — from indexed TaskChallenged events", + }) + @ApiQuery({ name: "taskId", required: true, description: "Task id (0x-prefixed bytes32)" }) + async getChallenges(@Query("taskId") taskId?: string) { + if (!taskId || !BYTES32_RE.test(taskId)) { + throw new BadRequestException('Query param "taskId" must be a 0x-prefixed bytes32'); + } + return this.service.getChallengesByTaskId(taskId); + } + + @Get("challenges/recent") + @ApiOperation({ summary: "Most recent challenges across all tasks (public read-only)" }) + @ApiQuery({ + name: "limit", + required: false, + description: `Page size (default ${DEFAULT_RECENT_LIMIT}, max ${MAX_RECENT_LIMIT})`, + }) + async getRecentChallenges(@Query("limit") limit?: string) { + const parsed = parseStrictInt("limit", limit) ?? DEFAULT_RECENT_LIMIT; + const clamped = Math.min(Math.max(parsed, 1), MAX_RECENT_LIMIT); + return this.service.getRecentChallenges(clamped); + } +} diff --git a/aastar/src/mytask-index/mytask-index.module.ts b/aastar/src/mytask-index/mytask-index.module.ts new file mode 100644 index 0000000..5c49578 --- /dev/null +++ b/aastar/src/mytask-index/mytask-index.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { IndexerModule } from "../indexer/indexer.module"; +import { MyTaskIndexController } from "./mytask-index.controller"; +import { MyTaskIndexService } from "./mytask-index.service"; + +/** + * First consumer of the shared A-8 IndexerService. Imports IndexerModule to + * obtain the (exported) IndexerService, registers TaskEscrowV2's challenge + * events as a source at init, and serves the /mytask/challenges read endpoints. + */ +@Module({ + imports: [IndexerModule], + controllers: [MyTaskIndexController], + providers: [MyTaskIndexService], + exports: [MyTaskIndexService], +}) +export class MyTaskIndexModule {} diff --git a/aastar/src/mytask-index/mytask-index.service.spec.ts b/aastar/src/mytask-index/mytask-index.service.spec.ts new file mode 100644 index 0000000..e59e3e2 --- /dev/null +++ b/aastar/src/mytask-index/mytask-index.service.spec.ts @@ -0,0 +1,432 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { BadRequestException, ServiceUnavailableException } from "@nestjs/common"; +import { IndexerService } from "../indexer/indexer.service"; +import { IndexedEventRecord } from "../indexer/indexer.types"; +import { MyTaskIndexService } from "./mytask-index.service"; +import { MyTaskIndexController } from "./mytask-index.controller"; +import { + CHALLENGE_RESOLVED_EVENT, + MAX_SCAN_EVENTS, + MYTASK_CHALLENGES_SOURCE, + MYTASK_ESCROW_DEFAULT_ADDRESS, + MYTASK_INDEX_DEFAULT_FROM_BLOCK, + TASK_CHALLENGED_EVENT, +} from "./mytask-index.constants"; + +const TASK_A = "0x" + "a".repeat(64); +const TASK_B = "0x" + "b".repeat(64); +const CHALLENGER_1 = "0x1111111111111111111111111111111111111111"; +const CHALLENGER_2 = "0x2222222222222222222222222222222222222222"; + +/** + * In-memory IndexerService double. Only the methods MyTaskIndexService touches + * are implemented: registerSource / hasSource / queryEvents. registerSource + * records the key so hasSource (and thus isRegistered) reflects reality. + */ +class FakeIndexer { + registerSource = jest.fn((config: { key: string }) => { + this.sources.add(config.key); + }); + private records: IndexedEventRecord[] = []; + private sources = new Set(); + + /** Seed events AND mark the source registered (mirrors a live registration). */ + seed(records: IndexedEventRecord[]) { + this.records = records; + this.sources.add(MYTASK_CHALLENGES_SOURCE); + } + + hasSource(key: string) { + return this.sources.has(key); + } + + // Mirrors IndexerService.queryEvents: newest-first, clamped, paginated. + async queryEvents(options: { source?: string; limit?: number; offset?: number }) { + const limit = Math.min(Math.max(Math.floor(options.limit ?? 50), 1), 200); + const offset = Math.max(Math.floor(options.offset ?? 0), 0); + const all = [...this.records] + .filter(e => (options.source ? e.source === options.source : true)) + .sort((a, b) => b.blockNumber - a.blockNumber || b.logIndex - a.logIndex); + return { + source: options.source ?? null, + limit, + offset, + total: all.length, + events: all.slice(offset, offset + limit), + }; + } +} + +function challengedRecord( + taskId: string, + challenger: string, + stake: string, + blockNumber: number, + logIndex = 0 +): IndexedEventRecord { + return { + id: `0xtx${blockNumber}-${logIndex}:${logIndex}`, + source: MYTASK_CHALLENGES_SOURCE, + eventName: "TaskChallenged", + contractAddress: MYTASK_ESCROW_DEFAULT_ADDRESS, + blockNumber, + blockHash: `0xhash${blockNumber}`, + txHash: `0xtx${blockNumber}-${logIndex}`, + logIndex, + args: { taskId, challenger, stake }, + createdAt: new Date().toISOString(), + }; +} + +function resolvedRecord( + taskId: string, + challengeAccepted: boolean, + blockNumber: number, + logIndex = 0 +): IndexedEventRecord { + return { + id: `0xtxr${blockNumber}-${logIndex}:${logIndex}`, + source: MYTASK_CHALLENGES_SOURCE, + eventName: "ChallengeResolved", + contractAddress: MYTASK_ESCROW_DEFAULT_ADDRESS, + blockNumber, + blockHash: `0xhash${blockNumber}`, + txHash: `0xtxr${blockNumber}-${logIndex}`, + logIndex, + args: { taskId, challengeAccepted }, + createdAt: new Date().toISOString(), + }; +} + +async function buildService( + indexer: FakeIndexer, + cfg: Record = {} +): Promise { + const merged: Record = { ethRpcUrl: "http://localhost:8545", ...cfg }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MyTaskIndexService, + { provide: IndexerService, useValue: indexer }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, fallback?: unknown) => + key in merged ? merged[key] : fallback + ), + }, + }, + ], + }).compile(); + return module.get(MyTaskIndexService); +} + +/** Build + register (onModuleInit) so the read guards pass. */ +async function buildRegistered( + indexer: FakeIndexer, + cfg: Record = {} +): Promise { + const service = await buildService(indexer, cfg); + service.onModuleInit(); + return service; +} + +describe("MyTaskIndexService", () => { + let indexer: FakeIndexer; + + beforeEach(() => { + indexer = new FakeIndexer(); + }); + + describe("source registration", () => { + it("registers the challenges source when an address is configured (default)", async () => { + const service = await buildService(indexer); + service.onModuleInit(); + + expect(indexer.registerSource).toHaveBeenCalledTimes(1); + expect(indexer.registerSource).toHaveBeenCalledWith( + expect.objectContaining({ + key: MYTASK_CHALLENGES_SOURCE, + address: MYTASK_ESCROW_DEFAULT_ADDRESS, + events: ["TaskChallenged", "ChallengeResolved"], + fromBlock: MYTASK_INDEX_DEFAULT_FROM_BLOCK, + }) + ); + expect(service.isRegistered()).toBe(true); + }); + + it("honors an explicit MYTASK_ESCROW_ADDRESS + MYTASK_INDEX_FROM_BLOCK", async () => { + const addr = "0x9999999999999999999999999999999999999999"; + const service = await buildService(indexer, { + MYTASK_ESCROW_ADDRESS: addr, + MYTASK_INDEX_FROM_BLOCK: "12345", + }); + service.onModuleInit(); + + expect(indexer.registerSource).toHaveBeenCalledWith( + expect.objectContaining({ address: addr, fromBlock: 12345 }) + ); + }); + + it("skips registration when MYTASK_ESCROW_ADDRESS is empty (disabled)", async () => { + const service = await buildService(indexer, { MYTASK_ESCROW_ADDRESS: "" }); + service.onModuleInit(); + expect(indexer.registerSource).not.toHaveBeenCalled(); + expect(service.isRegistered()).toBe(false); + }); + + it("skips registration on an invalid address", async () => { + const service = await buildService(indexer, { MYTASK_ESCROW_ADDRESS: "not-an-address" }); + service.onModuleInit(); + expect(indexer.registerSource).not.toHaveBeenCalled(); + }); + + it("skips registration when no RPC URL is configured", async () => { + const service = await buildService(indexer, { ethRpcUrl: undefined }); + service.onModuleInit(); + expect(indexer.registerSource).not.toHaveBeenCalled(); + }); + + it("falls back to the default fromBlock on an invalid MYTASK_INDEX_FROM_BLOCK", async () => { + const service = await buildService(indexer, { MYTASK_INDEX_FROM_BLOCK: "-5" }); + service.onModuleInit(); + expect(indexer.registerSource).toHaveBeenCalledWith( + expect.objectContaining({ fromBlock: MYTASK_INDEX_DEFAULT_FROM_BLOCK }) + ); + }); + + it("does not crash boot when registerSource throws", async () => { + indexer.registerSource.mockImplementation(() => { + throw new Error("already registered"); + }); + const service = await buildService(indexer); + expect(() => service.onModuleInit()).not.toThrow(); + expect(service.isRegistered()).toBe(false); + }); + + it("is idempotent: adopts an already-registered source without re-registering", async () => { + // Simulate the source already present (e.g. a re-init). + indexer.seed([]); + const service = await buildService(indexer); + service.onModuleInit(); + expect(indexer.registerSource).not.toHaveBeenCalled(); + expect(service.isRegistered()).toBe(true); + }); + }); + + describe("read guards (unregistered ⇒ 503, never silent empty)", () => { + it("throws ServiceUnavailable from getChallengesByTaskId when not registered", async () => { + const service = await buildService(indexer, { MYTASK_ESCROW_ADDRESS: "" }); + service.onModuleInit(); // skipped registration + await expect(service.getChallengesByTaskId(TASK_A)).rejects.toThrow( + ServiceUnavailableException + ); + }); + + it("throws ServiceUnavailable from getRecentChallenges when not registered", async () => { + const service = await buildService(indexer, { MYTASK_ESCROW_ADDRESS: "" }); + service.onModuleInit(); + await expect(service.getRecentChallenges(10)).rejects.toThrow(ServiceUnavailableException); + }); + }); + + describe("getChallengesByTaskId", () => { + it("filters TaskChallenged to the requested taskId and decodes challenger + stake", async () => { + indexer.seed([ + challengedRecord(TASK_A, CHALLENGER_1, "1000", 200, 0), + challengedRecord(TASK_B, CHALLENGER_2, "5000", 201, 0), + challengedRecord(TASK_A, CHALLENGER_2, "2000", 205, 1), + ]); + const service = await buildRegistered(indexer); + + const result = await service.getChallengesByTaskId(TASK_A); + + expect(result.taskId).toBe(TASK_A); + expect(result.challenges).toHaveLength(2); + // oldest-first + expect(result.challenges[0]).toMatchObject({ + challenger: CHALLENGER_1, + stake: "1000", + blockNumber: 200, + }); + expect(result.challenges[1]).toMatchObject({ challenger: CHALLENGER_2, stake: "2000" }); + expect(result.resolution).toBeNull(); + expect(result.truncated).toBe(false); + expect(result.total).toBe(3); + expect(result.scanned).toBe(3); + }); + + it("matches taskId case-insensitively", async () => { + indexer.seed([challengedRecord(TASK_A, CHALLENGER_1, "1000", 200)]); + const service = await buildRegistered(indexer); + const result = await service.getChallengesByTaskId(TASK_A.toUpperCase().replace("0X", "0x")); + expect(result.challenges).toHaveLength(1); + }); + + it("attaches the latest ChallengeResolved for the task", async () => { + indexer.seed([ + challengedRecord(TASK_A, CHALLENGER_1, "1000", 200), + resolvedRecord(TASK_A, false, 210), + resolvedRecord(TASK_A, true, 220), + ]); + const service = await buildRegistered(indexer); + const result = await service.getChallengesByTaskId(TASK_A); + expect(result.resolution).toMatchObject({ challengeAccepted: true, blockNumber: 220 }); + }); + + it("returns empty for an unknown taskId (not truncated ⇒ authoritative)", async () => { + indexer.seed([challengedRecord(TASK_A, CHALLENGER_1, "1000", 200)]); + const service = await buildRegistered(indexer); + const result = await service.getChallengesByTaskId(TASK_B); + expect(result.challenges).toHaveLength(0); + expect(result.resolution).toBeNull(); + expect(result.truncated).toBe(false); + }); + + it("flags truncated=true when the scan cap is hit before the task is found", async () => { + // > MAX_SCAN_EVENTS events, all for TASK_B ⇒ TASK_A not found within the window. + const many: IndexedEventRecord[] = []; + for (let i = 0; i < MAX_SCAN_EVENTS + 300; i++) { + many.push(challengedRecord(TASK_B, CHALLENGER_1, "1", i + 1, 0)); + } + indexer.seed(many); + const service = await buildRegistered(indexer); + + const result = await service.getChallengesByTaskId(TASK_A); + expect(result.challenges).toHaveLength(0); + expect(result.truncated).toBe(true); // "maybe in older window", not "definitely none" + expect(result.scanned).toBe(MAX_SCAN_EVENTS); + expect(result.total).toBe(MAX_SCAN_EVENTS + 300); + }); + }); + + describe("getRecentChallenges", () => { + it("returns newest-first challenges clamped to the limit", async () => { + indexer.seed([ + challengedRecord(TASK_A, CHALLENGER_1, "1000", 200), + challengedRecord(TASK_B, CHALLENGER_2, "2000", 205), + challengedRecord(TASK_A, CHALLENGER_2, "3000", 210), + resolvedRecord(TASK_A, true, 220), // must be filtered out + ]); + const service = await buildRegistered(indexer); + + const result = await service.getRecentChallenges(2); + expect(result.limit).toBe(2); + expect(result.challenges).toHaveLength(2); + expect(result.challenges[0].blockNumber).toBe(210); // newest first + expect(result.challenges[1].blockNumber).toBe(205); + expect(result.challenges.every(c => c.stake !== undefined)).toBe(true); + expect(result.truncated).toBe(false); + }); + + it("only returns TaskChallenged events (no resolutions)", async () => { + indexer.seed([resolvedRecord(TASK_A, true, 220)]); + const service = await buildRegistered(indexer); + const result = await service.getRecentChallenges(10); + expect(result.challenges).toHaveLength(0); + }); + + it("flags truncated=true when the scan cap is hit before `limit` is met", async () => { + // > MAX_SCAN_EVENTS events, all resolutions ⇒ 0 challenges collected, cap hit. + const many: IndexedEventRecord[] = []; + for (let i = 0; i < MAX_SCAN_EVENTS + 300; i++) { + many.push(resolvedRecord(TASK_A, true, i + 1, 0)); + } + indexer.seed(many); + const service = await buildRegistered(indexer); + + const result = await service.getRecentChallenges(5); + expect(result.challenges).toHaveLength(0); + expect(result.truncated).toBe(true); + expect(result.scanned).toBe(MAX_SCAN_EVENTS); + expect(result.total).toBe(MAX_SCAN_EVENTS + 300); + }); + }); + + describe("bytes32 validation helper", () => { + it("accepts a well-formed bytes32 and rejects malformed input", () => { + expect(MyTaskIndexService.isBytes32(TASK_A)).toBe(true); + expect(MyTaskIndexService.isBytes32("0x123")).toBe(false); + expect(MyTaskIndexService.isBytes32("deadbeef")).toBe(false); + }); + }); +}); + +describe("MyTask event ABI fragments (guard against contract drift)", () => { + it("TaskChallenged matches TaskEscrowV2.sol exactly", () => { + expect(TASK_CHALLENGED_EVENT.type).toBe("event"); + expect(TASK_CHALLENGED_EVENT.name).toBe("TaskChallenged"); + expect(TASK_CHALLENGED_EVENT.inputs).toEqual([ + { name: "taskId", type: "bytes32", indexed: true }, + { name: "challenger", type: "address", indexed: true }, + { name: "stake", type: "uint256", indexed: false }, + ]); + }); + + it("ChallengeResolved matches TaskEscrowV2.sol exactly", () => { + expect(CHALLENGE_RESOLVED_EVENT.type).toBe("event"); + expect(CHALLENGE_RESOLVED_EVENT.name).toBe("ChallengeResolved"); + expect(CHALLENGE_RESOLVED_EVENT.inputs).toEqual([ + { name: "taskId", type: "bytes32", indexed: true }, + { name: "challengeAccepted", type: "bool", indexed: false }, + ]); + }); +}); + +describe("MyTaskIndexController", () => { + function makeController(overrides: Partial = {}) { + const service = { + getChallengesByTaskId: jest.fn().mockResolvedValue({ + taskId: TASK_A, + challenges: [], + resolution: null, + scanned: 0, + total: 0, + truncated: false, + }), + getRecentChallenges: jest + .fn() + .mockResolvedValue({ limit: 20, challenges: [], scanned: 0, total: 0, truncated: false }), + ...overrides, + } as unknown as MyTaskIndexService; + return { controller: new MyTaskIndexController(service), service }; + } + + it("rejects a missing taskId with 400", async () => { + const { controller } = makeController(); + await expect(controller.getChallenges(undefined)).rejects.toThrow(BadRequestException); + }); + + it("rejects a non-bytes32 taskId with 400", async () => { + const { controller } = makeController(); + await expect(controller.getChallenges("0x123")).rejects.toThrow(BadRequestException); + await expect(controller.getChallenges("deadbeef")).rejects.toThrow(BadRequestException); + }); + + it("passes a valid bytes32 taskId through to the service", async () => { + const { controller, service } = makeController(); + await controller.getChallenges(TASK_A); + expect(service.getChallengesByTaskId).toHaveBeenCalledWith(TASK_A); + }); + + it("rejects a non-integer limit with 400", async () => { + const { controller } = makeController(); + await expect(controller.getRecentChallenges("abc")).rejects.toThrow(BadRequestException); + await expect(controller.getRecentChallenges("1.5")).rejects.toThrow(BadRequestException); + }); + + it("defaults limit to 20 when omitted", async () => { + const { controller, service } = makeController(); + await controller.getRecentChallenges(undefined); + expect(service.getRecentChallenges).toHaveBeenCalledWith(20); + }); + + it("clamps limit to the [1, 100] range", async () => { + const { controller, service } = makeController(); + await controller.getRecentChallenges("500"); + expect(service.getRecentChallenges).toHaveBeenCalledWith(100); + + await controller.getRecentChallenges("0"); + expect(service.getRecentChallenges).toHaveBeenLastCalledWith(1); + }); +}); diff --git a/aastar/src/mytask-index/mytask-index.service.ts b/aastar/src/mytask-index/mytask-index.service.ts new file mode 100644 index 0000000..3b83bf9 --- /dev/null +++ b/aastar/src/mytask-index/mytask-index.service.ts @@ -0,0 +1,301 @@ +import { Injectable, Logger, OnModuleInit, ServiceUnavailableException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { Address } from "viem"; +import { IndexerService } from "../indexer/indexer.service"; +import { IndexedEventRecord } from "../indexer/indexer.types"; +import { + BYTES32_RE, + INDEX_PAGE_SIZE, + MAX_SCAN_EVENTS, + MYTASK_CHALLENGES_ABI, + MYTASK_CHALLENGES_EVENT_NAMES, + MYTASK_CHALLENGES_SOURCE, + MYTASK_ESCROW_DEFAULT_ADDRESS, + MYTASK_INDEX_DEFAULT_FROM_BLOCK, +} from "./mytask-index.constants"; + +/** One TaskChallenged occurrence, resolved to challenger + stake + provenance. */ +export interface ChallengeEntry { + taskId: string; + challenger: string; + /** Stake actually received by the escrow, as a decimal string (wei). */ + stake: string; + blockNumber: number; + blockHash: string; + txHash: string; + logIndex: number; +} + +/** Resolution of a challenge (ChallengeResolved), if one has been indexed. */ +export interface ChallengeResolution { + challengeAccepted: boolean; + blockNumber: number; + txHash: string; + logIndex: number; +} + +export interface TaskChallengesResult { + taskId: string; + challenges: ChallengeEntry[]; + resolution: ChallengeResolution | null; + /** How many source events were scanned to build this answer. */ + scanned: number; + /** Total events the indexer holds for this source. */ + total: number; + /** + * True when the MAX_SCAN_EVENTS cap was hit before exhausting the source, so + * matching events (or a resolution) may exist in the un-scanned older window. + * When true and `challenges` is empty, "not found" is NOT authoritative. + */ + truncated: boolean; +} + +export interface RecentChallengesResult { + limit: number; + challenges: ChallengeEntry[]; + /** How many source events were scanned. */ + scanned: number; + /** Total events the indexer holds for this source. */ + total: number; + /** True when the scan cap was hit before `limit` challenges were collected. */ + truncated: boolean; +} + +/** + * First consumer of the A-8 IndexerService: registers TaskEscrowV2's challenge + * events as an indexer source and exposes read endpoints that answer "who + * challenged task X" — data the contract does not expose (no public getter for + * `_challengers`), so the on-chain event log is the only source (MT-11). + * + * Registration is opt-out: MYTASK_ESCROW_ADDRESS defaults to the deployed + * TaskEscrowV2. It is skipped (with a log, never blocking boot) when the address + * is explicitly cleared or invalid, or when no RPC URL is configured — matching + * A-8's "no source ⇒ no RPC traffic" idle contract. + */ +@Injectable() +export class MyTaskIndexService implements OnModuleInit { + private readonly logger = new Logger(MyTaskIndexService.name); + private registered = false; + + constructor( + private readonly configService: ConfigService, + private readonly indexerService: IndexerService + ) {} + + onModuleInit(): void { + // envOr default: the known deployed TaskEscrowV2. An explicit empty string + // disables MyTask indexing. + const rawAddress = this.configService.get( + "MYTASK_ESCROW_ADDRESS", + MYTASK_ESCROW_DEFAULT_ADDRESS + ); + const address = (rawAddress ?? "").trim(); + const rpcUrl = this.configService.get("ethRpcUrl"); + + if (!address) { + this.logger.log("MyTask indexing skipped: MYTASK_ESCROW_ADDRESS is empty (disabled)"); + return; + } + if (!/^0x[0-9a-fA-F]{40}$/.test(address)) { + this.logger.warn( + `MyTask indexing skipped: MYTASK_ESCROW_ADDRESS="${address}" is not a valid address` + ); + return; + } + if (!rpcUrl) { + // Align with A-8: without an RPC there is nothing to poll; don't register a + // dead source. (ETH_RPC_URL is normally required, so this is defensive.) + this.logger.warn("MyTask indexing skipped: no RPC URL configured (ethRpcUrl)"); + return; + } + + // Idempotency: if the source is already registered (e.g. a re-init), adopt it + // instead of calling registerSource again (which throws on a duplicate key). + if (this.indexerService.hasSource(MYTASK_CHALLENGES_SOURCE)) { + this.registered = true; + this.logger.log("MyTask challenges source already registered; skipping re-registration"); + return; + } + + const fromBlock = this.resolveFromBlock(); + try { + this.indexerService.registerSource({ + key: MYTASK_CHALLENGES_SOURCE, + address: address as Address, + abi: MYTASK_CHALLENGES_ABI, + events: MYTASK_CHALLENGES_EVENT_NAMES, + fromBlock, + }); + this.registered = true; + this.logger.log( + `MyTask challenges source registered (address=${address}, fromBlock=${fromBlock})` + ); + } catch (error) { + // A registration failure must not crash boot — the read endpoints will just + // report an unregistered source. + const message = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to register MyTask challenges source: ${message}`); + } + } + + /** Parse MYTASK_INDEX_FROM_BLOCK; fall back to the deployment block default. */ + private resolveFromBlock(): number { + const raw = this.configService.get("MYTASK_INDEX_FROM_BLOCK"); + if (raw === undefined || raw === null || raw === "") return MYTASK_INDEX_DEFAULT_FROM_BLOCK; + const value = typeof raw === "number" ? raw : Number(String(raw).trim()); + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) { + this.logger.warn( + `Invalid MYTASK_INDEX_FROM_BLOCK="${raw}"; using default ${MYTASK_INDEX_DEFAULT_FROM_BLOCK}` + ); + return MYTASK_INDEX_DEFAULT_FROM_BLOCK; + } + return value; + } + + /** Whether the MyTask challenge source is registered in the indexer. */ + isRegistered(): boolean { + return this.registered && this.indexerService.hasSource(MYTASK_CHALLENGES_SOURCE); + } + + /** + * Guard the read paths: if the source was never registered (empty/invalid + * address, no RPC, or a failed/duplicate registration), querying the indexer + * would return an empty array indistinguishable from "no one challenged". + * Surface that as 503 so callers never mistake "not indexed" for "no data". + */ + private ensureRegistered(): void { + if (!this.isRegistered()) { + throw new ServiceUnavailableException("MyTask index source is not registered"); + } + } + + /** + * All challengers of a task, oldest-challenge-first, plus the resolution if + * one has been indexed. taskId must be a 0x-prefixed bytes32; comparison is + * case-insensitive (topics decode to lowercase hex). + */ + async getChallengesByTaskId(taskId: string): Promise { + this.ensureRegistered(); + const normalized = taskId.toLowerCase(); + const { events: records, scanned, total, truncated } = await this.drainEvents(); + + const challenges: ChallengeEntry[] = []; + let resolution: ChallengeResolution | null = null; + + for (const record of records) { + const recordTaskId = this.argString(record, "taskId").toLowerCase(); + if (recordTaskId !== normalized) continue; + + if (record.eventName === "TaskChallenged") { + challenges.push(this.toChallengeEntry(record)); + } else if (record.eventName === "ChallengeResolved") { + const candidate: ChallengeResolution = { + challengeAccepted: this.argBool(record, "challengeAccepted"), + blockNumber: record.blockNumber, + txHash: record.txHash, + logIndex: record.logIndex, + }; + // Keep the latest resolution (highest block, then logIndex). + if ( + !resolution || + candidate.blockNumber > resolution.blockNumber || + (candidate.blockNumber === resolution.blockNumber && + candidate.logIndex > resolution.logIndex) + ) { + resolution = candidate; + } + } + } + + // Oldest challenge first for a stable, chronological UI list. + challenges.sort((a, b) => a.blockNumber - b.blockNumber || a.logIndex - b.logIndex); + return { taskId, challenges, resolution, scanned, total, truncated }; + } + + /** Most-recent challenges across all tasks, newest first, capped at `limit`. */ + async getRecentChallenges(limit: number): Promise { + this.ensureRegistered(); + // getEvents is newest-first; page forward, keeping only TaskChallenged, until + // we have `limit` of them (resolutions consume slots so a single page of + // `limit` events can under-fill). Bounded by MAX_SCAN_EVENTS. + const challenges: ChallengeEntry[] = []; + let scanned = 0; + let total = 0; + for (;;) { + const { events, total: pageTotal } = await this.indexerService.queryEvents({ + source: MYTASK_CHALLENGES_SOURCE, + limit: INDEX_PAGE_SIZE, + offset: scanned, + }); + total = pageTotal; + for (const event of events) { + scanned += 1; + if (event.eventName !== "TaskChallenged") continue; + challenges.push(this.toChallengeEntry(event)); + // Enough collected: the answer is complete regardless of un-scanned tail. + if (challenges.length >= limit) { + return { limit, challenges, scanned, total, truncated: false }; + } + } + if (events.length === 0 || scanned >= total || scanned >= MAX_SCAN_EVENTS) break; + } + // Fell short of `limit`: truncated only if the cap stopped us with more to scan. + return { limit, challenges, scanned, total, truncated: scanned < total }; + } + + /** + * Drain up to MAX_SCAN_EVENTS records for the source (A-8 has no arg-level + * filter, so taskId filtering happens here). Pages in INDEX_PAGE_SIZE chunks. + * `truncated` is true when the cap stopped us before the source was exhausted, + * so an empty filter result is "maybe in the older window", not "definitely none". + */ + private async drainEvents(): Promise<{ + events: IndexedEventRecord[]; + scanned: number; + total: number; + truncated: boolean; + }> { + const out: IndexedEventRecord[] = []; + let scanned = 0; + let total = 0; + for (;;) { + const { events, total: pageTotal } = await this.indexerService.queryEvents({ + source: MYTASK_CHALLENGES_SOURCE, + limit: INDEX_PAGE_SIZE, + offset: scanned, + }); + total = pageTotal; + out.push(...events); + scanned += events.length; + if (events.length === 0 || scanned >= total || scanned >= MAX_SCAN_EVENTS) break; + } + return { events: out, scanned, total, truncated: scanned < total }; + } + + private toChallengeEntry(record: IndexedEventRecord): ChallengeEntry { + return { + taskId: this.argString(record, "taskId"), + challenger: this.argString(record, "challenger"), + stake: this.argString(record, "stake"), + blockNumber: record.blockNumber, + blockHash: record.blockHash, + txHash: record.txHash, + logIndex: record.logIndex, + }; + } + + private argString(record: IndexedEventRecord, key: string): string { + const value = record.args?.[key]; + return value === undefined || value === null ? "" : String(value); + } + + private argBool(record: IndexedEventRecord, key: string): boolean { + const value = record.args?.[key]; + return value === true || value === "true"; + } + + /** Static bytes32 shape check reused by the controller. */ + static isBytes32(value: string): boolean { + return BYTES32_RE.test(value); + } +}