@arkiv-network/sdk
Version:
TypeScript client library for Arkiv's blockchains interactions
229 lines (214 loc) • 8.32 kB
text/typescript
import { decodeEventLog, type Hex } from "viem"
import type { ArkivClient } from "../../clients/baseClient"
import type { PublicArkivClient } from "../../clients/createPublicClient"
import { ARKIV_ADDRESS, BLOCK_TIME } from "../../consts"
import { ENTITY_EVENTS_ABI } from "../../entity/events"
import { decodeCreationFlags } from "../../entity/flags"
import type {
EntityCreatedEvent,
EntityDeletedEvent,
EntityEvent,
EntityEventContext,
EntityPatchedEvent,
ExpiryExtendedEvent,
OwnershipTransferredEvent,
} from "../../types/events"
import { getLogger } from "../../utils/logger"
const logger = getLogger("actions:public:watch-entity-events")
/**
* How often to poll, when the caller does not say.
*
* Half a block: fast enough that an event is never a whole block stale, slow enough not to hammer
* the node. viem's own default is derived from a 12-second Ethereum block and is far too slow here.
*/
const DEFAULT_POLLING_INTERVAL = (BLOCK_TIME * 1000) / 2
/**
* Handlers and options for `client.watchEntityEvents`. Every handler is optional; the watcher does
* the work for the ones you pass.
*/
export type WatchEntityEventsParameters = {
onEntityCreated?: ((event: EntityCreatedEvent) => void) | undefined
onEntityPatched?: ((event: EntityPatchedEvent) => void) | undefined
onExpiryExtended?: ((event: ExpiryExtendedEvent) => void) | undefined
onOwnershipTransferred?: ((event: OwnershipTransferredEvent) => void) | undefined
onEntityDeleted?: ((event: EntityDeletedEvent) => void) | undefined
/**
* Every event, in application order, whatever its type. Runs *before* the per-event handler for
* the same event.
*
* This is the replay seam: operations apply in batch order and emit one event each, so an
* off-chain replica that consumes this in order sees what the engine did once the watcher is
* running — see {@link watchEntityEvents} for when that is.
*/
onEvent?: ((event: EntityEvent) => void) | undefined
/**
* Transport failures, logs that will not decode, and anything one of your own handlers throws.
*
* Defaults to `console.error`. A watcher whose filter the node dropped otherwise just goes quiet,
* and a quiet watcher is indistinguishable from a quiet chain.
*/
onError?: ((error: Error) => void) | undefined
/** How often to poll, in milliseconds. Defaults to half a block. */
pollingInterval?: number | undefined
}
/**
* Watches entity events, calling the handlers you pass as they arrive.
*
* The watcher follows the head. It starts listening on its first poll, which sets up the filter on
* the node, so an event mined between the call and that poll — up to one polling interval — is not
* delivered, and nor is anything earlier: the network serves no historical reads. To build state
* from scratch, start the watcher first and then query the current state. The two overlap, so
* apply events in a way that tolerates seeing a change the query already returned.
*
* Every event here is decoded from a log the Arkiv operation address emitted, and carries the
* block, transaction and log index it came from — so consuming them in order replays exactly what
* the engine did.
*
* @param parameters - Handlers and options. {@link WatchEntityEventsParameters}
* @returns A function that stops the watcher.
*
*
* @example
* import { createPublicClient } from "@arkiv-network/sdk"
* import { rabdi } from "@arkiv-network/sdk/chains"
* import { http } from "viem"
*
* const client = createPublicClient({ chain: rabdi({ accessKey: "..." }), transport: http() })
*
* const unwatch = client.watchEntityEvents({
* onEntityCreated: ({ entityKey, owner, expiresAt }) =>
* console.log("created", entityKey, "by", owner, "until block", expiresAt),
* onEntityDeleted: ({ entityKey }) => console.log("deleted", entityKey),
* onError: (error) => console.error(error),
* })
*
* unwatch()
*/
export function watchEntityEvents(
client: ArkivClient,
parameters: WatchEntityEventsParameters,
): () => void {
const {
onEntityCreated,
onEntityPatched,
onExpiryExtended,
onOwnershipTransferred,
onEntityDeleted,
onEvent,
onError,
pollingInterval = DEFAULT_POLLING_INTERVAL,
} = parameters
const publicClient = client as PublicArkivClient
const reportError = onError ?? logToConsole
function emit(event: EntityEvent): void {
deliver(onEvent, event)
switch (event.type) {
case "EntityCreated":
deliver(onEntityCreated, event)
break
case "EntityPatched":
deliver(onEntityPatched, event)
break
case "ExpiryExtended":
deliver(onExpiryExtended, event)
break
case "OwnershipTransferred":
deliver(onOwnershipTransferred, event)
break
case "EntityDeleted":
deliver(onEntityDeleted, event)
break
}
}
/**
* Runs one handler, keeping its failure to itself.
*
* A handler that throws is the consumer's problem, not the watcher's: letting it escape would
* reject the poll, drop the rest of the batch, and take down every other handler with it.
*/
function deliver<TEvent>(handler: ((event: TEvent) => void) | undefined, event: TEvent): void {
if (handler === undefined) return
try {
handler(event)
} catch (error) {
reportError(asError(error))
}
}
const unwatchLogs = publicClient.watchEvent({
// Only the Arkiv operation address emits these. Without the filter any contract whose event
// happens to hash to the same topic lands in `onLogs`, and viem's observer key — which covers
// the address but not `events` — would make this subscription collide with any other
// unaddressed `watchEvent` on the same client.
address: ARKIV_ADDRESS,
events: ENTITY_EVENTS_ABI,
pollingInterval,
onLogs: (logs) => {
logger("logs %o", logs)
for (const log of logs) {
// A log with no position cannot be placed in application order, which is the one thing an
// event consumer needs from it. Dropping it beats inventing a position it never had.
if (log.blockNumber === null || log.transactionHash === null || log.logIndex === null) {
continue
}
const context: EntityEventContext = {
blockNumber: log.blockNumber,
transactionHash: log.transactionHash,
logIndex: log.logIndex,
}
// Only the decode is guarded here. Dispatch guards each handler separately, so a consumer's
// exception is never reported as if the log were the thing that was malformed.
let event: EntityEvent
try {
event = toEntityEvent(log.topics, log.data, context)
} catch (error) {
reportError(asError(error))
continue
}
emit(event)
}
},
onError: (error) => reportError(error),
})
return unwatchLogs
}
/** The fallback when no `onError` was given — see {@link WatchEntityEventsParameters.onError}. */
function logToConsole(error: Error): void {
console.error("watchEntityEvents error", error)
}
/** Handlers take an `Error`, and a `throw` can be anything. */
function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
/**
* Decodes one log into the {@link EntityEvent} it carries.
*
* @throws If the log does not decode against {@link ENTITY_EVENTS_ABI}.
*/
function toEntityEvent(
topics: readonly Hex[],
data: Hex,
context: EntityEventContext,
): EntityEvent {
const decoded = decodeEventLog({
abi: ENTITY_EVENTS_ABI,
topics: topics as [Hex, ...Hex[]] | [],
data,
})
switch (decoded.eventName) {
case "EntityCreated":
return {
type: "EntityCreated",
...context,
...decoded.args,
creationFlags: decodeCreationFlags(decoded.args.creationFlags),
}
case "EntityPatched":
return { type: "EntityPatched", ...context, ...decoded.args }
case "ExpiryExtended":
return { type: "ExpiryExtended", ...context, ...decoded.args }
case "OwnershipTransferred":
return { type: "OwnershipTransferred", ...context, ...decoded.args }
case "EntityDeleted":
return { type: "EntityDeleted", ...context, ...decoded.args }
}
}