From 826c2d165aa9bcbda30551791ef50e1d9aee2974 Mon Sep 17 00:00:00 2001 From: jhfnetboy Date: Tue, 14 Jul 2026 18:39:19 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(mytask-index):=20A-8=20indexer=20?= =?UTF-8?q?=E9=A6=96=E4=B8=AA=E6=B6=88=E8=B4=B9=E6=96=B9=20=E2=80=94=20?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=20MyTask=20=E6=8C=91=E6=88=98=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 TaskEscrowV2 (Sepolia, MT-8 重部署) 注册为 IndexerService 首个 source, 索引 TaskChallenged / ChallengeResolved 事件,并暴露按 taskId 查挑战者的读接口 (合约无 _challengers public getter,只能从事件索引取——落地 MT-11 遗留项)。 - 新模块 aastar/src/mytask-index/:onModuleInit 注册 source,仅在配了 MYTASK_ESCROW_ADDRESS(envOr 默认部署地址)且 RPC 可用时注册,否则 skip+log 不阻塞启动(对齐 A-8「无 source 不打 RPC」)。 - 读接口:GET /mytask/challenges?taskId=、GET /mytask/challenges/recent ?limit=,控制器 bytes32/limit 校验对齐 indexer.controller。 - event ABI 片段 1:1 复刻自 TaskEscrowV2.sol;不改 A-8 本体,只调其公开 API, taskId 过滤在本模块内(A-8 getEvents 无 arg 级过滤)。 - env:MYTASK_ESCROW_ADDRESS(默认 0x171234…1178)、MYTASK_INDEX_FROM_BLOCK (默认部署块 11269271)。 - 单测 mytask-index.service.spec.ts:注册/skip/taskId 过滤/recent limit 夹取。 Claude-Session: https://claude.ai/code/session_01MCxVCnKkpzi5sCGf1FZ4CH --- aastar/src/app.module.ts | 2 + .../mytask-index/mytask-index.constants.ts | 74 +++++ .../mytask-index/mytask-index.controller.ts | 44 +++ .../src/mytask-index/mytask-index.module.ts | 17 ++ .../mytask-index/mytask-index.service.spec.ts | 271 ++++++++++++++++++ .../src/mytask-index/mytask-index.service.ts | 248 ++++++++++++++++ 6 files changed, 656 insertions(+) create mode 100644 aastar/src/mytask-index/mytask-index.constants.ts create mode 100644 aastar/src/mytask-index/mytask-index.controller.ts create mode 100644 aastar/src/mytask-index/mytask-index.module.ts create mode 100644 aastar/src/mytask-index/mytask-index.service.spec.ts create mode 100644 aastar/src/mytask-index/mytask-index.service.ts 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..e852c85 --- /dev/null +++ b/aastar/src/mytask-index/mytask-index.service.spec.ts @@ -0,0 +1,271 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { IndexerService } from "../indexer/indexer.service"; +import { IndexedEventRecord } from "../indexer/indexer.types"; +import { MyTaskIndexService } from "./mytask-index.service"; +import { + MYTASK_CHALLENGES_SOURCE, + MYTASK_ESCROW_DEFAULT_ADDRESS, + MYTASK_INDEX_DEFAULT_FROM_BLOCK, +} 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. + */ +class FakeIndexer { + registerSource = jest.fn(); + private records: IndexedEventRecord[] = []; + private sources = new Set(); + + 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); +} + +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, + }) + ); + }); + + 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); + }); + }); + + 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 buildService(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(); + }); + + it("matches taskId case-insensitively", async () => { + indexer.seed([challengedRecord(TASK_A, CHALLENGER_1, "1000", 200)]); + const service = await buildService(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 buildService(indexer); + const result = await service.getChallengesByTaskId(TASK_A); + expect(result.resolution).toMatchObject({ challengeAccepted: true, blockNumber: 220 }); + }); + + it("returns empty for an unknown taskId", async () => { + indexer.seed([challengedRecord(TASK_A, CHALLENGER_1, "1000", 200)]); + const service = await buildService(indexer); + const result = await service.getChallengesByTaskId(TASK_B); + expect(result.challenges).toHaveLength(0); + expect(result.resolution).toBeNull(); + }); + }); + + 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 buildService(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); + }); + + it("only returns TaskChallenged events (no resolutions)", async () => { + indexer.seed([resolvedRecord(TASK_A, true, 220)]); + const service = await buildService(indexer); + const result = await service.getRecentChallenges(10); + expect(result.challenges).toHaveLength(0); + }); + }); + + 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); + }); + }); +}); 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..334fb5d --- /dev/null +++ b/aastar/src/mytask-index/mytask-index.service.ts @@ -0,0 +1,248 @@ +import { Injectable, Logger, OnModuleInit } 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; +} + +export interface RecentChallengesResult { + limit: number; + challenges: ChallengeEntry[]; +} + +/** + * 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; + } + + 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); + } + + /** + * 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 { + const normalized = taskId.toLowerCase(); + const records = 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 }; + } + + /** Most-recent challenges across all tasks, newest first, capped at `limit`. */ + async getRecentChallenges(limit: number): Promise { + // 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 offset = 0; + for (;;) { + const { events, total } = await this.indexerService.queryEvents({ + source: MYTASK_CHALLENGES_SOURCE, + limit: INDEX_PAGE_SIZE, + offset, + }); + for (const event of events) { + if (event.eventName !== "TaskChallenged") continue; + challenges.push(this.toChallengeEntry(event)); + if (challenges.length >= limit) return { limit, challenges }; + } + offset += events.length; + if (events.length === 0 || offset >= total || offset >= MAX_SCAN_EVENTS) break; + } + return { limit, challenges }; + } + + /** + * 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. + */ + private async drainEvents(): Promise { + const out: IndexedEventRecord[] = []; + let offset = 0; + for (;;) { + const { events, total } = await this.indexerService.queryEvents({ + source: MYTASK_CHALLENGES_SOURCE, + limit: INDEX_PAGE_SIZE, + offset, + }); + out.push(...events); + offset += events.length; + if (events.length === 0 || offset >= total || offset >= MAX_SCAN_EVENTS) break; + } + return out; + } + + 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); + } +} From 060a9eb501b0a3f32a615599b1bf5e0e2379555a Mon Sep 17 00:00:00 2001 From: jhfnetboy Date: Tue, 14 Jul 2026 18:49:22 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(mytask-index):=20=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E3=80=8C=E6=9F=A5=E4=B8=8D=E5=88=B0=E3=80=8D=E8=A2=AB=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=BD=93=E3=80=8C=E6=B2=A1=E4=BA=BA=E6=8C=91=E6=88=98?= =?UTF-8?q?=E3=80=8D(Codex=20RC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回应 Codex REQUEST_CHANGES:未注册/截断被静默返空,调用方误判无挑战。 - H1 未注册静默返空:新增 registered 标志 + ensureRegistered() 守卫; source 因空/非法地址、无 RPC、注册失败被 skip 时,读接口抛 ServiceUnavailableException(503)而非返静默空数组。 - H2/H3 扫描截断静默:drainEvents / getRecentChallenges 返回并透出 scanned + total + truncated;命中 MAX_SCAN_EVENTS 上限且源未扫完时 truncated=true,taskId 查询「查不到 + truncated」= 可能在更旧窗口, 非「确实没有」;recent 未凑够 limit 同理。 - L 注册幂等:registerSource 前先 hasSource(),已注册直接 registered=true 返回,避免重复注册噪音日志。 - 测试补强:未注册 503、>5000 事件截断(truncated + scanned 到上限)、 幂等注册、controller spec(bytes32/limit 400 校验 + 夹取)、event ABI 精确字段+indexed 断言防漂移。27 passed。 Claude-Session: https://claude.ai/code/session_01MCxVCnKkpzi5sCGf1FZ4CH --- .../mytask-index/mytask-index.service.spec.ts | 179 +++++++++++++++++- .../src/mytask-index/mytask-index.service.ts | 87 +++++++-- 2 files changed, 240 insertions(+), 26 deletions(-) diff --git a/aastar/src/mytask-index/mytask-index.service.spec.ts b/aastar/src/mytask-index/mytask-index.service.spec.ts index e852c85..e59e3e2 100644 --- a/aastar/src/mytask-index/mytask-index.service.spec.ts +++ b/aastar/src/mytask-index/mytask-index.service.spec.ts @@ -1,12 +1,17 @@ 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); @@ -16,13 +21,17 @@ const CHALLENGER_2 = "0x2222222222222222222222222222222222222222"; /** * In-memory IndexerService double. Only the methods MyTaskIndexService touches - * are implemented: registerSource / hasSource / queryEvents. + * are implemented: registerSource / hasSource / queryEvents. registerSource + * records the key so hasSource (and thus isRegistered) reflects reality. */ class FakeIndexer { - registerSource = jest.fn(); + 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); @@ -112,6 +121,16 @@ async function buildService( 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; @@ -133,6 +152,7 @@ describe("MyTaskIndexService", () => { fromBlock: MYTASK_INDEX_DEFAULT_FROM_BLOCK, }) ); + expect(service.isRegistered()).toBe(true); }); it("honors an explicit MYTASK_ESCROW_ADDRESS + MYTASK_INDEX_FROM_BLOCK", async () => { @@ -183,6 +203,31 @@ describe("MyTaskIndexService", () => { 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", () => { @@ -192,7 +237,7 @@ describe("MyTaskIndexService", () => { challengedRecord(TASK_B, CHALLENGER_2, "5000", 201, 0), challengedRecord(TASK_A, CHALLENGER_2, "2000", 205, 1), ]); - const service = await buildService(indexer); + const service = await buildRegistered(indexer); const result = await service.getChallengesByTaskId(TASK_A); @@ -206,11 +251,14 @@ describe("MyTaskIndexService", () => { }); 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 buildService(indexer); + const service = await buildRegistered(indexer); const result = await service.getChallengesByTaskId(TASK_A.toUpperCase().replace("0X", "0x")); expect(result.challenges).toHaveLength(1); }); @@ -221,17 +269,34 @@ describe("MyTaskIndexService", () => { resolvedRecord(TASK_A, false, 210), resolvedRecord(TASK_A, true, 220), ]); - const service = await buildService(indexer); + 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", async () => { + it("returns empty for an unknown taskId (not truncated ⇒ authoritative)", async () => { indexer.seed([challengedRecord(TASK_A, CHALLENGER_1, "1000", 200)]); - const service = await buildService(indexer); + 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); }); }); @@ -243,7 +308,7 @@ describe("MyTaskIndexService", () => { challengedRecord(TASK_A, CHALLENGER_2, "3000", 210), resolvedRecord(TASK_A, true, 220), // must be filtered out ]); - const service = await buildService(indexer); + const service = await buildRegistered(indexer); const result = await service.getRecentChallenges(2); expect(result.limit).toBe(2); @@ -251,14 +316,31 @@ describe("MyTaskIndexService", () => { 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 buildService(indexer); + 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", () => { @@ -269,3 +351,82 @@ describe("MyTaskIndexService", () => { }); }); }); + +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 index 334fb5d..3b83bf9 100644 --- a/aastar/src/mytask-index/mytask-index.service.ts +++ b/aastar/src/mytask-index/mytask-index.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { Injectable, Logger, OnModuleInit, ServiceUnavailableException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import type { Address } from "viem"; import { IndexerService } from "../indexer/indexer.service"; @@ -38,11 +38,27 @@ 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; } /** @@ -93,6 +109,14 @@ export class MyTaskIndexService implements OnModuleInit { 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({ @@ -133,14 +157,27 @@ export class MyTaskIndexService implements OnModuleInit { 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 records = await this.drainEvents(); + const { events: records, scanned, total, truncated } = await this.drainEvents(); const challenges: ChallengeEntry[] = []; let resolution: ChallengeResolution | null = null; @@ -172,51 +209,67 @@ export class MyTaskIndexService implements OnModuleInit { // 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 }; + 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 offset = 0; + let scanned = 0; + let total = 0; for (;;) { - const { events, total } = await this.indexerService.queryEvents({ + const { events, total: pageTotal } = await this.indexerService.queryEvents({ source: MYTASK_CHALLENGES_SOURCE, limit: INDEX_PAGE_SIZE, - offset, + offset: scanned, }); + total = pageTotal; for (const event of events) { + scanned += 1; if (event.eventName !== "TaskChallenged") continue; challenges.push(this.toChallengeEntry(event)); - if (challenges.length >= limit) return { limit, challenges }; + // Enough collected: the answer is complete regardless of un-scanned tail. + if (challenges.length >= limit) { + return { limit, challenges, scanned, total, truncated: false }; + } } - offset += events.length; - if (events.length === 0 || offset >= total || offset >= MAX_SCAN_EVENTS) break; + if (events.length === 0 || scanned >= total || scanned >= MAX_SCAN_EVENTS) break; } - return { limit, challenges }; + // 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 { + private async drainEvents(): Promise<{ + events: IndexedEventRecord[]; + scanned: number; + total: number; + truncated: boolean; + }> { const out: IndexedEventRecord[] = []; - let offset = 0; + let scanned = 0; + let total = 0; for (;;) { - const { events, total } = await this.indexerService.queryEvents({ + const { events, total: pageTotal } = await this.indexerService.queryEvents({ source: MYTASK_CHALLENGES_SOURCE, limit: INDEX_PAGE_SIZE, - offset, + offset: scanned, }); + total = pageTotal; out.push(...events); - offset += events.length; - if (events.length === 0 || offset >= total || offset >= MAX_SCAN_EVENTS) break; + scanned += events.length; + if (events.length === 0 || scanned >= total || scanned >= MAX_SCAN_EVENTS) break; } - return out; + return { events: out, scanned, total, truncated: scanned < total }; } private toChallengeEntry(record: IndexedEventRecord): ChallengeEntry {