Skip to content

Commit c336a6e

Browse files
Merge pull request #138 from deslawson/main
Soroban Contract Sync System
2 parents 28bf585 + e3bcf68 commit c336a6e

16 files changed

Lines changed: 1760 additions & 49 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect } from 'vitest'
2+
3+
describe('contract-sync barrel exports', () => {
4+
it('exports ContractSyncService', async () => {
5+
const mod = await import('@/lib/contract-sync')
6+
expect(mod.ContractSyncService).toBeDefined()
7+
})
8+
9+
it('exports SorobanEventListener', async () => {
10+
const mod = await import('@/lib/contract-sync')
11+
expect(mod.SorobanEventListener).toBeDefined()
12+
})
13+
14+
it('exports SyncQueue', async () => {
15+
const mod = await import('@/lib/contract-sync')
16+
expect(mod.SyncQueue).toBeDefined()
17+
})
18+
19+
it('exports mapEventToAction', async () => {
20+
const mod = await import('@/lib/contract-sync')
21+
expect(mod.mapEventToAction).toBeDefined()
22+
})
23+
24+
it('exports helper functions', async () => {
25+
const mod = await import('@/lib/contract-sync')
26+
expect(mod.getDefaultMaxRetries()).toBe(5)
27+
expect(mod.getBackoffDelay(0)).toBe(1000)
28+
expect(mod.ESCROW_EVENT_TOPIC_PREFIX).toBe('escrow_event')
29+
})
30+
31+
it('exports types', async () => {
32+
const mod = await import('@/lib/contract-sync')
33+
const types = [
34+
'SorobanContractEvent',
35+
'SorobanEventPayload',
36+
'SyncStatus',
37+
'ContractSyncLog',
38+
'SyncQueueItem',
39+
'SyncAction',
40+
'ContractStatusUpdate',
41+
'MilestoneStatusUpdate',
42+
]
43+
for (const typeName of types) {
44+
// TypeScript types are erased at runtime, so they'll be undefined
45+
// but we can check they exist in the module
46+
expect(typeName).toBeDefined()
47+
}
48+
})
49+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { SorobanEventListener } from '@/lib/contract-sync/listener'
3+
4+
const { mockGetLatestLedger, mockGetEvents, MockSorobanServer } = vi.hoisted(() => {
5+
const mGetLatestLedger = vi.fn()
6+
const mGetEvents = vi.fn()
7+
8+
const MServer = class {
9+
getLatestLedger = mGetLatestLedger
10+
getEvents = mGetEvents
11+
}
12+
13+
return {
14+
mockGetLatestLedger: mGetLatestLedger,
15+
mockGetEvents: mGetEvents,
16+
MockSorobanServer: MServer,
17+
}
18+
})
19+
20+
vi.mock('@stellar/stellar-sdk', () => ({
21+
default: MockSorobanServer,
22+
}))
23+
24+
describe('SorobanEventListener', () => {
25+
let listener: SorobanEventListener
26+
let callback: ReturnType<typeof vi.fn>
27+
28+
beforeEach(() => {
29+
vi.useFakeTimers()
30+
callback = vi.fn()
31+
mockGetLatestLedger.mockReset()
32+
mockGetEvents.mockReset()
33+
34+
listener = new SorobanEventListener({
35+
rpcUrl: 'https://soroban-testnet.stellar.org',
36+
networkPassphrase: 'Test SDF Network ; September 2015',
37+
contractAddresses: ['CA1234'],
38+
pollIntervalMs: 1000,
39+
maxLedgerOffset: 100,
40+
})
41+
42+
listener.setCallback(callback as any)
43+
})
44+
45+
afterEach(() => {
46+
listener.stop()
47+
vi.useRealTimers()
48+
})
49+
50+
it('initializes with correct options', () => {
51+
expect(listener.isRunning).toBe(false)
52+
})
53+
54+
it('starts and sets running flag', async () => {
55+
mockGetLatestLedger.mockResolvedValue({ sequence: 500 })
56+
57+
await listener.start()
58+
expect(listener.isRunning).toBe(true)
59+
})
60+
61+
it('stops and clears running flag', async () => {
62+
mockGetLatestLedger.mockResolvedValue({ sequence: 500 })
63+
64+
await listener.start()
65+
listener.stop()
66+
expect(listener.isRunning).toBe(false)
67+
})
68+
69+
it('does not start twice', async () => {
70+
mockGetLatestLedger.mockResolvedValue({ sequence: 500 })
71+
72+
await listener.start()
73+
await listener.start()
74+
expect(mockGetLatestLedger).toHaveBeenCalledTimes(1)
75+
})
76+
77+
it('fetches latest ledger on start', async () => {
78+
mockGetLatestLedger.mockResolvedValue({ sequence: 500 })
79+
80+
await listener.start()
81+
expect(mockGetLatestLedger).toHaveBeenCalledTimes(1)
82+
})
83+
})
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { mapEventToAction } from '@/lib/contract-sync/mapper'
3+
import type { SorobanEventPayload } from '@/lib/contract-sync/types'
4+
5+
function makePayload(overrides: Partial<SorobanEventPayload> = {}): SorobanEventPayload {
6+
return {
7+
event: 'init',
8+
contractAddress: 'CA1234',
9+
ledgerSequence: 1000,
10+
timestamp: Date.now(),
11+
txHash: 'abc123',
12+
data: [],
13+
...overrides,
14+
}
15+
}
16+
17+
describe('mapEventToAction', () => {
18+
it('maps init to noop', () => {
19+
const payload = makePayload({ event: 'init' })
20+
const action = mapEventToAction('init', payload)
21+
expect(action.kind).toBe('noop')
22+
expect(action.contractUpdate).toBeNull()
23+
expect(action.milestoneUpdate).toBeNull()
24+
})
25+
26+
it('maps fund to contract escrow_status=funded', () => {
27+
const payload = makePayload({ event: 'fund' })
28+
const action = mapEventToAction('fund', payload)
29+
expect(action.kind).toBe('update_contract')
30+
expect(action.contractUpdate?.escrowStatus).toBe('funded')
31+
expect(action.contractUpdate?.contractStatus).toBe('active')
32+
expect(action.contractUpdate?.fundedAt).toBeDefined()
33+
expect(action.contractUpdate?.startedAt).toBeDefined()
34+
expect(action.milestoneUpdate).toBeNull()
35+
})
36+
37+
it('maps submit to milestone status=submitted', () => {
38+
const payload = makePayload({ event: 'submit', milestoneId: 1 })
39+
const action = mapEventToAction('submit', payload)
40+
expect(action.kind).toBe('update_milestone')
41+
expect(action.milestoneUpdate?.status).toBe('submitted')
42+
expect(action.milestoneUpdate?.submittedAt).toBeDefined()
43+
expect(action.milestoneId).toBe(1)
44+
expect(action.contractUpdate).toBeNull()
45+
})
46+
47+
it('maps approve to milestone status=approved', () => {
48+
const payload = makePayload({ event: 'approve', milestoneId: 2 })
49+
const action = mapEventToAction('approve', payload)
50+
expect(action.kind).toBe('update_milestone')
51+
expect(action.milestoneUpdate?.status).toBe('approved')
52+
expect(action.milestoneUpdate?.approvedAt).toBeDefined()
53+
})
54+
55+
it('maps confirm to milestone status=approved', () => {
56+
const payload = makePayload({ event: 'confirm', milestoneId: 2 })
57+
const action = mapEventToAction('confirm', payload)
58+
expect(action.kind).toBe('update_milestone')
59+
expect(action.milestoneUpdate?.status).toBe('approved')
60+
})
61+
62+
it('maps release to both contract and milestone update', () => {
63+
const payload = makePayload({ event: 'release', milestoneId: 1, amount: '100' })
64+
const action = mapEventToAction('release', payload)
65+
expect(action.kind).toBe('update_both')
66+
expect(action.contractUpdate?.escrowStatus).toBe('fully_released')
67+
expect(action.contractUpdate?.contractStatus).toBe('completed')
68+
expect(action.contractUpdate?.completedAt).toBeDefined()
69+
expect(action.milestoneUpdate?.status).toBe('paid')
70+
expect(action.milestoneUpdate?.paidAt).toBeDefined()
71+
expect(action.milestoneId).toBe(1)
72+
})
73+
74+
it('maps refund to both contract and milestone update', () => {
75+
const payload = makePayload({ event: 'refund', milestoneId: 1, amount: '50' })
76+
const action = mapEventToAction('refund', payload)
77+
expect(action.kind).toBe('update_both')
78+
expect(action.contractUpdate?.escrowStatus).toBe('refunded')
79+
expect(action.contractUpdate?.contractStatus).toBe('cancelled')
80+
expect(action.milestoneUpdate?.status).toBe('refunded')
81+
expect(action.contractUpdate?.cancelledReason).toBe('Refunded on-chain')
82+
})
83+
84+
it('maps dispute to contract status=disputed and milestone status=disputed', () => {
85+
const payload = makePayload({ event: 'dispute', milestoneId: 1 })
86+
const action = mapEventToAction('dispute', payload)
87+
expect(action.kind).toBe('update_both')
88+
expect(action.contractUpdate?.contractStatus).toBe('disputed')
89+
expect(action.milestoneUpdate?.status).toBe('disputed')
90+
expect(action.disputeInfo).not.toBeNull()
91+
expect(action.disputeInfo?.reason).toBe('Dispute raised on-chain')
92+
})
93+
94+
it('maps resolve to contract completed and milestone paid', () => {
95+
const payload = makePayload({ event: 'resolve', milestoneId: 1 })
96+
const action = mapEventToAction('resolve', payload)
97+
expect(action.kind).toBe('update_both')
98+
expect(action.contractUpdate?.contractStatus).toBe('completed')
99+
expect(action.milestoneUpdate?.status).toBe('paid')
100+
})
101+
102+
it('maps expire to milestone auto_expired', () => {
103+
const payload = makePayload({ event: 'expire', milestoneId: 1, amount: '100' })
104+
const action = mapEventToAction('expire', payload)
105+
expect(action.kind).toBe('update_milestone')
106+
expect(action.milestoneUpdate?.status).toBe('auto_expired')
107+
expect(action.milestoneUpdate?.rejectionReason).toBe('Milestone deadline exceeded')
108+
expect(action.milestoneId).toBe(1)
109+
})
110+
111+
it('handles unknown event as noop', () => {
112+
const payload = makePayload({ event: 'unknown' as any })
113+
const action = mapEventToAction('unknown' as any, payload)
114+
expect(action.kind).toBe('noop')
115+
})
116+
117+
it('handles submit event without milestoneId gracefully', () => {
118+
const payload = makePayload({ event: 'submit', milestoneId: undefined })
119+
const action = mapEventToAction('submit', payload)
120+
expect(action.milestoneId).toBeNull()
121+
})
122+
})

0 commit comments

Comments
 (0)