Skip to content

Commit 0ca02f4

Browse files
Merge pull request #165 from modecodes/157-blockchain-event-indexer
fix(contract-sync): close reliability gaps in Soroban event indexer
2 parents 3a29358 + ff9b6b1 commit 0ca02f4

9 files changed

Lines changed: 501 additions & 26 deletions

File tree

__tests__/contract-sync/listener.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,116 @@ describe('SorobanEventListener', () => {
8080
await listener.start()
8181
expect(mockGetLatestLedger).toHaveBeenCalledTimes(1)
8282
})
83+
84+
describe('checkpoint resumption', () => {
85+
it('resumes from an initialLedger option instead of fetching the chain tip', async () => {
86+
const onCheckpoint = vi.fn()
87+
const resumable = new SorobanEventListener({
88+
rpcUrl: 'https://soroban-testnet.stellar.org',
89+
networkPassphrase: 'Test SDF Network ; September 2015',
90+
contractAddresses: ['CA1234'],
91+
pollIntervalMs: 1000,
92+
initialLedger: 900,
93+
onCheckpoint,
94+
})
95+
resumable.setCallback(callback as any)
96+
97+
await resumable.start()
98+
99+
expect(mockGetLatestLedger).not.toHaveBeenCalled()
100+
resumable.stop()
101+
})
102+
103+
it('setInitialLedger seeds the resume point before start()', async () => {
104+
listener.setInitialLedger(900)
105+
await listener.start()
106+
107+
expect(mockGetLatestLedger).not.toHaveBeenCalled()
108+
})
109+
110+
it('setInitialLedger is a no-op once already running', async () => {
111+
mockGetLatestLedger.mockResolvedValue({ sequence: 500 })
112+
await listener.start()
113+
114+
listener.setInitialLedger(900)
115+
mockGetEvents.mockResolvedValue({ events: [] })
116+
mockGetLatestLedger.mockResolvedValue({ sequence: 600 })
117+
118+
await vi.advanceTimersByTimeAsync(1000)
119+
120+
// startSeq should derive from the original 500 checkpoint (501), not 900
121+
expect(mockGetEvents).toHaveBeenCalledWith(
122+
expect.objectContaining({ startLedger: 501 })
123+
)
124+
})
125+
126+
it('invokes onCheckpoint with the new high-water-mark after each poll', async () => {
127+
const onCheckpoint = vi.fn()
128+
listener = new SorobanEventListener({
129+
rpcUrl: 'https://soroban-testnet.stellar.org',
130+
networkPassphrase: 'Test SDF Network ; September 2015',
131+
contractAddresses: ['CA1234'],
132+
pollIntervalMs: 1000,
133+
onCheckpoint,
134+
})
135+
listener.setCallback(callback as any)
136+
137+
mockGetLatestLedger.mockResolvedValueOnce({ sequence: 500 })
138+
await listener.start()
139+
140+
mockGetEvents.mockResolvedValue({ events: [] })
141+
mockGetLatestLedger.mockResolvedValue({ sequence: 505 })
142+
await vi.advanceTimersByTimeAsync(1000)
143+
144+
expect(onCheckpoint).toHaveBeenCalledWith(505)
145+
})
146+
147+
it('awaits the callback for each event before advancing the checkpoint', async () => {
148+
// Uses real timers and calls the private poll() directly so the
149+
// ordering can be observed deterministically without racing fake-timer
150+
// microtask flushing against a manually-controlled promise.
151+
vi.useRealTimers()
152+
153+
const order: string[] = []
154+
const onCheckpoint = vi.fn((ledger: number) => order.push(`checkpoint:${ledger}`))
155+
let resolveCallback!: () => void
156+
const slowCallback = vi.fn(
157+
() =>
158+
new Promise<void>((resolve) => {
159+
resolveCallback = () => {
160+
order.push('callback-resolved')
161+
resolve()
162+
}
163+
})
164+
)
165+
166+
const realtimeListener = new SorobanEventListener({
167+
rpcUrl: 'https://soroban-testnet.stellar.org',
168+
networkPassphrase: 'Test SDF Network ; September 2015',
169+
contractAddresses: ['CA1234'],
170+
pollIntervalMs: 1000,
171+
initialLedger: 500,
172+
onCheckpoint,
173+
})
174+
realtimeListener.setCallback(slowCallback)
175+
176+
mockGetEvents.mockResolvedValue({
177+
events: [{ topic: ['fund'], value: [], ledger: 501, txHash: 'tx1' }],
178+
})
179+
mockGetLatestLedger.mockResolvedValue({ sequence: 505 })
180+
181+
const pollPromise = (realtimeListener as any).poll()
182+
await Promise.resolve() // let poll() reach the awaited callback
183+
await Promise.resolve()
184+
expect(slowCallback).toHaveBeenCalled()
185+
expect(onCheckpoint).not.toHaveBeenCalled()
186+
187+
resolveCallback()
188+
await pollPromise
189+
190+
expect(order).toEqual(['callback-resolved', 'checkpoint:505'])
191+
192+
vi.useFakeTimers()
193+
})
194+
})
83195
})

__tests__/contract-sync/queue.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,44 @@ describe('SyncQueue', () => {
107107
expect(deadLetters[0].retryCount).toBeGreaterThanOrEqual(3)
108108
})
109109

110+
it('calls onRetry with the item and error message when a retry is scheduled', async () => {
111+
const onRetry = vi.fn()
112+
const retryQueue = new SyncQueue({ maxRetries: 3, concurrency: 2, pollIntervalMs: 100, onRetry })
113+
retryQueue.setHandler(async () => {
114+
throw new Error('transient failure')
115+
})
116+
117+
retryQueue.enqueue(makePayload({ txHash: 'retry001' }))
118+
retryQueue.start()
119+
await vi.advanceTimersByTimeAsync(100)
120+
retryQueue.stop()
121+
122+
expect(onRetry).toHaveBeenCalledTimes(1)
123+
const [item, error] = onRetry.mock.calls[0]
124+
expect(item.id).toBe('retry001:fund:0')
125+
expect(item.retryCount).toBe(1)
126+
expect(error).toBe('transient failure')
127+
})
128+
129+
it('calls onDeadLetter once retries are exhausted, and does not call onRetry for that attempt', async () => {
130+
const onRetry = vi.fn()
131+
const onDeadLetter = vi.fn()
132+
const dlQueue = new SyncQueue({ maxRetries: 1, concurrency: 2, pollIntervalMs: 100, onRetry, onDeadLetter })
133+
dlQueue.setHandler(async () => {
134+
throw new Error('fatal')
135+
})
136+
137+
dlQueue.enqueue(makePayload({ txHash: 'dead002' }))
138+
dlQueue.start()
139+
await vi.advanceTimersByTimeAsync(100)
140+
dlQueue.stop()
141+
142+
expect(onRetry).not.toHaveBeenCalled()
143+
expect(onDeadLetter).toHaveBeenCalledTimes(1)
144+
expect(onDeadLetter.mock.calls[0][0].id).toBe('dead002:fund:0')
145+
expect(onDeadLetter.mock.calls[0][0].status).toBe('dead_letter')
146+
})
147+
110148
it('returns empty dead letters when all succeed', async () => {
111149
handler.mockResolvedValue(undefined)
112150

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
3+
vi.mock('@/lib/db', () => ({
4+
sql: Object.assign(vi.fn(), { unsafe: vi.fn() }),
5+
}))
6+
7+
const { mockGetLatestLedger, mockGetEvents, MockSorobanServer } = vi.hoisted(() => {
8+
const mGetLatestLedger = vi.fn().mockResolvedValue({ sequence: 100 })
9+
const mGetEvents = vi.fn().mockResolvedValue({ events: [] })
10+
11+
const MServer = class {
12+
getLatestLedger = mGetLatestLedger
13+
getEvents = mGetEvents
14+
}
15+
16+
return {
17+
mockGetLatestLedger: mGetLatestLedger,
18+
mockGetEvents: mGetEvents,
19+
MockSorobanServer: MServer,
20+
}
21+
})
22+
23+
vi.mock('@stellar/stellar-sdk', () => ({
24+
default: MockSorobanServer,
25+
}))
26+
27+
import { sql } from '@/lib/db'
28+
import { ContractSyncService } from '@/lib/contract-sync/service'
29+
import type { SorobanEventPayload } from '@/lib/contract-sync/types'
30+
31+
function makePayload(overrides: Partial<SorobanEventPayload> = {}): SorobanEventPayload {
32+
return {
33+
event: 'fund',
34+
contractAddress: 'CA1234',
35+
ledgerSequence: 1000,
36+
timestamp: Date.now(),
37+
txHash: 'abc123',
38+
data: [],
39+
...overrides,
40+
}
41+
}
42+
43+
describe('ContractSyncService', () => {
44+
beforeEach(() => {
45+
vi.clearAllMocks()
46+
})
47+
48+
describe('onEvent (duplicate protection)', () => {
49+
it('skips enqueueing an event that was already synced successfully', async () => {
50+
const service = new ContractSyncService({ contractAddresses: ['CA1'] })
51+
const enqueueSpy = vi.spyOn(service.getQueue(), 'enqueue')
52+
53+
;(sql as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce([{ '?column?': 1 }])
54+
55+
await (service as any).onEvent(makePayload())
56+
57+
expect(enqueueSpy).not.toHaveBeenCalled()
58+
expect(sql).toHaveBeenCalledTimes(1)
59+
})
60+
61+
it('enqueues and logs an event that has not been synced before', async () => {
62+
const service = new ContractSyncService({ contractAddresses: ['CA1'] })
63+
const enqueueSpy = vi.spyOn(service.getQueue(), 'enqueue')
64+
65+
;(sql as unknown as ReturnType<typeof vi.fn>)
66+
.mockResolvedValueOnce([]) // isAlreadySynced -> not found
67+
.mockResolvedValueOnce([]) // createSyncLog insert
68+
69+
await (service as any).onEvent(makePayload({ txHash: 'new-tx' }))
70+
71+
expect(enqueueSpy).toHaveBeenCalledWith(expect.objectContaining({ txHash: 'new-tx' }))
72+
expect(sql).toHaveBeenCalledTimes(2)
73+
})
74+
})
75+
76+
describe('checkpoint persistence', () => {
77+
it('loadCheckpoint returns null when no row exists', async () => {
78+
const service = new ContractSyncService({ contractAddresses: ['CA1'] })
79+
;(sql as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce([])
80+
81+
const result = await (service as any).loadCheckpoint()
82+
expect(result).toBeNull()
83+
})
84+
85+
it('loadCheckpoint returns the persisted ledger as a number', async () => {
86+
const service = new ContractSyncService({ contractAddresses: ['CA1'] })
87+
;(sql as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce([{ last_ledger: '4242' }])
88+
89+
const result = await (service as any).loadCheckpoint()
90+
expect(result).toBe(4242)
91+
})
92+
93+
it('persistCheckpoint issues an upsert', async () => {
94+
const service = new ContractSyncService({ contractAddresses: ['CA1'] })
95+
;(sql as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce([])
96+
97+
await (service as any).persistCheckpoint(555)
98+
expect(sql).toHaveBeenCalledTimes(1)
99+
})
100+
101+
it('start() seeds the listener from a persisted checkpoint instead of the chain tip', async () => {
102+
const service = new ContractSyncService({ contractAddresses: ['CA1'] })
103+
;(sql as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce([{ last_ledger: '777' }])
104+
105+
await service.start()
106+
service.stop()
107+
108+
expect(mockGetLatestLedger).not.toHaveBeenCalled()
109+
})
110+
})
111+
112+
describe('failure / dead-letter audit logging', () => {
113+
it('records a failed sync attempt with the error message on retry', async () => {
114+
const service = new ContractSyncService({ contractAddresses: ['CA1'], maxRetries: 5 })
115+
const updateSpy = vi.spyOn(service as any, 'updateSyncLog').mockResolvedValue(undefined)
116+
117+
const queue = service.getQueue()
118+
queue.setHandler(async () => {
119+
throw new Error('boom')
120+
})
121+
122+
vi.useFakeTimers()
123+
queue.enqueue(makePayload({ txHash: 'fail-tx' }))
124+
queue.start()
125+
await vi.advanceTimersByTimeAsync(1000)
126+
queue.stop()
127+
vi.useRealTimers()
128+
129+
expect(updateSpy).toHaveBeenCalledWith(
130+
'fail-tx:fund:0',
131+
expect.objectContaining({ status: 'failed', errorMessage: 'boom' })
132+
)
133+
})
134+
135+
it('records dead_letter status once retries are exhausted', async () => {
136+
const service = new ContractSyncService({ contractAddresses: ['CA1'], maxRetries: 1 })
137+
const updateSpy = vi.spyOn(service as any, 'updateSyncLog').mockResolvedValue(undefined)
138+
139+
const queue = service.getQueue()
140+
queue.setHandler(async () => {
141+
throw new Error('permanent failure')
142+
})
143+
144+
vi.useFakeTimers()
145+
queue.enqueue(makePayload({ txHash: 'dead-tx' }))
146+
queue.start()
147+
await vi.advanceTimersByTimeAsync(1000)
148+
queue.stop()
149+
vi.useRealTimers()
150+
151+
expect(updateSpy).toHaveBeenCalledWith(
152+
'dead-tx:fund:0',
153+
expect.objectContaining({ status: 'dead_letter', errorMessage: 'permanent failure' })
154+
)
155+
})
156+
})
157+
})

0 commit comments

Comments
 (0)