Skip to content

Commit bb8eb0a

Browse files
committed
feat: pagination in list-transaction rpc call
1 parent 6fb81a4 commit bb8eb0a

File tree

9 files changed

+167
-232
lines changed

9 files changed

+167
-232
lines changed
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import type { Transaction } from '../types/snapState';
1+
import type { Transaction, TransactionsCursor } from '../types/snapState';
22

33
export type IDataClient = {
4-
getTransactions: (address: string, tillTo: number) => Promise<Transaction[]>;
4+
getTransactions: (
5+
address: string,
6+
cursor?: TransactionsCursor,
7+
) => Promise<{ transactions: Transaction[]; cursor: TransactionsCursor }>;
58
getDeployTransaction: (address: string) => Promise<Transaction | null>;
69
};

packages/starknet-snap/src/chain/data-client/starkscan.test.ts

Lines changed: 7 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -203,138 +203,37 @@ describe('StarkScanClient', () => {
203203
});
204204

205205
describe('getTransactions', () => {
206-
const mSecsFor24Hours = 1000 * 60 * 60 * 24;
207-
208-
const getFromAndToTimestamp = (tillToInDay: number) => {
209-
const from = Math.floor(Date.now() / 1000);
210-
const to = from - tillToInDay * 24 * 60 * 60;
211-
return {
212-
from,
213-
to,
214-
};
215-
};
216-
217206
it('returns transactions', async () => {
218207
const account = await mockAccount();
219208
const { fetchSpy } = createMockFetch();
220-
const { from, to } = getFromAndToTimestamp(5);
221209
// generate 10 invoke transactions
222210
const mockResponse = generateStarkScanTransactions({
223211
address: account.address,
224-
startFrom: from,
225212
});
226213
mockApiSuccess({ fetchSpy, response: mockResponse });
227214

228215
const client = createMockClient();
229-
const result = await client.getTransactions(account.address, to);
230-
231-
// The result should include the transaction if:
232-
// - it's timestamp is greater than the `tillTo`
233-
// - it's transaction type is `DEPLOY_ACCOUNT`
234-
expect(result).toHaveLength(
235-
mockResponse.data.filter(
236-
(tx) =>
237-
tx.transaction_type === TransactionType.DEPLOY_ACCOUNT ||
238-
tx.timestamp >= to,
239-
).length,
240-
);
216+
const result = await client.getTransactions(account.address);
217+
expect(result.transactions).toHaveLength(mockResponse.data.length);
241218
expect(
242-
result.find((tx) => tx.txnType === TransactionType.DEPLOY_ACCOUNT),
219+
result.transactions.find(
220+
(tx) => tx.txnType === TransactionType.DEPLOY_ACCOUNT,
221+
),
243222
).toBeDefined();
244223
});
245224

246225
it('returns empty array if no result found', async () => {
247226
const account = await mockAccount();
248227
const { fetchSpy } = createMockFetch();
249-
const { to } = getFromAndToTimestamp(5);
250228
// mock the get invoke transactions response with empty data
251229
mockApiSuccess({ fetchSpy });
252230
// mock the get deploy transaction response with empty data
253231
mockApiSuccess({ fetchSpy });
254232

255233
const client = createMockClient();
256-
const result = await client.getTransactions(account.address, to);
257-
258-
expect(result).toStrictEqual([]);
259-
});
260-
261-
it('continue to fetch if next_url is presented', async () => {
262-
const account = await mockAccount();
263-
const { fetchSpy } = createMockFetch();
264-
// generate the to timestamp which is 100 days ago
265-
const { to } = getFromAndToTimestamp(100);
266-
const mockPage1Response = generateStarkScanTransactions({
267-
address: account.address,
268-
txnTypes: [TransactionType.INVOKE],
269-
cnt: 10,
270-
});
271-
const mockPage2Response = generateStarkScanTransactions({
272-
address: account.address,
273-
cnt: 10,
274-
});
275-
const firstPageUrl = `https://api-sepolia.starkscan.co/api/v0/transactions?contract_address=${account.address}&order_by=desc&limit=100`;
276-
const nextPageUrl = `https://api-sepolia.starkscan.co/api/v0/transactions?contract_address=${account.address}&order_by=desc&cursor=MTcyNDc1OTQwNzAwMDAwNjAwMDAwMA%3D%3D`;
277-
278-
// mock the first page response, which contains the next_url
279-
mockApiSuccess({
280-
fetchSpy,
281-
response: {
282-
data: mockPage1Response.data,
283-
// eslint-disable-next-line @typescript-eslint/naming-convention
284-
next_url: nextPageUrl,
285-
},
286-
});
287-
// mock the send page response
288-
mockApiSuccess({ fetchSpy, response: mockPage2Response });
289-
290-
const client = createMockClient();
291-
await client.getTransactions(account.address, to);
292-
293-
expect(fetchSpy).toHaveBeenCalledTimes(2);
294-
expect(fetchSpy).toHaveBeenNthCalledWith(
295-
1,
296-
firstPageUrl,
297-
expect.any(Object),
298-
);
299-
expect(fetchSpy).toHaveBeenNthCalledWith(
300-
2,
301-
nextPageUrl,
302-
expect.any(Object),
303-
);
304-
});
234+
const result = await client.getTransactions(account.address);
305235

306-
it('fetchs the deploy transaction if it is not present', async () => {
307-
const account = await mockAccount();
308-
const { fetchSpy } = createMockFetch();
309-
// generate the to timestamp which is 5 days ago
310-
const { from, to } = getFromAndToTimestamp(5);
311-
// generate 10 invoke transactions, and 1 day time gap between each transaction
312-
const mockInvokeResponse = generateStarkScanTransactions({
313-
address: account.address,
314-
startFrom: from,
315-
timestampReduction: mSecsFor24Hours,
316-
txnTypes: [TransactionType.INVOKE],
317-
});
318-
// generate another 5 invoke transactions + deploy transactions for testing the fallback case
319-
const mockDeployResponse = generateStarkScanTransactions({
320-
address: account.address,
321-
// generate transactions that start from 100 days ago, to ensure not overlap with above invoke transactions
322-
startFrom: mSecsFor24Hours * 100,
323-
timestampReduction: mSecsFor24Hours,
324-
txnTypes: [TransactionType.INVOKE, TransactionType.DEPLOY_ACCOUNT],
325-
cnt: 5,
326-
});
327-
mockApiSuccess({ fetchSpy, response: mockInvokeResponse });
328-
mockApiSuccess({ fetchSpy, response: mockDeployResponse });
329-
330-
const client = createMockClient();
331-
// We only fetch the transactions from the last 5 days
332-
const result = await client.getTransactions(account.address, to);
333-
334-
// The result should include a deploy transaction, even it is not from the last 5 days
335-
expect(
336-
result.find((tx) => tx.txnType === TransactionType.DEPLOY_ACCOUNT),
337-
).toBeDefined();
236+
expect(result.transactions).toStrictEqual([]);
338237
});
339238
});
340239

packages/starknet-snap/src/chain/data-client/starkscan.ts

Lines changed: 41 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { TransactionType, constants } from 'starknet';
22
import type { Struct } from 'superstruct';
33

4-
import type { V2Transaction } from '../../types/snapState';
4+
import type { TransactionsCursor, V2Transaction } from '../../types/snapState';
55
import { type Network, type Transaction } from '../../types/snapState';
66
import { InvalidNetworkError } from '../../utils/exceptions';
77
import {
@@ -21,7 +21,7 @@ import {
2121
export class StarkScanClient extends ApiClient implements IDataClient {
2222
apiClientName = 'StarkScanClient';
2323

24-
protected limit = 100;
24+
protected limit = 10;
2525

2626
protected network: Network;
2727

@@ -78,73 +78,58 @@ export class StarkScanClient extends ApiClient implements IDataClient {
7878

7979
/**
8080
* Fetches the transactions for a given contract address.
81-
* The transactions are fetched in descending order and it will include the deploy transaction.
81+
* Transactions are retrieved in descending order for pagination.
8282
*
83-
* @param address - The address of the contract to fetch the transactions for.
84-
* @param to - The filter includes transactions with a timestamp that is >= a specified value, but the deploy transaction is always included regardless of its timestamp.
85-
* @returns A Promise that resolve an array of Transaction object.
83+
* @param address - The contract address to fetch transactions for.
84+
* @param cursor - Optional pagination cursor.
85+
* @param cursor.blockNumber - The block number for pagination.
86+
* @param cursor.txnHash - The transaction hash for pagination.
87+
* @returns A Promise resolving to an object with transactions and a pagination cursor.
8688
*/
87-
async getTransactions(address: string, to: number): Promise<Transaction[]> {
89+
async getTransactions(
90+
address: string,
91+
cursor?: { blockNumber: number; txnHash: string },
92+
): Promise<{ transactions: Transaction[]; cursor: TransactionsCursor }> {
8893
let apiUrl = this.getApiUrl(
8994
`/transactions?contract_address=${address}&order_by=desc&limit=${this.limit}`,
9095
);
9196

97+
if (cursor !== undefined) {
98+
apiUrl += `&to_block=${cursor.blockNumber}`;
99+
}
100+
92101
const txs: Transaction[] = [];
93-
let deployTxFound = false;
94-
let process = true;
95-
let timestamp = 0;
96-
97-
// Scan the transactions in descending order by timestamp
98-
// Include the transaction if:
99-
// - it's timestamp is greater than the `tillTo` AND
100-
// - there is an next data to fetch
101-
while (process && (timestamp === 0 || timestamp >= to)) {
102-
process = false;
103-
104-
const result = await this.sendApiRequest<StarkScanTransactionsResponse>({
105-
apiUrl,
106-
responseStruct: StarkScanTransactionsResponseStruct,
107-
requestName: 'getTransactions',
108-
});
102+
let newCursor: TransactionsCursor = {
103+
blockNumber: -1,
104+
txnHash: '',
105+
};
109106

110-
for (const data of result.data) {
111-
const tx = this.toTransaction(data);
112-
const isDeployTx = this.isDeployTransaction(data);
113-
114-
if (isDeployTx) {
115-
deployTxFound = true;
116-
}
117-
118-
timestamp = tx.timestamp;
119-
// Only include the records that newer than or equal to the `to` timestamp from the same batch of result
120-
// If there is an deploy transaction from the result, it should included too.
121-
// e.g
122-
// to: 1000
123-
// [
124-
// { timestamp: 1100, transaction_type: "invoke" }, <-- include
125-
// { timestamp: 900, transaction_type: "invoke" }, <-- exclude
126-
// { timestamp: 100, transaction_type: "deploy" } <-- include
127-
// ]
128-
if (timestamp >= to || isDeployTx) {
129-
txs.push(tx);
130-
}
131-
}
107+
const result = await this.sendApiRequest<StarkScanTransactionsResponse>({
108+
apiUrl,
109+
responseStruct: StarkScanTransactionsResponseStruct,
110+
requestName: 'getTransactions',
111+
});
132112

133-
if (result.next_url) {
134-
apiUrl = result.next_url;
135-
process = true;
136-
}
113+
const matchingIndex = cursor
114+
? result.data.findIndex((txn) => txn.transaction_hash === cursor.txnHash)
115+
: -1;
116+
117+
const startIndex = matchingIndex >= 0 ? matchingIndex + 1 : 0;
118+
console.log('startIndex', startIndex);
119+
for (let i = startIndex; i < result.data.length; i++) {
120+
const tx = this.toTransaction(result.data[i]);
121+
txs.push(tx);
137122
}
138123

139-
// In case no deploy transaction found from above,
140-
// then scan the transactions in asc order by timestamp,
141-
// the deploy transaction should usually be the first transaction from the list
142-
if (!deployTxFound) {
143-
const deployTx = await this.getDeployTransaction(address);
144-
deployTx && txs.push(deployTx);
124+
if (result.data.length > 0) {
125+
const lastTx = result.data[result.data.length - 1];
126+
newCursor = {
127+
blockNumber: lastTx.block_number,
128+
txnHash: lastTx.transaction_hash,
129+
};
145130
}
146131

147-
return txs;
132+
return { transactions: txs, cursor: newCursor };
148133
}
149134

150135
/**

packages/starknet-snap/src/chain/data-client/starkscan.type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const StarkScanTransactionStruct = object({
2525
transaction_type: enums(Object.values(TransactionType)),
2626
// The transaction version, 1 or 3, where 3 represents the fee will be paid in STRK
2727
version: number(),
28+
block_number: number(),
2829
max_fee: NullableStringStruct,
2930
actual_fee: NullableStringStruct,
3031
nonce: NullableStringStruct,

packages/starknet-snap/src/chain/transaction-service.test.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,9 @@ describe('TransactionService', () => {
1717
async *getTransactionsOnChain(
1818
address: string,
1919
contractAddress: string,
20-
tillToInDays: number,
20+
cursor?: { blockNumber: number; txnHash: string },
2121
) {
22-
yield* super.getTransactionsOnChain(
23-
address,
24-
contractAddress,
25-
tillToInDays,
26-
);
22+
yield* super.getTransactionsOnChain(address, contractAddress, cursor);
2723
}
2824

2925
async *getTransactionsOnState(address: string, contractAddress: string) {
@@ -124,7 +120,13 @@ describe('TransactionService', () => {
124120
const { getTransactionsSpy, dataClient } = mockDataClient();
125121
removeTransactionsSpy.mockReturnThis();
126122
findTransactionsSpy.mockResolvedValue(transactionsFromDataClientOrState);
127-
getTransactionsSpy.mockResolvedValue(transactionsFromDataClientOrState);
123+
getTransactionsSpy.mockResolvedValue({
124+
transactions: transactionsFromDataClientOrState,
125+
cursor: {
126+
blockNumber: -1,
127+
txnHash: '',
128+
},
129+
});
128130

129131
const service = mockTransactionService(network, dataClient);
130132

@@ -160,15 +162,12 @@ describe('TransactionService', () => {
160162
for await (const tx of service.getTransactionsOnChain(
161163
address,
162164
contractAddress,
163-
10,
164165
)) {
165-
transactions.push(tx);
166+
if (tx && typeof tx === 'object' && 'txnHash' in tx) {
167+
transactions.push(tx);
168+
}
166169
}
167-
168-
expect(getTransactionsSpy).toHaveBeenCalledWith(
169-
address,
170-
expect.any(Number),
171-
);
170+
expect(getTransactionsSpy).toHaveBeenCalledWith(address, undefined);
172171
expect(transactions).toStrictEqual(filteredTransactions);
173172
});
174173
});
@@ -230,15 +229,11 @@ describe('TransactionService', () => {
230229
});
231230
findTransactionsSpy.mockResolvedValue(transactionFromState);
232231

233-
const result = await service.getTransactions(
234-
address,
235-
contractAddress,
236-
10,
237-
);
232+
const result = await service.getTransactions(address, contractAddress);
238233

239234
const expectedResult = transactionFromState.concat(transactionsFromChain);
240235

241-
expect(result).toStrictEqual(expectedResult);
236+
expect(result.transactions).toStrictEqual(expectedResult);
242237
});
243238

244239
it('remove the transactions that are already on chain', async () => {
@@ -257,7 +252,7 @@ describe('TransactionService', () => {
257252

258253
findTransactionsSpy.mockResolvedValue(duplicatedTransactions);
259254

260-
await service.getTransactions(address, contractAddress, 10);
255+
await service.getTransactions(address, contractAddress);
261256

262257
expect(removeTransactionsSpy).toHaveBeenCalledWith({
263258
txnHash: [duplicatedTransactions[0].txnHash],

0 commit comments

Comments
 (0)