import { Hex } from 'ox' import { encodeAbiParameters, encodeEventTopics, getAddress, parseAbiItem } from 'viem' import * as AppWebhooks from './webhooks.js' const sender = `0x${'aa'.repeat(20)}` const recipient = `0x${'bb'.repeat(20)}` const callTarget = `0x${'cc'.repeat(20)}` const token = '0x20c0000000000000000000000000000000000001' const transactionHash = `0x${'ef'.repeat(32)}` const transferTopic = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' const customTopic = `0x${'12'.repeat(32)}` // 2026-01-01T00:00:00Z in seconds. const timestampHex = Hex.fromNumber(1_767_225_600) const addressTopic = (address: string) => `0x${'0'.repeat(24)}${address.slice(2)}` const oneToken = `0x${(10n ** 18n).toString(16).padStart(64, '0')}` function transaction(overrides: Record = {}): Record { return { blockHash: `0x${'ab'.repeat(32)}`, blockNumber: '0x7b', calls: [{ data: '0x', input: '0x', to: callTarget, value: '0x0' }], from: sender, gas: '0x5208', hash: transactionHash, input: `0xa9059cbb${'00'.repeat(8)}`, nonce: '0x1', to: recipient, transactionIndex: '0x0', type: '0x2', value: `0x${(10n ** 18n).toString(16)}`, ...overrides, } } function transferLog(overrides: Record = {}) { return { address: token, blockNumber: '0x7b', data: oneToken, logIndex: '0x4', topics: [transferTopic, addressTopic(sender), addressTopic(recipient)], transactionHash, transactionIndex: '0x0', ...overrides, } as never } function customLog(overrides: Record = {}) { return { address: callTarget, blockNumber: '0x7b', data: `0x${'0'.repeat(63)}1`, logIndex: '0x7', topics: [customTopic, addressTopic(sender)], transactionHash, transactionIndex: '0x0', ...overrides, } as never } function block(overrides: Record = {}): Record { return { gasLimit: '0x1c9c380', gasUsed: '0x5208', hash: `0x${'ab'.repeat(32)}`, miner: `0x${'12'.repeat(20)}`, number: '0x7b', parentHash: `0x${'cd'.repeat(32)}`, timestamp: timestampHex, transactions: [transaction()], ...overrides, } } function subscription(eventType: AppWebhooks.EventType, filters: Record = {}) { return { eventType, filters, id: `wh_${eventType}` } } describe('scanBlock', () => { test('delivers block, transaction, log, and transfer rows from one block', () => { const result = AppWebhooks.scanBlock({ block: block(), logs: [transferLog(), customLog()], subscriptions: [ subscription('block:created'), subscription('transaction:included', { from: sender }), subscription('log:emitted', { address: callTarget, topic0: customTopic }), subscription('token:transfer', { token }), ], }) expect(result.invalid).toEqual([]) expect( result.matches.map(({ blockNumber, logIndex, subscription }) => ({ blockNumber, logIndex, subscription: subscription.id, })), ).toMatchInlineSnapshot(` [ { "blockNumber": 123, "logIndex": 0, "subscription": "wh_block:created", }, { "blockNumber": 123, "logIndex": 0, "subscription": "wh_transaction:included", }, { "blockNumber": 123, "logIndex": 7, "subscription": "wh_log:emitted", }, { "blockNumber": 123, "logIndex": 4, "subscription": "wh_token:transfer", }, ] `) const [blockRow, txRow, logRow, transferRow] = result.matches.map((match) => match.data) expect(blockRow).toMatchObject({ gasUsed: 21000, miner: `0x${'12'.repeat(20)}`, number: 123, timestamp: '2026-01-01T00:00:00.000Z', transactionCount: 1, }) expect(txRow).toMatchObject({ hash: transactionHash, id: transactionHash, timestamp: '2026-01-01T00:00:00.000Z', }) expect(logRow).toMatchObject({ address: callTarget, logIndex: 7, topics: [customTopic, addressTopic(sender)], }) expect(transferRow).toMatchObject({ address: token, amount: (10n ** 18n).toString(), recipient, sender, timestamp: '2026-01-01T00:00:00.000Z', }) }) test('evaluates every filter operator against transactions', () => { const cases: readonly [Record, boolean][] = [ [{ from: sender }, true], [{ from: { eq: recipient } }, false], [{ from: { not: sender } }, false], [{ from: { not: recipient } }, true], [{ to: { in: [callTarget, recipient] } }, true], [{ to: { in: [callTarget] } }, true], // inner call target via includeCalls [{ to: { in: [callTarget] }, includeCalls: false }, false], [{ value: { gt: '0xde0b6b3a763ffff' } }, true], [{ value: { gte: `0x${(10n ** 18n).toString(16)}` } }, true], [{ value: { lt: '0x1' } }, false], [{ value: { lte: `0x${(10n ** 18n).toString(16)}` } }, true], [{ gasLimit: { gte: '0x5208' } }, true], [{ nonce: { lte: '0x0' } }, false], [{ input: { selector: '0xa9059cbb' } }, true], [{ input: { startsWith: '0xa905' } }, true], [{ hash: transactionHash }, true], [{ calls: { to: callTarget } }, true], [{ txType: '0x2' }, true], [{ blockNumber: { eq: '0x7b' } }, true], [{ timestamp: { gte: timestampHex } }, true], ] for (const [filters, expected] of cases) { const result = AppWebhooks.scanBlock({ block: block(), logs: [], subscriptions: [subscription('transaction:included', filters)], }) expect(result.invalid, JSON.stringify(filters)).toEqual([]) expect(result.matches.length, JSON.stringify(filters)).toBe(expected ? 1 : 0) } }) test('matches decoded event arguments and raw topics for logs', () => { const signature = 'event Transfer(address indexed from, address indexed to, uint256 value)' const scan = (filters: Record) => AppWebhooks.scanBlock({ block: block(), logs: [transferLog()], subscriptions: [subscription('log:emitted', filters)], }) const decoded = scan({ args: { from: sender }, signature }) expect(decoded.invalid).toEqual([]) expect(decoded.matches).toHaveLength(1) expect(decoded.matches[0]?.data).toMatchObject({ // Decoding checksums addresses. args: { from: getAddress(sender), to: getAddress(recipient), value: (10n ** 18n).toString() }, event: { signature, topic0: transferTopic }, }) expect(scan({ args: { from: recipient }, signature }).matches).toHaveLength(0) expect(scan({ topic0: transferTopic, topic1: addressTopic(sender) }).matches).toHaveLength(1) expect( scan({ topic0: transferTopic, topic1: addressTopic(sender), topic2: { not: addressTopic(recipient) }, }).matches, ).toHaveLength(0) expect(scan({ address: token, topic0: transferTopic }).matches).toHaveLength(1) // An args name absent from the ABI invalidates only that subscription. const unknownArg = AppWebhooks.scanBlock({ block: block(), logs: [transferLog()], subscriptions: [ subscription('log:emitted', { args: { bogus: sender }, signature }), subscription('token:transfer', { token }), ], }) expect(unknownArg.invalid).toHaveLength(1) expect(unknownArg.matches).toHaveLength(1) // Arg names fail closed even when the block has no decodable logs. const unknownArgEmpty = AppWebhooks.scanBlock({ block: block(), logs: [], subscriptions: [subscription('log:emitted', { args: { bogus: sender }, signature })], }) expect(unknownArgEmpty.invalid).toHaveLength(1) const invalid = scan({ signature: 'not a signature' }) expect(invalid.matches).toHaveLength(0) expect(invalid.invalid).toHaveLength(1) }) test('rolls back partial matches when a matcher throws mid-block', () => { const signature = 'event Named(string value)' const namedTopic = encodeEventTopics({ abi: [parseAbiItem(signature)] })[0] const namedLog = (value: string, logIndex: string) => ({ address: callTarget, blockNumber: '0x7b', data: encodeAbiParameters([{ name: 'value', type: 'string' }], [value]), logIndex, topics: [namedTopic], transactionHash, transactionIndex: '0x0', }) as never // The first log matches the numeric comparison; the second's nonnumeric // string makes the matcher throw after that match was recorded. const result = AppWebhooks.scanBlock({ block: block(), logs: [namedLog('0x2', '0x1'), namedLog('not numeric', '0x2'), transferLog()], subscriptions: [ subscription('log:emitted', { args: { value: { gt: '0x1' } }, signature }), subscription('token:transfer', { token }), ], }) expect(result.invalid).toHaveLength(1) expect(result.invalid[0]?.subscription.id).toBe('wh_log:emitted') expect(result.matches.map((match) => match.subscription.id)).toEqual(['wh_token:transfer']) }) test('matches transfers on each side and ignores non-transfer logs', () => { const scan = (filters: Record, logs = [transferLog()]) => AppWebhooks.scanBlock({ block: block(), logs, subscriptions: [subscription('token:transfer', filters)], }).matches expect(scan({ sender })).toHaveLength(1) expect(scan({ recipient })).toHaveLength(1) expect(scan({ address: sender })).toHaveLength(1) expect(scan({ address: callTarget })).toHaveLength(0) expect(scan({ token: '0x20c0000000000000000000000000000000000002' })).toHaveLength(0) // Outside the TIP-20 range and malformed topics both fail decoding. expect(scan({}, [transferLog({ address: callTarget })])).toHaveLength(0) expect(scan({}, [transferLog({ topics: [transferTopic] })])).toHaveLength(0) }) test('fails closed on invalid filters and unparsable blocks', () => { const mixed = AppWebhooks.scanBlock({ block: block(), logs: [transferLog()], subscriptions: [ subscription('token:transfer', { token: 123 }), subscription('token:transfer', { token }), ], }) expect(mixed.invalid).toHaveLength(1) expect(mixed.invalid[0]?.subscription.filters).toEqual({ token: 123 }) expect(mixed.matches).toHaveLength(1) const unparsable = AppWebhooks.scanBlock({ block: block({ number: undefined }), logs: [transferLog()], subscriptions: [subscription('block:created'), subscription('token:transfer', {})], }) expect(unparsable.matches).toEqual([]) }) test('matches blocks on header fields and never on proposer', () => { const scan = (filters: Record) => AppWebhooks.scanBlock({ block: block(), logs: [], subscriptions: [subscription('block:created', filters)], }).matches expect(scan({})).toHaveLength(1) expect(scan({ number: { eq: '0x7b' } })).toHaveLength(1) expect(scan({ miner: `0x${'12'.repeat(20)}` })).toHaveLength(1) expect(scan({ gasUsed: { gt: '0x5207' } })).toHaveLength(1) expect(scan({ gasUsed: { lt: '0x5208' } })).toHaveLength(0) expect(scan({ timestamp: { gte: timestampHex } })).toHaveLength(1) expect(scan({ proposer: `0x${'12'.repeat(20)}` })).toHaveLength(0) }) })