Skip to content

Commit 4591f1c

Browse files
committed
feat(events): typed EventManager for contract log subscriptions
Implements #121: - EventManager with ABI-derived event names and auto-decoded args (viem) - HTTP polling + optional WebSocket subscribeLogs backend - unsubscribe() per registration; removeAllListeners / destroy - Unit tests for polling delivery, offline decode, and WS unsub Payout wallet: GBVHELLD2JE235Y2NGTDT3MWI3T65ON6SY4N6FBHYVDAQ5FZC2CP5QXH
1 parent 1f717f3 commit 4591f1c

5 files changed

Lines changed: 708 additions & 0 deletions

File tree

src/events/EventManager.ts

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
import {
2+
decodeEventLog,
3+
toEventSelector,
4+
type Abi,
5+
type Address,
6+
type Hex,
7+
type Log,
8+
} from 'viem'
9+
import { ValidationError } from '../errors/index.js'
10+
import type {
11+
AbiEventItem,
12+
DecodedEventArgs,
13+
EventCallback,
14+
EventLogClient,
15+
EventManagerOptions,
16+
RawRpcLog,
17+
TypedEventLog,
18+
Unsubscribe,
19+
} from './types.js'
20+
21+
type ListenerEntry = {
22+
eventName: string
23+
eventAbi: AbiEventItem
24+
topic0: Hex
25+
callback: EventCallback
26+
}
27+
28+
function isEventItem(item: unknown): item is AbiEventItem {
29+
return (
30+
!!item &&
31+
typeof item === 'object' &&
32+
(item as AbiEventItem).type === 'event' &&
33+
typeof (item as AbiEventItem).name === 'string'
34+
)
35+
}
36+
37+
function hexToBigInt(value: string | null | undefined): bigint | null {
38+
if (value === null || value === undefined || value === '0x') return null
39+
try {
40+
return BigInt(value)
41+
} catch {
42+
return null
43+
}
44+
}
45+
46+
function hexToNumber(value: string | number | null | undefined): number | null {
47+
if (value === null || value === undefined) return null
48+
if (typeof value === 'number') return value
49+
try {
50+
return Number(BigInt(value))
51+
} catch {
52+
return null
53+
}
54+
}
55+
56+
function normalizeLog(raw: RawRpcLog | Log): TypedEventLog['log'] {
57+
const topics = ((raw as any).topics ?? []) as readonly Hex[]
58+
return {
59+
address: ((raw as any).address ?? '0x') as Address,
60+
blockNumber:
61+
typeof (raw as any).blockNumber === 'bigint'
62+
? (raw as any).blockNumber
63+
: hexToBigInt((raw as any).blockNumber),
64+
blockHash: ((raw as any).blockHash ?? null) as Hex | null,
65+
transactionHash: ((raw as any).transactionHash ?? null) as Hex | null,
66+
logIndex:
67+
typeof (raw as any).logIndex === 'number'
68+
? (raw as any).logIndex
69+
: hexToNumber((raw as any).logIndex),
70+
topics,
71+
data: ((raw as any).data ?? '0x') as Hex,
72+
}
73+
}
74+
75+
/**
76+
* Strongly typed contract event subscription manager.
77+
*
78+
* ```ts
79+
* const events = new EventManager({ address, abi, client })
80+
* const stop = events.on('Deposit', ({ args }) => console.log(args.amount))
81+
* // later:
82+
* stop()
83+
* ```
84+
*
85+
* - Derives event signatures from the ABI (`abitype` / viem)
86+
* - Auto-decodes log topics + data into named argument objects
87+
* - Supports HTTP polling and optional WebSocket log subscriptions
88+
* - Re-polls after transport errors; WS re-subscribe is delegated to `subscribeLogs`
89+
*/
90+
export class EventManager {
91+
public readonly address: Address
92+
public readonly abi: Abi
93+
public readonly client: EventLogClient
94+
public readonly pollingIntervalMs: number
95+
public readonly onError?: (error: unknown) => void
96+
97+
private readonly _eventsByName = new Map<string, AbiEventItem>()
98+
private readonly _listeners = new Map<string, Set<ListenerEntry>>()
99+
private readonly _unsubscribers = new Map<ListenerEntry, Unsubscribe>()
100+
private _pollTimer: ReturnType<typeof setInterval> | null = null
101+
private _fromBlock: bigint | 'latest'
102+
private _lastPolledBlock: bigint | null = null
103+
private _subscribeLogs?: EventManagerOptions['subscribeLogs']
104+
private _polling = false
105+
106+
constructor(options: EventManagerOptions) {
107+
if (!options?.address) {
108+
throw new ValidationError('EventManager requires a contract address')
109+
}
110+
if (!options?.abi || !Array.isArray(options.abi)) {
111+
throw new ValidationError('EventManager requires a contract ABI array')
112+
}
113+
if (!options?.client) {
114+
throw new ValidationError('EventManager requires a client with request/getLogs')
115+
}
116+
117+
this.address = options.address
118+
this.abi = options.abi
119+
this.client = options.client
120+
this.pollingIntervalMs = options.pollingIntervalMs ?? 4000
121+
this.onError = options.onError
122+
this._subscribeLogs = options.subscribeLogs
123+
this._fromBlock = options.fromBlock === undefined ? 'latest' : options.fromBlock === 'latest'
124+
? 'latest'
125+
: BigInt(options.fromBlock)
126+
127+
for (const item of options.abi) {
128+
if (isEventItem(item)) {
129+
this._eventsByName.set(item.name, item)
130+
}
131+
}
132+
}
133+
134+
/** Event names present on the configured ABI. */
135+
public listEventNames(): string[] {
136+
return Array.from(this._eventsByName.keys())
137+
}
138+
139+
/**
140+
* Subscribe to a named event. Callback receives fully decoded args.
141+
* Returns an `unsubscribe()` function to remove this listener.
142+
*/
143+
public on<TArgs extends DecodedEventArgs = DecodedEventArgs>(
144+
eventName: string,
145+
callback: EventCallback<TArgs>,
146+
): Unsubscribe {
147+
const eventAbi = this._eventsByName.get(eventName)
148+
if (!eventAbi) {
149+
throw new ValidationError(
150+
`Event "${eventName}" not found in contract ABI. Known: ${this.listEventNames().join(', ') || '(none)'}`,
151+
)
152+
}
153+
154+
const topic0 = toEventSelector(eventAbi as any) as Hex
155+
const entry: ListenerEntry = {
156+
eventName,
157+
eventAbi,
158+
topic0,
159+
callback: callback as EventCallback,
160+
}
161+
162+
let set = this._listeners.get(eventName)
163+
if (!set) {
164+
set = new Set()
165+
this._listeners.set(eventName, set)
166+
}
167+
set.add(entry)
168+
169+
// Prefer WS subscription when available
170+
if (this._subscribeLogs) {
171+
const maybe = this._subscribeLogs(
172+
{ address: this.address, topics: [topic0] },
173+
(raw) => {
174+
void this._dispatchRaw(raw, entry)
175+
},
176+
)
177+
Promise.resolve(maybe)
178+
.then((unsub) => {
179+
this._unsubscribers.set(entry, unsub)
180+
})
181+
.catch((err) => this.onError?.(err))
182+
} else {
183+
this._ensurePolling()
184+
}
185+
186+
let active = true
187+
return () => {
188+
if (!active) return
189+
active = false
190+
this._removeListener(entry)
191+
}
192+
}
193+
194+
/**
195+
* Remove all listeners for an event name, or all listeners when omitted.
196+
*/
197+
public removeAllListeners(eventName?: string): void {
198+
if (eventName) {
199+
const set = this._listeners.get(eventName)
200+
if (!set) return
201+
for (const entry of Array.from(set)) {
202+
this._removeListener(entry)
203+
}
204+
return
205+
}
206+
for (const set of Array.from(this._listeners.values())) {
207+
for (const entry of Array.from(set)) {
208+
this._removeListener(entry)
209+
}
210+
}
211+
}
212+
213+
/** Tear down timers and all subscriptions. */
214+
public destroy(): void {
215+
this.removeAllListeners()
216+
this._stopPolling()
217+
}
218+
219+
private _removeListener(entry: ListenerEntry): void {
220+
const set = this._listeners.get(entry.eventName)
221+
set?.delete(entry)
222+
if (set && set.size === 0) {
223+
this._listeners.delete(entry.eventName)
224+
}
225+
const unsub = this._unsubscribers.get(entry)
226+
if (unsub) {
227+
try {
228+
unsub()
229+
} catch (err) {
230+
this.onError?.(err)
231+
}
232+
this._unsubscribers.delete(entry)
233+
}
234+
if (this._listeners.size === 0) {
235+
this._stopPolling()
236+
}
237+
}
238+
239+
private _ensurePolling(): void {
240+
if (this._pollTimer || this._subscribeLogs) return
241+
// Immediate first poll, then interval
242+
void this._pollOnce()
243+
this._pollTimer = setInterval(() => {
244+
void this._pollOnce()
245+
}, this.pollingIntervalMs)
246+
}
247+
248+
private _stopPolling(): void {
249+
if (this._pollTimer) {
250+
clearInterval(this._pollTimer)
251+
this._pollTimer = null
252+
}
253+
}
254+
255+
private async _getBlockNumber(): Promise<bigint> {
256+
if (typeof this.client.getBlockNumber === 'function') {
257+
return this.client.getBlockNumber()
258+
}
259+
if (typeof this.client.request === 'function') {
260+
const hex = (await this.client.request({ method: 'eth_blockNumber', params: [] })) as string
261+
return BigInt(hex)
262+
}
263+
throw new ValidationError('EventManager client cannot resolve block number')
264+
}
265+
266+
private async _getLogs(params: {
267+
fromBlock: bigint
268+
toBlock: bigint
269+
topics: (Hex | null)[]
270+
}): Promise<RawRpcLog[]> {
271+
if (typeof this.client.getLogs === 'function') {
272+
const logs = await this.client.getLogs({
273+
address: this.address,
274+
fromBlock: params.fromBlock,
275+
toBlock: params.toBlock,
276+
})
277+
// Filter topic0 client-side when using getLogs without events filter
278+
return logs.filter((l) => {
279+
const t0 = l.topics?.[0]
280+
return !params.topics[0] || t0 === params.topics[0]
281+
}) as unknown as RawRpcLog[]
282+
}
283+
284+
if (typeof this.client.request !== 'function') {
285+
throw new ValidationError('EventManager client must implement request or getLogs')
286+
}
287+
288+
const result = (await this.client.request({
289+
method: 'eth_getLogs',
290+
params: [
291+
{
292+
address: this.address,
293+
fromBlock: `0x${params.fromBlock.toString(16)}`,
294+
toBlock: `0x${params.toBlock.toString(16)}`,
295+
topics: params.topics,
296+
},
297+
],
298+
})) as RawRpcLog[]
299+
300+
return Array.isArray(result) ? result : []
301+
}
302+
303+
private async _pollOnce(): Promise<void> {
304+
if (this._polling || this._listeners.size === 0) return
305+
this._polling = true
306+
try {
307+
const latest = await this._getBlockNumber()
308+
let from: bigint
309+
if (this._lastPolledBlock !== null) {
310+
from = this._lastPolledBlock + 1n
311+
} else if (this._fromBlock === 'latest') {
312+
// First poll: only observe from current head (avoid historical flood)
313+
this._lastPolledBlock = latest
314+
return
315+
} else {
316+
from = this._fromBlock
317+
}
318+
319+
if (from > latest) return
320+
321+
// Group listeners by topic0 to minimize RPC fan-out
322+
const byTopic = new Map<Hex, ListenerEntry[]>()
323+
for (const set of this._listeners.values()) {
324+
for (const entry of set) {
325+
const list = byTopic.get(entry.topic0) ?? []
326+
list.push(entry)
327+
byTopic.set(entry.topic0, list)
328+
}
329+
}
330+
331+
for (const [topic0, entries] of byTopic) {
332+
const logs = await this._getLogs({
333+
fromBlock: from,
334+
toBlock: latest,
335+
topics: [topic0],
336+
})
337+
for (const raw of logs) {
338+
for (const entry of entries) {
339+
await this._dispatchRaw(raw, entry)
340+
}
341+
}
342+
}
343+
344+
this._lastPolledBlock = latest
345+
} catch (err) {
346+
this.onError?.(err)
347+
} finally {
348+
this._polling = false
349+
}
350+
}
351+
352+
private async _dispatchRaw(raw: RawRpcLog | Log, entry: ListenerEntry): Promise<void> {
353+
if ((raw as RawRpcLog).removed) return
354+
355+
try {
356+
const decoded = decodeEventLog({
357+
abi: this.abi,
358+
data: ((raw as any).data ?? '0x') as Hex,
359+
topics: ((raw as any).topics ?? []) as [Hex, ...Hex[]],
360+
})
361+
362+
if (decoded.eventName !== entry.eventName) return
363+
364+
const payload: TypedEventLog = {
365+
eventName: decoded.eventName,
366+
args: (decoded.args ?? {}) as DecodedEventArgs,
367+
log: normalizeLog(raw),
368+
}
369+
370+
await entry.callback(payload)
371+
} catch (err) {
372+
this.onError?.(err)
373+
}
374+
}
375+
376+
/**
377+
* Decode a single raw log against this manager's ABI (utility for tests / offline use).
378+
*/
379+
public decodeLog(raw: RawRpcLog | Log): TypedEventLog | null {
380+
try {
381+
const decoded = decodeEventLog({
382+
abi: this.abi,
383+
data: ((raw as any).data ?? '0x') as Hex,
384+
topics: ((raw as any).topics ?? []) as [Hex, ...Hex[]],
385+
})
386+
return {
387+
eventName: decoded.eventName,
388+
args: (decoded.args ?? {}) as DecodedEventArgs,
389+
log: normalizeLog(raw),
390+
}
391+
} catch {
392+
return null
393+
}
394+
}
395+
}
396+
397+
export function createEventManager(options: EventManagerOptions): EventManager {
398+
return new EventManager(options)
399+
}

0 commit comments

Comments
 (0)