Skip to content

Commit 5d57905

Browse files
feat(onchain): replace vacuous ledger reconciliation with real RPC source
Replaces the placeholder fetchOnChainData (which returned [] and caused every stored row to be flagged as missing) with a real SorobanLedgerOnChainSource that pages the Stellar RPC getTransactions API and decodes aid_escrow #[contractevent] payloads into BalanceLedger entries. Key changes: - New LedgerOnChainSource interface + SorobanLedgerOnChainSource impl - decodeContractEvent maps contract event names to BalanceLedger vocabulary - Exact BigInt amount comparison (no float conversion) - Source errors and zero-entry ranges fail the job instead of producing fabricated 'completed' reports - OnchainProcessor routes ledger-reconciliation and ledger-backfill jobs - Comprehensive unit tests for source, decoder, and reconciliation service
1 parent 79a52f4 commit 5d57905

6 files changed

Lines changed: 951 additions & 38 deletions
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { ConfigService } from '@nestjs/config';
2+
import {
3+
xdr,
4+
StrKey,
5+
} from '@stellar/stellar-sdk';
6+
import {
7+
SorobanLedgerOnChainSource,
8+
decodeContractEvent,
9+
CONTRACT_EVENT_TO_BALANCE_LEDGER_TYPE,
10+
} from './ledger-on-chain-source';
11+
12+
/**
13+
* Tests for issue #427's real on-chain source: RPC pagination with explicit
14+
* range-coverage failures, and decoding of the aid_escrow `#[contractevent]`
15+
* payloads (topic symbol + named data map) into BalanceLedger entries.
16+
*/
17+
18+
const CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM';
19+
const OTHER_CONTRACT_ID = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4';
20+
21+
const mockServer = {
22+
getTransactions: jest.fn(),
23+
};
24+
25+
jest.mock('@stellar/stellar-sdk', () => {
26+
const actual = jest.requireActual('@stellar/stellar-sdk');
27+
return {
28+
...actual,
29+
rpc: {
30+
...actual.rpc,
31+
Server: jest.fn().mockImplementation(() => mockServer),
32+
},
33+
};
34+
});
35+
36+
const contractIdBytes = (StrKey.decodeContract(CONTRACT_ID) as unknown) as xdr.ContractId;
37+
38+
/** Build a `ContractEvent` mirroring the contract's `#[contractevent]` payloads. */
39+
function contractEvent(
40+
name: string,
41+
amount: bigint,
42+
opts: { contractId?: string; diagnostic?: boolean } = {},
43+
): xdr.ContractEvent {
44+
const data = xdr.ScVal.scvMap([
45+
new xdr.ScMapEntry({
46+
key: xdr.ScVal.scvSymbol('amount'),
47+
val: xdr.ScVal.scvI128(
48+
new xdr.Int128Parts({
49+
hi: xdr.Int64.fromString('0'),
50+
lo: xdr.Uint64.fromString(amount.toString()),
51+
}),
52+
),
53+
}),
54+
new xdr.ScMapEntry({
55+
key: xdr.ScVal.scvSymbol('package_id'),
56+
val: xdr.ScVal.scvU64(xdr.Uint64.fromString('42')),
57+
}),
58+
]);
59+
return new xdr.ContractEvent({
60+
ext: new xdr.ExtensionPoint(0),
61+
contractId: StrKey.decodeContract(
62+
opts.contractId ?? CONTRACT_ID,
63+
) as unknown as xdr.ContractId,
64+
type: opts.diagnostic
65+
? xdr.ContractEventType.diagnostic()
66+
: xdr.ContractEventType.contract(),
67+
body: new xdr.ContractEventBody(
68+
0,
69+
new xdr.ContractEventV0({
70+
topics: [xdr.ScVal.scvSymbol(name)],
71+
data,
72+
}),
73+
),
74+
});
75+
}
76+
77+
function eventWithoutAmount(name = 'package_created'): xdr.ContractEvent {
78+
return new xdr.ContractEvent({
79+
ext: new xdr.ExtensionPoint(0),
80+
contractId: contractIdBytes,
81+
type: xdr.ContractEventType.contract(),
82+
body: new xdr.ContractEventBody(
83+
0,
84+
new xdr.ContractEventV0({
85+
topics: [xdr.ScVal.scvSymbol(name)],
86+
data: xdr.ScVal.scvMap([]),
87+
}),
88+
),
89+
});
90+
}
91+
92+
function page(overrides: Record<string, unknown> = {}) {
93+
return {
94+
transactions: [],
95+
latestLedger: 2000,
96+
latestLedgerCloseTimestamp: 0,
97+
oldestLedger: 1,
98+
oldestLedgerCloseTimestamp: 0,
99+
cursor: '',
100+
...overrides,
101+
};
102+
}
103+
104+
function buildSource(): SorobanLedgerOnChainSource {
105+
const config = {
106+
get: jest.fn((key: string, fallback?: unknown) => {
107+
switch (key) {
108+
case 'STELLAR_RPC_URL':
109+
return 'https://soroban-testnet.stellar.org';
110+
case 'AID_ESCROW_CONTRACT_ID':
111+
return CONTRACT_ID;
112+
default:
113+
return fallback;
114+
}
115+
}),
116+
} as unknown as ConfigService;
117+
return new SorobanLedgerOnChainSource(config);
118+
}
119+
120+
describe('decodeContractEvent (issue #427)', () => {
121+
it('decodes package_created into a lock entry with the exact integer amount', () => {
122+
const event = contractEvent('package_created', 10000000n);
123+
const entry = decodeContractEvent(event, CONTRACT_ID, 'txhash', 1234, 0);
124+
125+
expect(entry).toEqual({
126+
id: 'txhash:0',
127+
ledger: 1234,
128+
amount: '10000000',
129+
eventType: 'lock',
130+
});
131+
});
132+
133+
it('maps every BalanceLedger-relevant contract event to its vocabulary', () => {
134+
const cases: Array<[string, string]> = [
135+
['package_created', 'lock'],
136+
['package_disbursed', 'disburse'],
137+
['package_revoked', 'unlock'],
138+
['package_refunded', 'unlock'],
139+
];
140+
for (const [eventName, eventType] of cases) {
141+
const entry = decodeContractEvent(
142+
contractEvent(eventName, 5n),
143+
CONTRACT_ID,
144+
'txhash',
145+
1,
146+
0,
147+
);
148+
expect(entry?.eventType).toBe(eventType);
149+
}
150+
expect(Object.keys(CONTRACT_EVENT_TO_BALANCE_LEDGER_TYPE)).toHaveLength(
151+
cases.length,
152+
);
153+
});
154+
155+
it('skips diagnostic events, events from other contracts, and unmapped events', () => {
156+
expect(
157+
decodeContractEvent(
158+
contractEvent('package_created', 1n, { diagnostic: true }),
159+
CONTRACT_ID,
160+
'tx',
161+
1,
162+
0,
163+
),
164+
).toBeNull();
165+
expect(
166+
decodeContractEvent(
167+
contractEvent('package_created', 1n, { contractId: OTHER_CONTRACT_ID }),
168+
CONTRACT_ID,
169+
'tx',
170+
1,
171+
0,
172+
),
173+
).toBeNull();
174+
expect(
175+
decodeContractEvent(contractEvent('escrow_funded', 1n), CONTRACT_ID, 'tx', 1, 0),
176+
).toBeNull();
177+
});
178+
179+
it('skips events without a numeric amount', () => {
180+
expect(
181+
decodeContractEvent(eventWithoutAmount(), CONTRACT_ID, 'tx', 1, 0),
182+
).toBeNull();
183+
});
184+
});
185+
186+
describe('SorobanLedgerOnChainSource.fetchLedgerEntries (issue #427)', () => {
187+
let source: SorobanLedgerOnChainSource;
188+
189+
beforeEach(() => {
190+
jest.clearAllMocks();
191+
source = buildSource();
192+
});
193+
194+
it('pages transactions and decodes their events into entries', async () => {
195+
const event = contractEvent('package_created', 10000000n);
196+
mockServer.getTransactions
197+
.mockResolvedValueOnce(
198+
page({
199+
transactions: [
200+
{
201+
status: 'SUCCESS',
202+
ledger: 1000,
203+
txHash: 'txhash-a',
204+
events: { contractEventsXdr: [[event]] },
205+
},
206+
{
207+
status: 'SUCCESS',
208+
ledger: 1001,
209+
txHash: 'txhash-b',
210+
events: { contractEventsXdr: [] },
211+
},
212+
],
213+
latestLedger: 1100,
214+
cursor: 'cursor-1',
215+
}),
216+
)
217+
.mockResolvedValueOnce(
218+
page({ transactions: [], latestLedger: 2000, oldestLedger: 1, cursor: '' }),
219+
);
220+
221+
const entries = await source.fetchLedgerEntries(1000, 1100);
222+
223+
expect(entries).toHaveLength(1);
224+
expect(entries[0]).toEqual({
225+
id: 'txhash-a:0',
226+
ledger: 1000,
227+
amount: '10000000',
228+
eventType: 'lock',
229+
});
230+
// Two pages: the first carries the events and a cursor, the second is
231+
// empty (cursor exhausted) — the loop then stops.
232+
expect(mockServer.getTransactions).toHaveBeenCalledTimes(2);
233+
});
234+
235+
it('fails loudly when the range extends beyond the chain head', async () => {
236+
mockServer.getTransactions.mockResolvedValue(
237+
page({ latestLedger: 900, oldestLedger: 1 }),
238+
);
239+
240+
await expect(source.fetchLedgerEntries(1000, 1100)).rejects.toThrow(
241+
'chain head is at ledger 900',
242+
);
243+
});
244+
245+
it('fails loudly when the requested range predates RPC retention', async () => {
246+
mockServer.getTransactions.mockResolvedValue(
247+
page({ latestLedger: 2000, oldestLedger: 1500 }),
248+
);
249+
250+
await expect(source.fetchLedgerEntries(1000, 1100)).rejects.toThrow(
251+
'RPC retention starts at ledger 1500',
252+
);
253+
});
254+
255+
it('returns an empty list for a fully covered range with no relevant events', async () => {
256+
mockServer.getTransactions.mockResolvedValue(
257+
page({ latestLedger: 2000, oldestLedger: 1 }),
258+
);
259+
260+
const entries = await source.fetchLedgerEntries(1000, 1100);
261+
262+
expect(entries).toEqual([]);
263+
});
264+
265+
it('does not fail the job on an empty covered range (the service decides)', async () => {
266+
mockServer.getTransactions.mockResolvedValue(
267+
page({ latestLedger: 2000, oldestLedger: 1 }),
268+
);
269+
270+
await expect(source.fetchLedgerEntries(1000, 1100)).resolves.toEqual([]);
271+
});
272+
});

0 commit comments

Comments
 (0)