From 5ad78ca090537fe8781b409ac1d0ffb56fb2c9c6 Mon Sep 17 00:00:00 2001 From: Precious Igwealor Date: Sat, 27 Jun 2026 09:24:01 +0100 Subject: [PATCH 1/5] feat(indexer/common): add event handler registration interface (#30) - Define `SorobanEventInput`, `HandlerResult`, `EventHandler`, and `HandlerFilter` types in `indexer/common/src/handlers/types.ts` - Implement `HandlerRegistry` in `registry.ts` with `register()`, `matches()` (filters by contractId, topic, eventName), and `dispatch()` - Export all handler types and the registry from `common/src/index.ts` - Add 10 unit tests covering filter matching, dispatch, error results, fluent chaining, and the empty-filter catch-all --- indexer/common/src/handlers/index.ts | 7 ++ indexer/common/src/handlers/registry.test.ts | 123 +++++++++++++++++++ indexer/common/src/handlers/registry.ts | 42 +++++++ indexer/common/src/handlers/types.ts | 21 ++++ indexer/common/src/index.ts | 1 + 5 files changed, 194 insertions(+) create mode 100644 indexer/common/src/handlers/index.ts create mode 100644 indexer/common/src/handlers/registry.test.ts create mode 100644 indexer/common/src/handlers/registry.ts create mode 100644 indexer/common/src/handlers/types.ts diff --git a/indexer/common/src/handlers/index.ts b/indexer/common/src/handlers/index.ts new file mode 100644 index 0000000..5d7d50a --- /dev/null +++ b/indexer/common/src/handlers/index.ts @@ -0,0 +1,7 @@ +export { HandlerRegistry } from "./registry.js"; +export type { + EventHandler, + HandlerFilter, + HandlerResult, + SorobanEventInput, +} from "./types.js"; diff --git a/indexer/common/src/handlers/registry.test.ts b/indexer/common/src/handlers/registry.test.ts new file mode 100644 index 0000000..41cd21c --- /dev/null +++ b/indexer/common/src/handlers/registry.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test, vi } from "vitest"; + +import { HandlerRegistry } from "./registry.js"; +import type { EventHandler, HandlerResult, SorobanEventInput } from "./types.js"; + +const baseEvent: SorobanEventInput = { + contractId: "CABC123", + ledger: 100, + ledgerClosedAt: "2024-01-01T00:00:00Z", + topic: ["stream_created"], + data: { amount: 1000 }, + id: "event-1", + pagingToken: "paging-1", +}; + +const ok: HandlerResult = { ok: true }; +const makeHandler = (result: HandlerResult = ok): EventHandler => + vi.fn().mockResolvedValue(result); + +describe("HandlerRegistry", () => { + describe("register and matches", () => { + test("matches on contractId", () => { + const registry = new HandlerRegistry(); + const handler = makeHandler(); + registry.register({ contractId: "CABC123" }, handler); + + expect(registry.matches(baseEvent)).toEqual([handler]); + }); + + test("does not match wrong contractId", () => { + const registry = new HandlerRegistry(); + registry.register({ contractId: "COTHER" }, makeHandler()); + + expect(registry.matches(baseEvent)).toHaveLength(0); + }); + + test("matches on topic", () => { + const registry = new HandlerRegistry(); + const handler = makeHandler(); + registry.register({ topic: "stream_created" }, handler); + + expect(registry.matches(baseEvent)).toEqual([handler]); + }); + + test("does not match absent topic", () => { + const registry = new HandlerRegistry(); + registry.register({ topic: "stream_cancelled" }, makeHandler()); + + expect(registry.matches(baseEvent)).toHaveLength(0); + }); + + test("matches on eventName alias", () => { + const registry = new HandlerRegistry(); + const handler = makeHandler(); + registry.register({ eventName: "stream_created" }, handler); + + expect(registry.matches(baseEvent)).toEqual([handler]); + }); + + test("matches combined contractId + topic filter", () => { + const registry = new HandlerRegistry(); + const handler = makeHandler(); + registry.register({ contractId: "CABC123", topic: "stream_created" }, handler); + + expect(registry.matches(baseEvent)).toEqual([handler]); + expect( + registry.matches({ ...baseEvent, contractId: "COTHER" }), + ).toHaveLength(0); + }); + + test("returns multiple handlers when several match", () => { + const registry = new HandlerRegistry(); + const h1 = makeHandler(); + const h2 = makeHandler(); + registry.register({ contractId: "CABC123" }, h1); + registry.register({ topic: "stream_created" }, h2); + + expect(registry.matches(baseEvent)).toEqual([h1, h2]); + }); + + test("empty filter matches every event", () => { + const registry = new HandlerRegistry(); + const handler = makeHandler(); + registry.register({}, handler); + + expect(registry.matches(baseEvent)).toEqual([handler]); + }); + }); + + describe("dispatch", () => { + test("calls all matched handlers and returns their results", async () => { + const registry = new HandlerRegistry(); + const h1 = makeHandler({ ok: true }); + const h2 = makeHandler({ ok: false, error: "boom", retriable: true }); + registry.register({}, h1); + registry.register({}, h2); + + const results = await registry.dispatch(baseEvent); + + expect(h1).toHaveBeenCalledWith(baseEvent); + expect(h2).toHaveBeenCalledWith(baseEvent); + expect(results).toEqual([ + { ok: true }, + { ok: false, error: "boom", retriable: true }, + ]); + }); + + test("returns empty array when no handlers match", async () => { + const registry = new HandlerRegistry(); + const results = await registry.dispatch(baseEvent); + expect(results).toEqual([]); + }); + }); + + describe("fluent API", () => { + test("register returns the registry for chaining", () => { + const registry = new HandlerRegistry(); + const returned = registry.register({}, makeHandler()); + expect(returned).toBe(registry); + }); + }); +}); + diff --git a/indexer/common/src/handlers/registry.ts b/indexer/common/src/handlers/registry.ts new file mode 100644 index 0000000..a5681ae --- /dev/null +++ b/indexer/common/src/handlers/registry.ts @@ -0,0 +1,42 @@ +import type { + EventHandler, + HandlerFilter, + HandlerResult, + SorobanEventInput, +} from "./types.js"; + +interface RegisteredHandler { + filter: HandlerFilter; + handler: EventHandler; +} + +export class HandlerRegistry { + private readonly entries: RegisteredHandler[] = []; + + register(filter: HandlerFilter, handler: EventHandler): this { + this.entries.push({ filter, handler }); + return this; + } + + matches(event: SorobanEventInput): EventHandler[] { + return this.entries + .filter(({ filter }) => { + if (filter.contractId && filter.contractId !== event.contractId) { + return false; + } + if (filter.topic && !event.topic.includes(filter.topic)) { + return false; + } + if (filter.eventName && !event.topic.includes(filter.eventName)) { + return false; + } + return true; + }) + .map(({ handler }) => handler); + } + + async dispatch(event: SorobanEventInput): Promise { + const handlers = this.matches(event); + return Promise.all(handlers.map((h) => h(event))); + } +} diff --git a/indexer/common/src/handlers/types.ts b/indexer/common/src/handlers/types.ts new file mode 100644 index 0000000..444c418 --- /dev/null +++ b/indexer/common/src/handlers/types.ts @@ -0,0 +1,21 @@ +export interface SorobanEventInput { + contractId: string; + ledger: number; + ledgerClosedAt: string; + topic: string[]; + data: unknown; + id: string; + pagingToken: string; +} + +export type HandlerResult = + | { ok: true } + | { ok: false; error: string; retriable: boolean }; + +export type EventHandler = (event: SorobanEventInput) => Promise; + +export interface HandlerFilter { + contractId?: string; + topic?: string; + eventName?: string; +} diff --git a/indexer/common/src/index.ts b/indexer/common/src/index.ts index cbe5437..ad2f597 100644 --- a/indexer/common/src/index.ts +++ b/indexer/common/src/index.ts @@ -8,3 +8,4 @@ export { createSorobanClient, sorobanClient } from "./rpc/client.js"; export { IndexedEvent } from "./db/entity/IndexedEvent.js"; export { EventRepository } from "./db/repository.js"; export { SorobanPoller, type PollerOptions, type PollResult } from "./poller/index.js"; +export * from "./handlers/index.js"; From 08ee9d439b61bcf19027f1891450ec9764a435cf Mon Sep 17 00:00:00 2001 From: Precious Igwealor Date: Sat, 27 Jun 2026 09:24:11 +0100 Subject: [PATCH 2/5] feat(indexer/streams): define full Streams GraphQL schema (#33) Expand the stub schema to cover the full public API shape: - `Stream` type with all fields (status, balances, timestamps, relations) - `WithdrawalAction` and `CancelAction` types - `StreamStatus` enum (ACTIVE / CANCELLED / COMPLETED) - `StreamFilterInput` and `PaginationInput` input types - `StreamConnection` / `PageInfo` for cursor-based pagination - `Query` type with `stream`, `streams`, `streamsByRecipient`, `streamsBySender` root fields --- indexer/streams/schema.graphql | 67 ++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/indexer/streams/schema.graphql b/indexer/streams/schema.graphql index 6fe88ce..1303db3 100644 --- a/indexer/streams/schema.graphql +++ b/indexer/streams/schema.graphql @@ -1,11 +1,78 @@ +enum StreamStatus { + ACTIVE + CANCELLED + COMPLETED +} + type Stream { id: ID! + contractId: String! + sender: String! + recipient: String! + token: String! + depositedAmount: String! + withdrawnAmount: String! + startTime: String! + stopTime: String! + status: StreamStatus! + ledger: Int! + ledgerClosedAt: String! + createdAt: String! + updatedAt: String! + withdrawals: [WithdrawalAction!]! + cancelAction: CancelAction } type WithdrawalAction { id: ID! + streamId: ID! + recipient: String! + amount: String! + ledger: Int! + ledgerClosedAt: String! + transactionHash: String! + createdAt: String! } type CancelAction { id: ID! + streamId: ID! + cancelledBy: String! + senderBalance: String! + recipientBalance: String! + ledger: Int! + ledgerClosedAt: String! + transactionHash: String! + createdAt: String! +} + +input StreamFilterInput { + sender: String + recipient: String + token: String + status: StreamStatus + contractId: String +} + +input PaginationInput { + first: Int + after: String +} + +type StreamConnection { + nodes: [Stream!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type PageInfo { + hasNextPage: Boolean! + endCursor: String +} + +type Query { + stream(id: ID!): Stream + streams(filter: StreamFilterInput, pagination: PaginationInput): StreamConnection! + streamsByRecipient(recipient: String!, pagination: PaginationInput): StreamConnection! + streamsBySender(sender: String!, pagination: PaginationInput): StreamConnection! } From 9efc8258d30559d62335523cc47756c102955253 Mon Sep 17 00:00:00 2001 From: Precious Igwealor Date: Sat, 27 Jun 2026 09:24:19 +0100 Subject: [PATCH 3/5] feat(indexer/streams): implement funded, withdrawal, and cancel handlers (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add payload types and parser helpers (`parseStreamFunded`, `parseStreamWithdrawal`, `parseStreamCancel`) in `handlers/types.ts` - Implement `streamFundedHandler`, `streamWithdrawalHandler`, and `streamCancelHandler` — each validates required fields, logs an info line, and returns `{ ok: true }` (or a non-retriable error for missing streamId); DB persistence is left as TODO pending #32 - Export all handlers from `streams/src/index.ts` - Add 7 unit tests with mocked payloads covering valid events and missing-streamId error paths for all three handlers --- indexer/streams/src/handlers/index.ts | 4 + .../src/handlers/stream-cancel.handler.ts | 31 +++++ .../src/handlers/stream-funded.handler.ts | 31 +++++ .../src/handlers/stream-handlers.test.ts | 112 ++++++++++++++++++ .../src/handlers/stream-withdrawal.handler.ts | 31 +++++ indexer/streams/src/handlers/types.ts | 56 +++++++++ indexer/streams/src/index.ts | 1 + 7 files changed, 266 insertions(+) create mode 100644 indexer/streams/src/handlers/index.ts create mode 100644 indexer/streams/src/handlers/stream-cancel.handler.ts create mode 100644 indexer/streams/src/handlers/stream-funded.handler.ts create mode 100644 indexer/streams/src/handlers/stream-handlers.test.ts create mode 100644 indexer/streams/src/handlers/stream-withdrawal.handler.ts create mode 100644 indexer/streams/src/handlers/types.ts diff --git a/indexer/streams/src/handlers/index.ts b/indexer/streams/src/handlers/index.ts new file mode 100644 index 0000000..060e416 --- /dev/null +++ b/indexer/streams/src/handlers/index.ts @@ -0,0 +1,4 @@ +export { streamFundedHandler } from "./stream-funded.handler.js"; +export { streamWithdrawalHandler } from "./stream-withdrawal.handler.js"; +export { streamCancelHandler } from "./stream-cancel.handler.js"; +export * from "./types.js"; diff --git a/indexer/streams/src/handlers/stream-cancel.handler.ts b/indexer/streams/src/handlers/stream-cancel.handler.ts new file mode 100644 index 0000000..d0972a3 --- /dev/null +++ b/indexer/streams/src/handlers/stream-cancel.handler.ts @@ -0,0 +1,31 @@ +import type { + EventHandler, + HandlerResult, + SorobanEventInput, +} from "@fundable-indexer/common"; +import { parseStreamCancel } from "./types.js"; + +export const streamCancelHandler: EventHandler = async ( + event: SorobanEventInput, +): Promise => { + try { + const payload = parseStreamCancel(event.data); + + if (!payload.streamId) { + return { ok: false, error: "Missing streamId in cancel event", retriable: false }; + } + + // TODO(#32): update stream status to CANCELLED via repository once DB schema is merged + console.info( + `[stream-cancel] streamId=${payload.streamId} cancelledBy=${payload.cancelledBy} senderBalance=${payload.senderBalance} ledger=${event.ledger}`, + ); + + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + retriable: true, + }; + } +}; diff --git a/indexer/streams/src/handlers/stream-funded.handler.ts b/indexer/streams/src/handlers/stream-funded.handler.ts new file mode 100644 index 0000000..6ed2dec --- /dev/null +++ b/indexer/streams/src/handlers/stream-funded.handler.ts @@ -0,0 +1,31 @@ +import type { + EventHandler, + HandlerResult, + SorobanEventInput, +} from "@fundable-indexer/common"; +import { parseStreamFunded } from "./types.js"; + +export const streamFundedHandler: EventHandler = async ( + event: SorobanEventInput, +): Promise => { + try { + const payload = parseStreamFunded(event.data); + + if (!payload.streamId) { + return { ok: false, error: "Missing streamId in funded event", retriable: false }; + } + + // TODO(#32): persist deposit via stream repository once DB schema is merged + console.info( + `[stream-funded] streamId=${payload.streamId} amount=${payload.amount} token=${payload.token} ledger=${event.ledger}`, + ); + + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + retriable: true, + }; + } +}; diff --git a/indexer/streams/src/handlers/stream-handlers.test.ts b/indexer/streams/src/handlers/stream-handlers.test.ts new file mode 100644 index 0000000..245831b --- /dev/null +++ b/indexer/streams/src/handlers/stream-handlers.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "vitest"; + +import type { SorobanEventInput } from "@fundable-indexer/common"; +import { streamCancelHandler } from "./stream-cancel.handler.js"; +import { streamFundedHandler } from "./stream-funded.handler.js"; +import { streamWithdrawalHandler } from "./stream-withdrawal.handler.js"; + +const baseEvent: SorobanEventInput = { + contractId: "CSTREAM123", + ledger: 200, + ledgerClosedAt: "2024-06-01T00:00:00Z", + topic: ["stream_funded"], + data: {}, + id: "event-2", + pagingToken: "paging-2", +}; + +describe("streamFundedHandler", () => { + test("returns ok for valid funded payload", async () => { + const event: SorobanEventInput = { + ...baseEvent, + topic: ["stream_funded"], + data: { + stream_id: "stream-1", + sender: "GSENDER", + amount: "5000", + token: "USDC", + tx_hash: "abc123", + }, + }; + + const result = await streamFundedHandler(event); + expect(result).toEqual({ ok: true }); + }); + + test("returns error when streamId is missing", async () => { + const event: SorobanEventInput = { + ...baseEvent, + data: { amount: "100", token: "XLM", sender: "G123" }, + }; + + const result = await streamFundedHandler(event); + expect(result).toMatchObject({ ok: false, retriable: false }); + }); + + test("handles unexpected data shape without throwing", async () => { + const event: SorobanEventInput = { + ...baseEvent, + data: null, + }; + + const result = await streamFundedHandler(event); + expect(result.ok).toBe(false); + }); +}); + +describe("streamWithdrawalHandler", () => { + test("returns ok for valid withdrawal payload", async () => { + const event: SorobanEventInput = { + ...baseEvent, + topic: ["stream_withdrawal"], + data: { + stream_id: "stream-1", + recipient: "GRECIPIENT", + amount: "250", + tx_hash: "def456", + }, + }; + + const result = await streamWithdrawalHandler(event); + expect(result).toEqual({ ok: true }); + }); + + test("returns error when streamId is missing", async () => { + const event: SorobanEventInput = { + ...baseEvent, + data: { recipient: "G123", amount: "50" }, + }; + + const result = await streamWithdrawalHandler(event); + expect(result).toMatchObject({ ok: false, retriable: false }); + }); +}); + +describe("streamCancelHandler", () => { + test("returns ok for valid cancel payload", async () => { + const event: SorobanEventInput = { + ...baseEvent, + topic: ["stream_cancel"], + data: { + stream_id: "stream-1", + cancelled_by: "GSENDER", + sender_balance: "4750", + recipient_balance: "250", + tx_hash: "ghi789", + }, + }; + + const result = await streamCancelHandler(event); + expect(result).toEqual({ ok: true }); + }); + + test("returns error when streamId is missing", async () => { + const event: SorobanEventInput = { + ...baseEvent, + data: { cancelled_by: "G123" }, + }; + + const result = await streamCancelHandler(event); + expect(result).toMatchObject({ ok: false, retriable: false }); + }); +}); diff --git a/indexer/streams/src/handlers/stream-withdrawal.handler.ts b/indexer/streams/src/handlers/stream-withdrawal.handler.ts new file mode 100644 index 0000000..8169432 --- /dev/null +++ b/indexer/streams/src/handlers/stream-withdrawal.handler.ts @@ -0,0 +1,31 @@ +import type { + EventHandler, + HandlerResult, + SorobanEventInput, +} from "@fundable-indexer/common"; +import { parseStreamWithdrawal } from "./types.js"; + +export const streamWithdrawalHandler: EventHandler = async ( + event: SorobanEventInput, +): Promise => { + try { + const payload = parseStreamWithdrawal(event.data); + + if (!payload.streamId) { + return { ok: false, error: "Missing streamId in withdrawal event", retriable: false }; + } + + // TODO(#32): record withdrawal action via repository once DB schema is merged + console.info( + `[stream-withdrawal] streamId=${payload.streamId} recipient=${payload.recipient} amount=${payload.amount} ledger=${event.ledger}`, + ); + + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + retriable: true, + }; + } +}; diff --git a/indexer/streams/src/handlers/types.ts b/indexer/streams/src/handlers/types.ts new file mode 100644 index 0000000..b195cbe --- /dev/null +++ b/indexer/streams/src/handlers/types.ts @@ -0,0 +1,56 @@ +export interface StreamFundedPayload { + streamId: string; + sender: string; + amount: string; + token: string; + transactionHash: string; +} + +export interface StreamWithdrawalPayload { + streamId: string; + recipient: string; + amount: string; + transactionHash: string; +} + +export interface StreamCancelPayload { + streamId: string; + cancelledBy: string; + senderBalance: string; + recipientBalance: string; + transactionHash: string; +} + +export function parseStreamFunded(data: unknown): StreamFundedPayload { + const d = data as Record; + return { + streamId: String(d.streamId ?? d.stream_id ?? ""), + sender: String(d.sender ?? ""), + amount: String(d.amount ?? "0"), + token: String(d.token ?? ""), + transactionHash: String(d.transactionHash ?? d.tx_hash ?? ""), + }; +} + +export function parseStreamWithdrawal(data: unknown): StreamWithdrawalPayload { + const d = data as Record; + return { + streamId: String(d.streamId ?? d.stream_id ?? ""), + recipient: String(d.recipient ?? ""), + amount: String(d.amount ?? "0"), + transactionHash: String(d.transactionHash ?? d.tx_hash ?? ""), + }; +} + +export function parseStreamCancel(data: unknown): StreamCancelPayload { + const d = data as Record; + return { + streamId: String(d.streamId ?? d.stream_id ?? ""), + cancelledBy: String(d.cancelledBy ?? d.cancelled_by ?? ""), + senderBalance: String(d.senderBalance ?? d.sender_balance ?? "0"), + recipientBalance: String( + d.recipientBalance ?? d.recipient_balance ?? "0", + ), + transactionHash: String(d.transactionHash ?? d.tx_hash ?? ""), + }; +} diff --git a/indexer/streams/src/index.ts b/indexer/streams/src/index.ts index 2da94b9..94682ad 100644 --- a/indexer/streams/src/index.ts +++ b/indexer/streams/src/index.ts @@ -9,3 +9,4 @@ export const streamsPackage = { export { Stream } from "./db/entity/Stream.js"; export { WithdrawalAction } from "./db/entity/WithdrawalAction.js"; export { CancelAction } from "./db/entity/CancelAction.js"; +export * from "./handlers/index.js"; From a625693846aa26ba56fbde8a9d5b9d1bd02cd28d Mon Sep 17 00:00:00 2001 From: Precious Igwealor Date: Sat, 27 Jun 2026 09:26:20 +0100 Subject: [PATCH 4/5] feat(indexer/distributions): implement distribution event handlers (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add payload types and parsers for distribution-created, tokens-claimed, distribution-paused, and distribution-resumed events in `handlers/types.ts` - Implement `distributionCreatedHandler`, `tokensClaimedHandler`, `distributionPausedHandler`, and `distributionResumedHandler` — each validates the required distributionId, logs, and returns `{ ok: true }`; DB persistence is TODO pending #36 (repository layer) and #27 (event store) - Export all handlers from `distributions/src/index.ts` - Add 9 unit tests covering valid payloads, missing-distributionId errors, and an idempotency test for `distributionCreatedHandler` --- .../handlers/distribution-created.handler.ts | 36 +++++ .../handlers/distribution-handlers.test.ts | 148 ++++++++++++++++++ .../handlers/distribution-pause.handler.ts | 64 ++++++++ indexer/distributions/src/handlers/index.ts | 7 + .../src/handlers/tokens-claimed.handler.ts | 36 +++++ indexer/distributions/src/handlers/types.ts | 82 ++++++++++ indexer/distributions/src/index.ts | 2 + 7 files changed, 375 insertions(+) create mode 100644 indexer/distributions/src/handlers/distribution-created.handler.ts create mode 100644 indexer/distributions/src/handlers/distribution-handlers.test.ts create mode 100644 indexer/distributions/src/handlers/distribution-pause.handler.ts create mode 100644 indexer/distributions/src/handlers/index.ts create mode 100644 indexer/distributions/src/handlers/tokens-claimed.handler.ts create mode 100644 indexer/distributions/src/handlers/types.ts diff --git a/indexer/distributions/src/handlers/distribution-created.handler.ts b/indexer/distributions/src/handlers/distribution-created.handler.ts new file mode 100644 index 0000000..3915974 --- /dev/null +++ b/indexer/distributions/src/handlers/distribution-created.handler.ts @@ -0,0 +1,36 @@ +import type { + EventHandler, + HandlerResult, + SorobanEventInput, +} from "@fundable-indexer/common"; +import { parseDistributionCreated } from "./types.js"; + +export const distributionCreatedHandler: EventHandler = async ( + event: SorobanEventInput, +): Promise => { + try { + const payload = parseDistributionCreated(event.data); + + if (!payload.distributionId) { + return { + ok: false, + error: "Missing distributionId in distribution_created event", + retriable: false, + }; + } + + // TODO(#36): persist distribution batch via repository once DB schema is merged + // TODO(#27): record indexed event identity via event repository + console.info( + `[distribution-created] id=${payload.distributionId} creator=${payload.creator} token=${payload.token} total=${payload.totalAmount} ledger=${event.ledger}`, + ); + + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + retriable: true, + }; + } +}; diff --git a/indexer/distributions/src/handlers/distribution-handlers.test.ts b/indexer/distributions/src/handlers/distribution-handlers.test.ts new file mode 100644 index 0000000..6c68d32 --- /dev/null +++ b/indexer/distributions/src/handlers/distribution-handlers.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "vitest"; + +import type { SorobanEventInput } from "@fundable-indexer/common"; +import { distributionCreatedHandler } from "./distribution-created.handler.js"; +import { + distributionPausedHandler, + distributionResumedHandler, +} from "./distribution-pause.handler.js"; +import { tokensClaimedHandler } from "./tokens-claimed.handler.js"; + +const baseEvent: SorobanEventInput = { + contractId: "CDIST456", + ledger: 300, + ledgerClosedAt: "2024-06-15T00:00:00Z", + topic: ["distribution_created"], + data: {}, + id: "event-3", + pagingToken: "paging-3", +}; + +describe("distributionCreatedHandler", () => { + test("returns ok for valid created payload", async () => { + const event: SorobanEventInput = { + ...baseEvent, + data: { + distribution_id: "dist-1", + creator: "GCREATOR", + token: "USDC", + total_amount: "100000", + recipient_count: 50, + tx_hash: "txabc", + }, + }; + + const result = await distributionCreatedHandler(event); + expect(result).toEqual({ ok: true }); + }); + + test("returns error when distributionId is missing", async () => { + const event: SorobanEventInput = { + ...baseEvent, + data: { creator: "GCREATOR", token: "USDC" }, + }; + + const result = await distributionCreatedHandler(event); + expect(result).toMatchObject({ ok: false, retriable: false }); + }); + + test("is idempotent — processes same payload without throwing", async () => { + const payload = { + distribution_id: "dist-idempotent", + creator: "G123", + token: "XLM", + total_amount: "500", + recipient_count: 5, + tx_hash: "txidem", + }; + + const results = await Promise.all([ + distributionCreatedHandler({ ...baseEvent, data: payload }), + distributionCreatedHandler({ ...baseEvent, data: payload }), + ]); + + expect(results.every((r) => r.ok)).toBe(true); + }); +}); + +describe("tokensClaimedHandler", () => { + test("returns ok for valid claim payload", async () => { + const event: SorobanEventInput = { + ...baseEvent, + topic: ["tokens_claimed"], + data: { + distribution_id: "dist-1", + claimant: "GCLAIMANT", + amount: "2000", + tx_hash: "txclaim", + }, + }; + + const result = await tokensClaimedHandler(event); + expect(result).toEqual({ ok: true }); + }); + + test("returns error when distributionId is missing", async () => { + const event: SorobanEventInput = { + ...baseEvent, + data: { claimant: "GCLAIM", amount: "10" }, + }; + + const result = await tokensClaimedHandler(event); + expect(result).toMatchObject({ ok: false, retriable: false }); + }); +}); + +describe("distributionPausedHandler", () => { + test("returns ok for valid paused payload", async () => { + const event: SorobanEventInput = { + ...baseEvent, + topic: ["distribution_paused"], + data: { + distribution_id: "dist-1", + paused_by: "GADMIN", + tx_hash: "txpause", + }, + }; + + const result = await distributionPausedHandler(event); + expect(result).toEqual({ ok: true }); + }); + + test("returns error when distributionId is missing", async () => { + const event: SorobanEventInput = { + ...baseEvent, + data: { paused_by: "GADMIN" }, + }; + + const result = await distributionPausedHandler(event); + expect(result).toMatchObject({ ok: false, retriable: false }); + }); +}); + +describe("distributionResumedHandler", () => { + test("returns ok for valid resumed payload", async () => { + const event: SorobanEventInput = { + ...baseEvent, + topic: ["distribution_resumed"], + data: { + distribution_id: "dist-1", + resumed_by: "GADMIN", + tx_hash: "txresume", + }, + }; + + const result = await distributionResumedHandler(event); + expect(result).toEqual({ ok: true }); + }); + + test("returns error when distributionId is missing", async () => { + const event: SorobanEventInput = { + ...baseEvent, + data: { resumed_by: "GADMIN" }, + }; + + const result = await distributionResumedHandler(event); + expect(result).toMatchObject({ ok: false, retriable: false }); + }); +}); diff --git a/indexer/distributions/src/handlers/distribution-pause.handler.ts b/indexer/distributions/src/handlers/distribution-pause.handler.ts new file mode 100644 index 0000000..065e19c --- /dev/null +++ b/indexer/distributions/src/handlers/distribution-pause.handler.ts @@ -0,0 +1,64 @@ +import type { + EventHandler, + HandlerResult, + SorobanEventInput, +} from "@fundable-indexer/common"; +import { parseDistributionPaused, parseDistributionResumed } from "./types.js"; + +export const distributionPausedHandler: EventHandler = async ( + event: SorobanEventInput, +): Promise => { + try { + const payload = parseDistributionPaused(event.data); + + if (!payload.distributionId) { + return { + ok: false, + error: "Missing distributionId in distribution_paused event", + retriable: false, + }; + } + + // TODO(#36): update distribution status to PAUSED via repository + console.info( + `[distribution-paused] id=${payload.distributionId} by=${payload.pausedBy} ledger=${event.ledger}`, + ); + + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + retriable: true, + }; + } +}; + +export const distributionResumedHandler: EventHandler = async ( + event: SorobanEventInput, +): Promise => { + try { + const payload = parseDistributionResumed(event.data); + + if (!payload.distributionId) { + return { + ok: false, + error: "Missing distributionId in distribution_resumed event", + retriable: false, + }; + } + + // TODO(#36): update distribution status to ACTIVE via repository + console.info( + `[distribution-resumed] id=${payload.distributionId} by=${payload.resumedBy} ledger=${event.ledger}`, + ); + + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + retriable: true, + }; + } +}; diff --git a/indexer/distributions/src/handlers/index.ts b/indexer/distributions/src/handlers/index.ts new file mode 100644 index 0000000..cad4931 --- /dev/null +++ b/indexer/distributions/src/handlers/index.ts @@ -0,0 +1,7 @@ +export { distributionCreatedHandler } from "./distribution-created.handler.js"; +export { tokensClaimedHandler } from "./tokens-claimed.handler.js"; +export { + distributionPausedHandler, + distributionResumedHandler, +} from "./distribution-pause.handler.js"; +export * from "./types.js"; diff --git a/indexer/distributions/src/handlers/tokens-claimed.handler.ts b/indexer/distributions/src/handlers/tokens-claimed.handler.ts new file mode 100644 index 0000000..21c2b18 --- /dev/null +++ b/indexer/distributions/src/handlers/tokens-claimed.handler.ts @@ -0,0 +1,36 @@ +import type { + EventHandler, + HandlerResult, + SorobanEventInput, +} from "@fundable-indexer/common"; +import { parseTokensClaimed } from "./types.js"; + +export const tokensClaimedHandler: EventHandler = async ( + event: SorobanEventInput, +): Promise => { + try { + const payload = parseTokensClaimed(event.data); + + if (!payload.distributionId) { + return { + ok: false, + error: "Missing distributionId in tokens_claimed event", + retriable: false, + }; + } + + // TODO(#36): record claim action via repository once DB schema is merged + // TODO(#27): record indexed event identity via event repository + console.info( + `[tokens-claimed] distributionId=${payload.distributionId} claimant=${payload.claimant} amount=${payload.amount} ledger=${event.ledger}`, + ); + + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + retriable: true, + }; + } +}; diff --git a/indexer/distributions/src/handlers/types.ts b/indexer/distributions/src/handlers/types.ts new file mode 100644 index 0000000..267ddbb --- /dev/null +++ b/indexer/distributions/src/handlers/types.ts @@ -0,0 +1,82 @@ +export interface DistributionCreatedPayload { + distributionId: string; + creator: string; + token: string; + totalAmount: string; + recipientCount: number; + transactionHash: string; +} + +export interface TokensClaimedPayload { + distributionId: string; + claimant: string; + amount: string; + transactionHash: string; +} + +export interface DistributionPausedPayload { + distributionId: string; + pausedBy: string; + transactionHash: string; +} + +export interface DistributionResumedPayload { + distributionId: string; + resumedBy: string; + transactionHash: string; +} + +function str(v: unknown): string { + return v !== undefined && v !== null ? String(v) : ""; +} + +function num(v: unknown): number { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +export function parseDistributionCreated( + data: unknown, +): DistributionCreatedPayload { + const d = data as Record; + return { + distributionId: str(d.distributionId ?? d.distribution_id), + creator: str(d.creator), + token: str(d.token), + totalAmount: str(d.totalAmount ?? d.total_amount ?? "0"), + recipientCount: num(d.recipientCount ?? d.recipient_count), + transactionHash: str(d.transactionHash ?? d.tx_hash), + }; +} + +export function parseTokensClaimed(data: unknown): TokensClaimedPayload { + const d = data as Record; + return { + distributionId: str(d.distributionId ?? d.distribution_id), + claimant: str(d.claimant), + amount: str(d.amount ?? "0"), + transactionHash: str(d.transactionHash ?? d.tx_hash), + }; +} + +export function parseDistributionPaused( + data: unknown, +): DistributionPausedPayload { + const d = data as Record; + return { + distributionId: str(d.distributionId ?? d.distribution_id), + pausedBy: str(d.pausedBy ?? d.paused_by), + transactionHash: str(d.transactionHash ?? d.tx_hash), + }; +} + +export function parseDistributionResumed( + data: unknown, +): DistributionResumedPayload { + const d = data as Record; + return { + distributionId: str(d.distributionId ?? d.distribution_id), + resumedBy: str(d.resumedBy ?? d.resumed_by), + transactionHash: str(d.transactionHash ?? d.tx_hash), + }; +} diff --git a/indexer/distributions/src/index.ts b/indexer/distributions/src/index.ts index 51d59b6..f231142 100644 --- a/indexer/distributions/src/index.ts +++ b/indexer/distributions/src/index.ts @@ -5,3 +5,5 @@ export const distributionsPackage = { role: "distribution-indexer", common: commonPackage.name, } as const; + +export * from "./handlers/index.js"; From 5621be3ab76bc1f6699670c5a539e94da27ade6e Mon Sep 17 00:00:00 2001 From: Precious Igwealor Date: Sun, 28 Jun 2026 07:11:03 +0100 Subject: [PATCH 5/5] fix(indexer): address CodeRabbit review findings on handler robustness - registry: catch per-handler rejections in dispatch() so one failure does not abort the entire batch (Promise.all propagation fix) - distributions/types: add record() guard before dereferencing event.data to prevent poison-message loops on null/primitive payloads - distribution-created: validate creator, token, totalAmount, transactionHash, and recipientCount > 0 in addition to distributionId - distribution-pause: validate pausedBy/resumedBy and transactionHash for both pause and resume handlers - tokens-claimed: validate claimant, transactionHash, and non-zero amount - streams/types: replace fabricated "" / "0" defaults with undefined for absent fields using a safe record() coercion helper and nullable str() - stream handlers: add missing field validation for sender/recipient/ cancelledBy, amount, token, and transactionHash --- indexer/common/src/handlers/registry.ts | 10 ++- .../handlers/distribution-created.handler.ts | 40 ++++++++++ .../handlers/distribution-pause.handler.ts | 32 ++++++++ .../src/handlers/tokens-claimed.handler.ts | 24 ++++++ indexer/distributions/src/handlers/types.ts | 12 ++- .../src/handlers/stream-cancel.handler.ts | 8 ++ .../src/handlers/stream-funded.handler.ts | 16 ++++ .../src/handlers/stream-withdrawal.handler.ts | 12 +++ indexer/streams/src/handlers/types.ts | 74 ++++++++++--------- 9 files changed, 190 insertions(+), 38 deletions(-) diff --git a/indexer/common/src/handlers/registry.ts b/indexer/common/src/handlers/registry.ts index a5681ae..07a965e 100644 --- a/indexer/common/src/handlers/registry.ts +++ b/indexer/common/src/handlers/registry.ts @@ -37,6 +37,14 @@ export class HandlerRegistry { async dispatch(event: SorobanEventInput): Promise { const handlers = this.matches(event); - return Promise.all(handlers.map((h) => h(event))); + return Promise.all( + handlers.map((h) => + h(event).catch((err) => ({ + ok: false as const, + error: err instanceof Error ? err.message : String(err), + retriable: true, + })) + ) + ); } } diff --git a/indexer/distributions/src/handlers/distribution-created.handler.ts b/indexer/distributions/src/handlers/distribution-created.handler.ts index 3915974..ea8cee0 100644 --- a/indexer/distributions/src/handlers/distribution-created.handler.ts +++ b/indexer/distributions/src/handlers/distribution-created.handler.ts @@ -19,6 +19,46 @@ export const distributionCreatedHandler: EventHandler = async ( }; } + if (!payload.creator) { + return { + ok: false, + error: "Missing creator in distribution_created event", + retriable: false, + }; + } + + if (!payload.token) { + return { + ok: false, + error: "Missing token in distribution_created event", + retriable: false, + }; + } + + if (!payload.totalAmount) { + return { + ok: false, + error: "Missing totalAmount in distribution_created event", + retriable: false, + }; + } + + if (!payload.transactionHash) { + return { + ok: false, + error: "Missing transactionHash in distribution_created event", + retriable: false, + }; + } + + if (payload.recipientCount <= 0) { + return { + ok: false, + error: "Invalid recipientCount in distribution_created event", + retriable: false, + }; + } + // TODO(#36): persist distribution batch via repository once DB schema is merged // TODO(#27): record indexed event identity via event repository console.info( diff --git a/indexer/distributions/src/handlers/distribution-pause.handler.ts b/indexer/distributions/src/handlers/distribution-pause.handler.ts index 065e19c..2f73ff3 100644 --- a/indexer/distributions/src/handlers/distribution-pause.handler.ts +++ b/indexer/distributions/src/handlers/distribution-pause.handler.ts @@ -19,6 +19,22 @@ export const distributionPausedHandler: EventHandler = async ( }; } + if (!payload.pausedBy) { + return { + ok: false, + error: "Missing pausedBy in distribution_paused event", + retriable: false, + }; + } + + if (!payload.transactionHash) { + return { + ok: false, + error: "Missing transactionHash in distribution_paused event", + retriable: false, + }; + } + // TODO(#36): update distribution status to PAUSED via repository console.info( `[distribution-paused] id=${payload.distributionId} by=${payload.pausedBy} ledger=${event.ledger}`, @@ -48,6 +64,22 @@ export const distributionResumedHandler: EventHandler = async ( }; } + if (!payload.resumedBy) { + return { + ok: false, + error: "Missing resumedBy in distribution_resumed event", + retriable: false, + }; + } + + if (!payload.transactionHash) { + return { + ok: false, + error: "Missing transactionHash in distribution_resumed event", + retriable: false, + }; + } + // TODO(#36): update distribution status to ACTIVE via repository console.info( `[distribution-resumed] id=${payload.distributionId} by=${payload.resumedBy} ledger=${event.ledger}`, diff --git a/indexer/distributions/src/handlers/tokens-claimed.handler.ts b/indexer/distributions/src/handlers/tokens-claimed.handler.ts index 21c2b18..10b5302 100644 --- a/indexer/distributions/src/handlers/tokens-claimed.handler.ts +++ b/indexer/distributions/src/handlers/tokens-claimed.handler.ts @@ -19,6 +19,30 @@ export const tokensClaimedHandler: EventHandler = async ( }; } + if (!payload.claimant) { + return { + ok: false, + error: "Missing claimant in tokens_claimed event", + retriable: false, + }; + } + + if (!payload.transactionHash) { + return { + ok: false, + error: "Missing transactionHash in tokens_claimed event", + retriable: false, + }; + } + + if (!payload.amount || payload.amount === "0") { + return { + ok: false, + error: "Missing or zero amount in tokens_claimed event", + retriable: false, + }; + } + // TODO(#36): record claim action via repository once DB schema is merged // TODO(#27): record indexed event identity via event repository console.info( diff --git a/indexer/distributions/src/handlers/types.ts b/indexer/distributions/src/handlers/types.ts index 267ddbb..7aa6f4b 100644 --- a/indexer/distributions/src/handlers/types.ts +++ b/indexer/distributions/src/handlers/types.ts @@ -26,6 +26,10 @@ export interface DistributionResumedPayload { transactionHash: string; } +function record(v: unknown): Record { + return v !== null && typeof v === "object" ? (v as Record) : {}; +} + function str(v: unknown): string { return v !== undefined && v !== null ? String(v) : ""; } @@ -38,7 +42,7 @@ function num(v: unknown): number { export function parseDistributionCreated( data: unknown, ): DistributionCreatedPayload { - const d = data as Record; + const d = record(data); return { distributionId: str(d.distributionId ?? d.distribution_id), creator: str(d.creator), @@ -50,7 +54,7 @@ export function parseDistributionCreated( } export function parseTokensClaimed(data: unknown): TokensClaimedPayload { - const d = data as Record; + const d = record(data); return { distributionId: str(d.distributionId ?? d.distribution_id), claimant: str(d.claimant), @@ -62,7 +66,7 @@ export function parseTokensClaimed(data: unknown): TokensClaimedPayload { export function parseDistributionPaused( data: unknown, ): DistributionPausedPayload { - const d = data as Record; + const d = record(data); return { distributionId: str(d.distributionId ?? d.distribution_id), pausedBy: str(d.pausedBy ?? d.paused_by), @@ -73,7 +77,7 @@ export function parseDistributionPaused( export function parseDistributionResumed( data: unknown, ): DistributionResumedPayload { - const d = data as Record; + const d = record(data); return { distributionId: str(d.distributionId ?? d.distribution_id), resumedBy: str(d.resumedBy ?? d.resumed_by), diff --git a/indexer/streams/src/handlers/stream-cancel.handler.ts b/indexer/streams/src/handlers/stream-cancel.handler.ts index d0972a3..2b3a916 100644 --- a/indexer/streams/src/handlers/stream-cancel.handler.ts +++ b/indexer/streams/src/handlers/stream-cancel.handler.ts @@ -15,6 +15,14 @@ export const streamCancelHandler: EventHandler = async ( return { ok: false, error: "Missing streamId in cancel event", retriable: false }; } + if (!payload.cancelledBy) { + return { ok: false, error: "Missing cancelledBy in cancel event", retriable: false }; + } + + if (!payload.transactionHash) { + return { ok: false, error: "Missing transactionHash in cancel event", retriable: false }; + } + // TODO(#32): update stream status to CANCELLED via repository once DB schema is merged console.info( `[stream-cancel] streamId=${payload.streamId} cancelledBy=${payload.cancelledBy} senderBalance=${payload.senderBalance} ledger=${event.ledger}`, diff --git a/indexer/streams/src/handlers/stream-funded.handler.ts b/indexer/streams/src/handlers/stream-funded.handler.ts index 6ed2dec..c0e951c 100644 --- a/indexer/streams/src/handlers/stream-funded.handler.ts +++ b/indexer/streams/src/handlers/stream-funded.handler.ts @@ -15,6 +15,22 @@ export const streamFundedHandler: EventHandler = async ( return { ok: false, error: "Missing streamId in funded event", retriable: false }; } + if (!payload.sender) { + return { ok: false, error: "Missing sender in funded event", retriable: false }; + } + + if (!payload.token) { + return { ok: false, error: "Missing token in funded event", retriable: false }; + } + + if (!payload.amount) { + return { ok: false, error: "Missing amount in funded event", retriable: false }; + } + + if (!payload.transactionHash) { + return { ok: false, error: "Missing transactionHash in funded event", retriable: false }; + } + // TODO(#32): persist deposit via stream repository once DB schema is merged console.info( `[stream-funded] streamId=${payload.streamId} amount=${payload.amount} token=${payload.token} ledger=${event.ledger}`, diff --git a/indexer/streams/src/handlers/stream-withdrawal.handler.ts b/indexer/streams/src/handlers/stream-withdrawal.handler.ts index 8169432..1e03219 100644 --- a/indexer/streams/src/handlers/stream-withdrawal.handler.ts +++ b/indexer/streams/src/handlers/stream-withdrawal.handler.ts @@ -15,6 +15,18 @@ export const streamWithdrawalHandler: EventHandler = async ( return { ok: false, error: "Missing streamId in withdrawal event", retriable: false }; } + if (!payload.recipient) { + return { ok: false, error: "Missing recipient in withdrawal event", retriable: false }; + } + + if (!payload.amount) { + return { ok: false, error: "Missing amount in withdrawal event", retriable: false }; + } + + if (!payload.transactionHash) { + return { ok: false, error: "Missing transactionHash in withdrawal event", retriable: false }; + } + // TODO(#32): record withdrawal action via repository once DB schema is merged console.info( `[stream-withdrawal] streamId=${payload.streamId} recipient=${payload.recipient} amount=${payload.amount} ledger=${event.ledger}`, diff --git a/indexer/streams/src/handlers/types.ts b/indexer/streams/src/handlers/types.ts index b195cbe..92bb017 100644 --- a/indexer/streams/src/handlers/types.ts +++ b/indexer/streams/src/handlers/types.ts @@ -1,56 +1,64 @@ export interface StreamFundedPayload { - streamId: string; - sender: string; - amount: string; - token: string; - transactionHash: string; + streamId: string | undefined; + sender: string | undefined; + amount: string | undefined; + token: string | undefined; + transactionHash: string | undefined; } export interface StreamWithdrawalPayload { - streamId: string; - recipient: string; - amount: string; - transactionHash: string; + streamId: string | undefined; + recipient: string | undefined; + amount: string | undefined; + transactionHash: string | undefined; } export interface StreamCancelPayload { - streamId: string; - cancelledBy: string; - senderBalance: string; - recipientBalance: string; - transactionHash: string; + streamId: string | undefined; + cancelledBy: string | undefined; + senderBalance: string | undefined; + recipientBalance: string | undefined; + transactionHash: string | undefined; +} + +function record(v: unknown): Record { + return v !== null && typeof v === "object" ? (v as Record) : {}; +} + +function str(v: unknown): string | undefined { + if (v === undefined || v === null || v === "") return undefined; + const s = String(v); + return s === "" ? undefined : s; } export function parseStreamFunded(data: unknown): StreamFundedPayload { - const d = data as Record; + const d = record(data); return { - streamId: String(d.streamId ?? d.stream_id ?? ""), - sender: String(d.sender ?? ""), - amount: String(d.amount ?? "0"), - token: String(d.token ?? ""), - transactionHash: String(d.transactionHash ?? d.tx_hash ?? ""), + streamId: str(d.streamId ?? d.stream_id), + sender: str(d.sender), + amount: str(d.amount), + token: str(d.token), + transactionHash: str(d.transactionHash ?? d.tx_hash), }; } export function parseStreamWithdrawal(data: unknown): StreamWithdrawalPayload { - const d = data as Record; + const d = record(data); return { - streamId: String(d.streamId ?? d.stream_id ?? ""), - recipient: String(d.recipient ?? ""), - amount: String(d.amount ?? "0"), - transactionHash: String(d.transactionHash ?? d.tx_hash ?? ""), + streamId: str(d.streamId ?? d.stream_id), + recipient: str(d.recipient), + amount: str(d.amount), + transactionHash: str(d.transactionHash ?? d.tx_hash), }; } export function parseStreamCancel(data: unknown): StreamCancelPayload { - const d = data as Record; + const d = record(data); return { - streamId: String(d.streamId ?? d.stream_id ?? ""), - cancelledBy: String(d.cancelledBy ?? d.cancelled_by ?? ""), - senderBalance: String(d.senderBalance ?? d.sender_balance ?? "0"), - recipientBalance: String( - d.recipientBalance ?? d.recipient_balance ?? "0", - ), - transactionHash: String(d.transactionHash ?? d.tx_hash ?? ""), + streamId: str(d.streamId ?? d.stream_id), + cancelledBy: str(d.cancelledBy ?? d.cancelled_by), + senderBalance: str(d.senderBalance ?? d.sender_balance), + recipientBalance: str(d.recipientBalance ?? d.recipient_balance), + transactionHash: str(d.transactionHash ?? d.tx_hash), }; }