Skip to content

Commit a8bbdec

Browse files
committed
feat(listeners): ERC-20/ERC-721 transfer event helpers
Implements #60: - onTokenTransfer / onNftTransfer with account filtering - Parsed amount + optional decimals formatting (formatUnits) - getTransferTopics / padAddressTopic / decodeTokenTransferLog - HTTP polling or WS via EventManager; unsubscribe() for React cleanup - Unit tests for formatting, filtering, and NFT tokenId delivery Payout wallet: GBVHELLD2JE235Y2NGTDT3MWI3T65ON6SY4N6FBHYVDAQ5FZC2CP5QXH
1 parent cbc9ad2 commit a8bbdec

5 files changed

Lines changed: 612 additions & 0 deletions

File tree

src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,21 @@ export {
292292
type Unsubscribe,
293293
type RawRpcLog,
294294
} from './events/index.js'
295+
296+
// ---------------------------------------------------------------------------
297+
// ERC-20 / ERC-721 transfer listeners (#60)
298+
// ---------------------------------------------------------------------------
299+
export {
300+
onTokenTransfer,
301+
onNftTransfer,
302+
getTransferTopics,
303+
decodeTokenTransferLog,
304+
padAddressTopic,
305+
TRANSFER_EVENT_ABI,
306+
ERC721_TRANSFER_EVENT_ABI,
307+
type TokenTransferListenerOptions,
308+
type NftTransferListenerOptions,
309+
type ParsedTokenTransfer,
310+
type ParsedNftTransfer,
311+
type TransferDirection,
312+
} from './utils/listeners.js'

src/utils/addressTopics.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Address, Hex } from 'viem'
2+
3+
/**
4+
* Pad a 20-byte address to a 32-byte topic (left-zero-padded), as used in
5+
* indexed event parameters for eth_getLogs filters.
6+
*/
7+
export function padAddressTopic(address: Address | string): Hex {
8+
const hex = address.toLowerCase().replace(/^0x/, '')
9+
if (!/^[0-9a-f]{40}$/.test(hex)) {
10+
throw new Error(`Invalid address for topic padding: ${address}`)
11+
}
12+
return `0x${'0'.repeat(24)}${hex}` as Hex
13+
}

src/utils/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,17 @@ export {
3737
type RetryOptions,
3838
type RetryAttemptInfo,
3939
} from './retry.js'
40+
export {
41+
onTokenTransfer,
42+
onNftTransfer,
43+
getTransferTopics,
44+
decodeTokenTransferLog,
45+
padAddressTopic,
46+
TRANSFER_EVENT_ABI,
47+
ERC721_TRANSFER_EVENT_ABI,
48+
type TokenTransferListenerOptions,
49+
type NftTransferListenerOptions,
50+
type ParsedTokenTransfer,
51+
type ParsedNftTransfer,
52+
type TransferDirection,
53+
} from './listeners.js'

src/utils/listeners.ts

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import {
2+
decodeEventLog,
3+
encodeEventTopics,
4+
formatUnits,
5+
type Address,
6+
type Hex,
7+
type Log,
8+
} from 'viem'
9+
import { ValidationError } from '../errors/index.js'
10+
import type { EventLogClient, RawRpcLog, Unsubscribe } from '../events/types.js'
11+
import { EventManager } from '../events/EventManager.js'
12+
import { padAddressTopic } from './addressTopics.js'
13+
14+
/** Minimal ERC-20 / ERC-721 Transfer event ABI. */
15+
export const TRANSFER_EVENT_ABI = [
16+
{
17+
type: 'event',
18+
name: 'Transfer',
19+
inputs: [
20+
{ name: 'from', type: 'address', indexed: true },
21+
{ name: 'to', type: 'address', indexed: true },
22+
{ name: 'value', type: 'uint256', indexed: false },
23+
],
24+
},
25+
] as const
26+
27+
/** ERC-721 Transfer uses indexed tokenId instead of value. */
28+
export const ERC721_TRANSFER_EVENT_ABI = [
29+
{
30+
type: 'event',
31+
name: 'Transfer',
32+
inputs: [
33+
{ name: 'from', type: 'address', indexed: true },
34+
{ name: 'to', type: 'address', indexed: true },
35+
{ name: 'tokenId', type: 'uint256', indexed: true },
36+
],
37+
},
38+
] as const
39+
40+
export type TransferDirection = 'incoming' | 'outgoing' | 'both'
41+
42+
export interface ParsedTokenTransfer {
43+
tokenAddress: Address
44+
from: Address
45+
to: Address
46+
/** Raw on-chain amount (uint256). */
47+
value: bigint
48+
/** Formatted decimal string when `decimals` is known; otherwise null. */
49+
formattedValue: string | null
50+
decimals: number | null
51+
direction: 'incoming' | 'outgoing' | 'self'
52+
log: {
53+
blockNumber: bigint | null
54+
transactionHash: Hex | null
55+
logIndex: number | null
56+
}
57+
}
58+
59+
export interface ParsedNftTransfer {
60+
tokenAddress: Address
61+
from: Address
62+
to: Address
63+
tokenId: bigint
64+
direction: 'incoming' | 'outgoing' | 'self'
65+
log: {
66+
blockNumber: bigint | null
67+
transactionHash: Hex | null
68+
logIndex: number | null
69+
}
70+
}
71+
72+
export interface TokenTransferListenerOptions {
73+
client: EventLogClient
74+
/** Token contract address. */
75+
tokenAddress: Address
76+
/** Account to watch (from and/or to). */
77+
accountAddress: Address
78+
/** Callback invoked for each matching transfer. */
79+
onTransfer: (transfer: ParsedTokenTransfer) => void | Promise<void>
80+
/** Watch incoming, outgoing, or both (default: both). */
81+
direction?: TransferDirection
82+
/** Token decimals for formattedValue (default: null → no formatting). */
83+
decimals?: number
84+
pollingIntervalMs?: number
85+
fromBlock?: bigint | number | 'latest'
86+
onError?: (error: unknown) => void
87+
/**
88+
* Optional WS log subscriber. When provided, real-time push is used.
89+
* Signature matches EventManagerOptions.subscribeLogs.
90+
*/
91+
subscribeLogs?: (filter: {
92+
address: Address
93+
topics: (Hex | Hex[] | null)[]
94+
}, handler: (log: RawRpcLog) => void) => Promise<Unsubscribe> | Unsubscribe
95+
}
96+
97+
export interface NftTransferListenerOptions {
98+
client: EventLogClient
99+
tokenAddress: Address
100+
accountAddress: Address
101+
onTransfer: (transfer: ParsedNftTransfer) => void | Promise<void>
102+
direction?: TransferDirection
103+
pollingIntervalMs?: number
104+
fromBlock?: bigint | number | 'latest'
105+
onError?: (error: unknown) => void
106+
subscribeLogs?: TokenTransferListenerOptions['subscribeLogs']
107+
}
108+
109+
function directionOf(
110+
from: Address,
111+
to: Address,
112+
account: Address,
113+
): 'incoming' | 'outgoing' | 'self' {
114+
const a = account.toLowerCase()
115+
const f = from.toLowerCase()
116+
const t = to.toLowerCase()
117+
if (f === a && t === a) return 'self'
118+
if (t === a) return 'incoming'
119+
return 'outgoing'
120+
}
121+
122+
function matchesDirection(
123+
dir: 'incoming' | 'outgoing' | 'self',
124+
wanted: TransferDirection,
125+
): boolean {
126+
if (wanted === 'both') return true
127+
if (wanted === 'incoming') return dir === 'incoming' || dir === 'self'
128+
return dir === 'outgoing' || dir === 'self'
129+
}
130+
131+
/**
132+
* Subscribe to ERC-20 `Transfer` events involving `accountAddress`.
133+
*
134+
* Abstracts HTTP polling vs WebSocket push. Returns `unsubscribe()` for React cleanup.
135+
*
136+
* @example
137+
* ```ts
138+
* const stop = onTokenTransfer({
139+
* client,
140+
* tokenAddress: USDC,
141+
* accountAddress: user,
142+
* decimals: 6,
143+
* onTransfer: (t) => console.log(t.formattedValue, t.direction),
144+
* })
145+
* // on unmount:
146+
* stop()
147+
* ```
148+
*/
149+
export function onTokenTransfer(options: TokenTransferListenerOptions): Unsubscribe {
150+
if (!options?.client) throw new ValidationError('onTokenTransfer requires a client')
151+
if (!options.tokenAddress) throw new ValidationError('tokenAddress is required')
152+
if (!options.accountAddress) throw new ValidationError('accountAddress is required')
153+
154+
const direction = options.direction ?? 'both'
155+
const decimals = options.decimals ?? null
156+
const account = options.accountAddress
157+
158+
// Build topic filter: topic0 = Transfer, topic1/2 = from/to account depending on direction
159+
// For "both" we cannot OR topics in a single filter without multiple subscriptions;
160+
// EventManager polls all Transfer logs for the token and we filter client-side.
161+
const manager = new EventManager({
162+
address: options.tokenAddress,
163+
abi: TRANSFER_EVENT_ABI as unknown as any,
164+
client: options.client,
165+
pollingIntervalMs: options.pollingIntervalMs,
166+
fromBlock: options.fromBlock,
167+
onError: options.onError,
168+
subscribeLogs: options.subscribeLogs,
169+
})
170+
171+
return manager.on('Transfer', async (event) => {
172+
const from = String(event.args.from ?? '') as Address
173+
const to = String(event.args.to ?? '') as Address
174+
const value = BigInt(event.args.value as bigint | string | number)
175+
176+
const dir = directionOf(from, to, account)
177+
// Only events involving the watched account
178+
const involves =
179+
from.toLowerCase() === account.toLowerCase() ||
180+
to.toLowerCase() === account.toLowerCase()
181+
if (!involves) return
182+
if (!matchesDirection(dir, direction)) return
183+
184+
const parsed: ParsedTokenTransfer = {
185+
tokenAddress: options.tokenAddress,
186+
from,
187+
to,
188+
value,
189+
decimals,
190+
formattedValue: decimals === null ? null : formatUnits(value, decimals),
191+
direction: dir,
192+
log: {
193+
blockNumber: event.log.blockNumber,
194+
transactionHash: event.log.transactionHash,
195+
logIndex: event.log.logIndex,
196+
},
197+
}
198+
199+
await options.onTransfer(parsed)
200+
})
201+
}
202+
203+
/**
204+
* Subscribe to ERC-721 `Transfer` events involving `accountAddress`.
205+
*/
206+
export function onNftTransfer(options: NftTransferListenerOptions): Unsubscribe {
207+
if (!options?.client) throw new ValidationError('onNftTransfer requires a client')
208+
if (!options.tokenAddress) throw new ValidationError('tokenAddress is required')
209+
if (!options.accountAddress) throw new ValidationError('accountAddress is required')
210+
211+
const direction = options.direction ?? 'both'
212+
const account = options.accountAddress
213+
214+
const manager = new EventManager({
215+
address: options.tokenAddress,
216+
abi: ERC721_TRANSFER_EVENT_ABI as unknown as any,
217+
client: options.client,
218+
pollingIntervalMs: options.pollingIntervalMs,
219+
fromBlock: options.fromBlock,
220+
onError: options.onError,
221+
subscribeLogs: options.subscribeLogs,
222+
})
223+
224+
return manager.on('Transfer', async (event) => {
225+
const from = String(event.args.from ?? '') as Address
226+
const to = String(event.args.to ?? '') as Address
227+
const tokenId = BigInt(event.args.tokenId as bigint | string | number)
228+
229+
const dir = directionOf(from, to, account)
230+
const involves =
231+
from.toLowerCase() === account.toLowerCase() ||
232+
to.toLowerCase() === account.toLowerCase()
233+
if (!involves) return
234+
if (!matchesDirection(dir, direction)) return
235+
236+
const parsed: ParsedNftTransfer = {
237+
tokenAddress: options.tokenAddress,
238+
from,
239+
to,
240+
tokenId,
241+
direction: dir,
242+
log: {
243+
blockNumber: event.log.blockNumber,
244+
transactionHash: event.log.transactionHash,
245+
logIndex: event.log.logIndex,
246+
},
247+
}
248+
249+
await options.onTransfer(parsed)
250+
})
251+
}
252+
253+
/**
254+
* Low-level helper: compute Transfer topic0 + optional padded address topic.
255+
* Useful when building custom eth_getLogs filters.
256+
*/
257+
export function getTransferTopics(options?: {
258+
from?: Address | null
259+
to?: Address | null
260+
/** Use ERC-721 ABI (indexed tokenId). Default false (ERC-20). */
261+
erc721?: boolean
262+
}): (Hex | null)[] {
263+
const abi = options?.erc721 ? ERC721_TRANSFER_EVENT_ABI : TRANSFER_EVENT_ABI
264+
const topics = encodeEventTopics({
265+
abi: abi as any,
266+
eventName: 'Transfer',
267+
args: {
268+
from: options?.from ?? undefined,
269+
to: options?.to ?? undefined,
270+
},
271+
}) as Hex[]
272+
// encodeEventTopics returns only defined topics; normalize to length-3 sparse array
273+
return [topics[0] ?? null, topics[1] ?? null, topics[2] ?? null]
274+
}
275+
276+
/**
277+
* Decode a raw Transfer log into a structured object without subscribing.
278+
*/
279+
export function decodeTokenTransferLog(
280+
log: RawRpcLog | Log,
281+
options?: { decimals?: number; accountAddress?: Address },
282+
): ParsedTokenTransfer | null {
283+
try {
284+
const decoded = decodeEventLog({
285+
abi: TRANSFER_EVENT_ABI as any,
286+
data: ((log as any).data ?? '0x') as Hex,
287+
topics: ((log as any).topics ?? []) as [Hex, ...Hex[]],
288+
})
289+
if (decoded.eventName !== 'Transfer') return null
290+
const from = String((decoded.args as any).from) as Address
291+
const to = String((decoded.args as any).to) as Address
292+
const value = BigInt((decoded.args as any).value)
293+
const decimals = options?.decimals ?? null
294+
const account = options?.accountAddress
295+
const dir = account ? directionOf(from, to, account) : 'incoming'
296+
297+
return {
298+
tokenAddress: ((log as any).address ?? '0x') as Address,
299+
from,
300+
to,
301+
value,
302+
decimals,
303+
formattedValue: decimals === null ? null : formatUnits(value, decimals),
304+
direction: dir,
305+
log: {
306+
blockNumber:
307+
typeof (log as any).blockNumber === 'bigint'
308+
? (log as any).blockNumber
309+
: (log as any).blockNumber
310+
? BigInt((log as any).blockNumber)
311+
: null,
312+
transactionHash: ((log as any).transactionHash ?? null) as Hex | null,
313+
logIndex:
314+
typeof (log as any).logIndex === 'number'
315+
? (log as any).logIndex
316+
: (log as any).logIndex != null
317+
? Number(BigInt((log as any).logIndex))
318+
: null,
319+
},
320+
}
321+
} catch {
322+
return null
323+
}
324+
}
325+
326+
// Re-export pad helper for advanced filter builders
327+
export { padAddressTopic }

0 commit comments

Comments
 (0)