UNPKG

@arkiv-network/sdk

Version:

TypeScript client library for Arkiv's blockchains interactions

408 lines (350 loc) • 12.5 kB
import { describe, expect, it } from "bun:test" import { type Abi, encodeAbiParameters, encodeEventTopics, getAbiItem, type Hex } from "viem" import type { ArkivClient } from "../../clients/baseClient" import { ARKIV_ADDRESS } from "../../consts" import { ENTITY_EVENTS_ABI, type EntityEventName } from "../../entity/events" import type { EntityEvent } from "../../types/events" import { watchEntityEvents } from "./watchEntityEvents" const KEY_A = `0x${"aa".repeat(32)}` as Hex const KEY_B = `0x${"bb".repeat(32)}` as Hex const ALICE = "0x1111111111111111111111111111111111111111" as const const BOB = "0x2222222222222222222222222222222222222222" as const type Log = { topics: Hex[] data: Hex blockNumber: bigint | null transactionHash: Hex | null logIndex: number | null } /** * Builds the log the engine would emit for an event, from the same ABI the watcher decodes with. * * Encoding rather than hand-writing topics is what keeps the test honest about the indexed/ * non-indexed split: change the ABI and the log changes with it. */ function logFor( eventName: EntityEventName, args: Record<string, unknown>, position: { blockNumber?: bigint; transactionHash?: Hex; logIndex?: number } = {}, ): Log { const item = getAbiItem({ abi: ENTITY_EVENTS_ABI as Abi, name: eventName }) if (item === undefined || item.type !== "event") throw new Error(`no event ${eventName}`) const unindexed = item.inputs.filter((input) => !("indexed" in input && input.indexed)) return { topics: encodeEventTopics({ abi: ENTITY_EVENTS_ABI, eventName, // biome-ignore lint/suspicious/noExplicitAny: the arg shape varies per event by design. args: args as any, }) as Hex[], data: unindexed.length === 0 ? "0x" : encodeAbiParameters( unindexed, unindexed.map((input) => args[input.name ?? ""]), ), blockNumber: position.blockNumber ?? 100n, transactionHash: position.transactionHash ?? (`0x${"cc".repeat(32)}` as Hex), logIndex: position.logIndex ?? 0, } } type Harness = { client: ArkivClient /** Feeds logs to the watcher, as the poller would. */ emitLogs: (logs: Log[]) => void /** Raises a transport error on the log watcher. */ failLogs: (error: Error) => void watchEventCalls: Record<string, unknown>[] stops: string[] } /** A client that hands back its watcher callbacks so a test can drive them directly. */ function harness(): Harness { const state: Partial<{ onLogs: (logs: Log[]) => void onLogsError: (error: Error) => void }> = {} const watchEventCalls: Record<string, unknown>[] = [] const stops: string[] = [] const client = { // biome-ignore lint/suspicious/noExplicitAny: a stand-in for viem's generic watcher. watchEvent(parameters: any) { watchEventCalls.push(parameters) state.onLogs = parameters.onLogs state.onLogsError = parameters.onError return () => stops.push("logs") }, } as unknown as ArkivClient return { client, emitLogs: (logs) => state.onLogs?.(logs), failLogs: (error) => state.onLogsError?.(error), watchEventCalls, stops, } } /** Runs `body` with `console.error` captured, so a test can assert on the default error path. */ function captureConsoleError(body: () => void): unknown[][] { const calls: unknown[][] = [] const original = console.error console.error = (...args: unknown[]) => { calls.push(args) } try { body() } finally { console.error = original } return calls } describe("decoding the five on-chain events", () => { it("decodes a create, with the position it was applied at", () => { const h = harness() const seen: EntityEvent[] = [] watchEntityEvents(h.client, { onEntityCreated: (event) => seen.push(event) }) h.emitLogs([ logFor( "EntityCreated", { entityKey: KEY_A, owner: ALICE, expiresAt: 1_200_000n, creationFlags: 0b11 }, { blockNumber: 42n, logIndex: 3 }, ), ]) expect(seen).toEqual([ { type: "EntityCreated", entityKey: KEY_A, owner: ALICE, expiresAt: 1_200_000n, // Decoded, with the byte kept alongside so a flag this SDK has no name for stays visible. creationFlags: { readonly: true, permissionlessExtension: true, raw: 0b11 }, blockNumber: 42n, transactionHash: `0x${"cc".repeat(32)}`, logIndex: 3, }, ]) }) it("decodes a patch", () => { const h = harness() const seen: EntityEvent[] = [] watchEntityEvents(h.client, { onEntityPatched: (event) => seen.push(event) }) h.emitLogs([logFor("EntityPatched", { entityKey: KEY_A, owner: ALICE })]) expect(seen).toMatchObject([{ type: "EntityPatched", entityKey: KEY_A, owner: ALICE }]) }) it("decodes an extension", () => { const h = harness() const seen: EntityEvent[] = [] watchEntityEvents(h.client, { onExpiryExtended: (event) => seen.push(event) }) h.emitLogs([logFor("ExpiryExtended", { entityKey: KEY_A, owner: ALICE, expiresAt: 999n })]) // `owner` is the entity's owner, not the caller — a permissionless extension is by a non-owner. expect(seen).toMatchObject([ { type: "ExpiryExtended", entityKey: KEY_A, owner: ALICE, expiresAt: 999n }, ]) }) it("decodes a transfer, naming both sides", () => { const h = harness() const seen: EntityEvent[] = [] watchEntityEvents(h.client, { onOwnershipTransferred: (event) => seen.push(event) }) h.emitLogs([ logFor("OwnershipTransferred", { entityKey: KEY_A, previousOwner: ALICE, newOwner: BOB }), ]) expect(seen).toMatchObject([ { type: "OwnershipTransferred", entityKey: KEY_A, previousOwner: ALICE, newOwner: BOB }, ]) }) it("decodes a delete", () => { const h = harness() const seen: EntityEvent[] = [] watchEntityEvents(h.client, { onEntityDeleted: (event) => seen.push(event) }) h.emitLogs([logFor("EntityDeleted", { entityKey: KEY_A, owner: ALICE })]) expect(seen).toMatchObject([{ type: "EntityDeleted", entityKey: KEY_A, owner: ALICE }]) }) }) describe("onEvent", () => { it("receives every event in the order it was applied", () => { const h = harness() const seen: string[] = [] watchEntityEvents(h.client, { onEvent: (event) => seen.push(event.type) }) h.emitLogs([ logFor( "EntityCreated", { entityKey: KEY_A, owner: ALICE, expiresAt: 500n, creationFlags: 0 }, { logIndex: 0 }, ), logFor("EntityPatched", { entityKey: KEY_A, owner: ALICE }, { logIndex: 1 }), logFor( "OwnershipTransferred", { entityKey: KEY_A, previousOwner: ALICE, newOwner: BOB }, { logIndex: 2 }, ), logFor("EntityDeleted", { entityKey: KEY_A, owner: BOB }, { logIndex: 3 }), ]) expect(seen).toEqual([ "EntityCreated", "EntityPatched", "OwnershipTransferred", "EntityDeleted", ]) }) it("runs before the per-event handler for the same event", () => { const h = harness() const order: string[] = [] watchEntityEvents(h.client, { onEvent: () => order.push("onEvent"), onEntityCreated: () => order.push("onEntityCreated"), }) h.emitLogs([ logFor("EntityCreated", { entityKey: KEY_A, owner: ALICE, expiresAt: 1n, creationFlags: 0 }), ]) expect(order).toEqual(["onEvent", "onEntityCreated"]) }) }) describe("the watcher itself", () => { it("only listens to the Arkiv operation address", () => { const h = harness() watchEntityEvents(h.client, { onEntityCreated: () => {} }) expect(h.watchEventCalls[0]).toMatchObject({ address: ARKIV_ADDRESS }) }) it("polls twice a block by default", () => { const h = harness() watchEntityEvents(h.client, { onEntityCreated: () => {} }) // Blocks are 2s, so a default that leaves an event a whole block stale is too slow. expect(h.watchEventCalls[0]).toMatchObject({ pollingInterval: 1000 }) }) it("forwards pollingInterval, and starts at the head", () => { const h = harness() watchEntityEvents(h.client, { onEntityCreated: () => {}, pollingInterval: 250 }) expect(h.watchEventCalls[0]).toMatchObject({ pollingInterval: 250 }) expect(h.watchEventCalls[0]).not.toHaveProperty("fromBlock") }) it("stops the watcher when unwatched", () => { const h = harness() const unwatch = watchEntityEvents(h.client, { onEntityCreated: () => {} }) expect(h.stops).toEqual([]) unwatch() expect(h.stops).toEqual(["logs"]) }) it("skips a log with no position rather than inventing one", () => { const h = harness() const seen: EntityEvent[] = [] watchEntityEvents(h.client, { onEvent: (event) => seen.push(event) }) const log = logFor("EntityCreated", { entityKey: KEY_A, owner: ALICE, expiresAt: 1n, creationFlags: 0, }) h.emitLogs([{ ...log, blockNumber: null }]) h.emitLogs([{ ...log, transactionHash: null }]) h.emitLogs([{ ...log, logIndex: null }]) expect(seen).toEqual([]) }) it("reports a log it cannot decode instead of throwing out of the poller", () => { const h = harness() const errors: Error[] = [] const seen: EntityEvent[] = [] watchEntityEvents(h.client, { onEvent: (event) => seen.push(event), onError: (error) => errors.push(error), }) const good = logFor("EntityDeleted", { entityKey: KEY_A, owner: ALICE }) // A create's topic with no data — the expiresAt word is missing. const broken = { ...logFor("EntityCreated", { entityKey: KEY_A, owner: ALICE, expiresAt: 1n, creationFlags: 0, }), data: "0x" as Hex, } expect(() => h.emitLogs([broken, good])).not.toThrow() expect(errors).toHaveLength(1) // The rest of the batch still gets through. expect(seen).toMatchObject([{ type: "EntityDeleted" }]) }) it("forwards transport errors", () => { const h = harness() const errors: Error[] = [] watchEntityEvents(h.client, { onEntityCreated: () => {}, onError: (error) => errors.push(error), }) const boom = new Error("transport died") h.failLogs(boom) expect(errors).toEqual([boom]) }) it("logs to the console when no onError was given, so a dead watcher is not silent", () => { const h = harness() watchEntityEvents(h.client, { onEntityCreated: () => {} }) const boom = new Error("filter not found") const logged = captureConsoleError(() => h.failLogs(boom)) expect(logged).toEqual([["watchEntityEvents error", boom]]) }) }) describe("a handler that throws", () => { it("is reported, not allowed to escape into the poller", () => { const h = harness() const errors: Error[] = [] watchEntityEvents(h.client, { onEntityCreated: () => { throw new Error("consumer blew up") }, onError: (error) => errors.push(error), }) expect(() => h.emitLogs([ logFor("EntityCreated", { entityKey: KEY_A, owner: ALICE, expiresAt: 200n, creationFlags: 0, }), ]), ).not.toThrow() expect(errors).toMatchObject([{ message: "consumer blew up" }]) }) it("does not stop the other handlers for the same event", () => { const h = harness() const seen: string[] = [] watchEntityEvents(h.client, { onEvent: () => { throw new Error("onEvent blew up") }, onEntityCreated: () => seen.push("onEntityCreated"), onError: () => {}, }) h.emitLogs([ logFor("EntityCreated", { entityKey: KEY_A, owner: ALICE, expiresAt: 200n, creationFlags: 0, }), ]) expect(seen).toEqual(["onEntityCreated"]) }) it("does not stop the rest of the batch", () => { const h = harness() const seen: Hex[] = [] watchEntityEvents(h.client, { onEntityCreated: ({ entityKey }) => { seen.push(entityKey) if (entityKey === KEY_A) throw new Error("first one blew up") }, onError: () => {}, }) h.emitLogs([ logFor( "EntityCreated", { entityKey: KEY_A, owner: ALICE, expiresAt: 200n, creationFlags: 0 }, { logIndex: 0 }, ), logFor( "EntityCreated", { entityKey: KEY_B, owner: BOB, expiresAt: 200n, creationFlags: 0 }, { logIndex: 1 }, ), ]) expect(seen).toEqual([KEY_A, KEY_B]) }) })