Skip to content

feat: pagination in list-transaction rpc call #546

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/starknet-snap/src/chain/data-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { Transaction } from '../types/snapState';
import type { Transaction, TransactionsCursor } from '../types/snapState';

export type IDataClient = {
getTransactions: (address: string, tillTo: number) => Promise<Transaction[]>;
getTransactions: (
address: string,
cursor?: TransactionsCursor,
) => Promise<{ transactions: Transaction[]; cursor: TransactionsCursor }>;
getDeployTransaction: (address: string) => Promise<Transaction | null>;
};
115 changes: 7 additions & 108 deletions packages/starknet-snap/src/chain/data-client/starkscan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,138 +203,37 @@ describe('StarkScanClient', () => {
});

describe('getTransactions', () => {
const mSecsFor24Hours = 1000 * 60 * 60 * 24;

const getFromAndToTimestamp = (tillToInDay: number) => {
const from = Math.floor(Date.now() / 1000);
const to = from - tillToInDay * 24 * 60 * 60;
return {
from,
to,
};
};

it('returns transactions', async () => {
const account = await mockAccount();
const { fetchSpy } = createMockFetch();
const { from, to } = getFromAndToTimestamp(5);
// generate 10 invoke transactions
const mockResponse = generateStarkScanTransactions({
address: account.address,
startFrom: from,
});
mockApiSuccess({ fetchSpy, response: mockResponse });

const client = createMockClient();
const result = await client.getTransactions(account.address, to);

// The result should include the transaction if:
// - it's timestamp is greater than the `tillTo`
// - it's transaction type is `DEPLOY_ACCOUNT`
expect(result).toHaveLength(
mockResponse.data.filter(
(tx) =>
tx.transaction_type === TransactionType.DEPLOY_ACCOUNT ||
tx.timestamp >= to,
).length,
);
const result = await client.getTransactions(account.address);
expect(result.transactions).toHaveLength(mockResponse.data.length);
expect(
result.find((tx) => tx.txnType === TransactionType.DEPLOY_ACCOUNT),
result.transactions.find(
(tx) => tx.txnType === TransactionType.DEPLOY_ACCOUNT,
),
).toBeDefined();
});

it('returns empty array if no result found', async () => {
const account = await mockAccount();
const { fetchSpy } = createMockFetch();
const { to } = getFromAndToTimestamp(5);
// mock the get invoke transactions response with empty data
mockApiSuccess({ fetchSpy });
// mock the get deploy transaction response with empty data
mockApiSuccess({ fetchSpy });

const client = createMockClient();
const result = await client.getTransactions(account.address, to);

expect(result).toStrictEqual([]);
});

it('continue to fetch if next_url is presented', async () => {
const account = await mockAccount();
const { fetchSpy } = createMockFetch();
// generate the to timestamp which is 100 days ago
const { to } = getFromAndToTimestamp(100);
const mockPage1Response = generateStarkScanTransactions({
address: account.address,
txnTypes: [TransactionType.INVOKE],
cnt: 10,
});
const mockPage2Response = generateStarkScanTransactions({
address: account.address,
cnt: 10,
});
const firstPageUrl = `https://api-sepolia.starkscan.co/api/v0/transactions?contract_address=${account.address}&order_by=desc&limit=100`;
const nextPageUrl = `https://api-sepolia.starkscan.co/api/v0/transactions?contract_address=${account.address}&order_by=desc&cursor=MTcyNDc1OTQwNzAwMDAwNjAwMDAwMA%3D%3D`;

// mock the first page response, which contains the next_url
mockApiSuccess({
fetchSpy,
response: {
data: mockPage1Response.data,
// eslint-disable-next-line @typescript-eslint/naming-convention
next_url: nextPageUrl,
},
});
// mock the send page response
mockApiSuccess({ fetchSpy, response: mockPage2Response });

const client = createMockClient();
await client.getTransactions(account.address, to);

expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(fetchSpy).toHaveBeenNthCalledWith(
1,
firstPageUrl,
expect.any(Object),
);
expect(fetchSpy).toHaveBeenNthCalledWith(
2,
nextPageUrl,
expect.any(Object),
);
});
const result = await client.getTransactions(account.address);

it('fetchs the deploy transaction if it is not present', async () => {
const account = await mockAccount();
const { fetchSpy } = createMockFetch();
// generate the to timestamp which is 5 days ago
const { from, to } = getFromAndToTimestamp(5);
// generate 10 invoke transactions, and 1 day time gap between each transaction
const mockInvokeResponse = generateStarkScanTransactions({
address: account.address,
startFrom: from,
timestampReduction: mSecsFor24Hours,
txnTypes: [TransactionType.INVOKE],
});
// generate another 5 invoke transactions + deploy transactions for testing the fallback case
const mockDeployResponse = generateStarkScanTransactions({
address: account.address,
// generate transactions that start from 100 days ago, to ensure not overlap with above invoke transactions
startFrom: mSecsFor24Hours * 100,
timestampReduction: mSecsFor24Hours,
txnTypes: [TransactionType.INVOKE, TransactionType.DEPLOY_ACCOUNT],
cnt: 5,
});
mockApiSuccess({ fetchSpy, response: mockInvokeResponse });
mockApiSuccess({ fetchSpy, response: mockDeployResponse });

const client = createMockClient();
// We only fetch the transactions from the last 5 days
const result = await client.getTransactions(account.address, to);

// The result should include a deploy transaction, even it is not from the last 5 days
expect(
result.find((tx) => tx.txnType === TransactionType.DEPLOY_ACCOUNT),
).toBeDefined();
expect(result.transactions).toStrictEqual([]);
});
});

Expand Down
97 changes: 41 additions & 56 deletions packages/starknet-snap/src/chain/data-client/starkscan.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { TransactionType, constants } from 'starknet';
import type { Struct } from 'superstruct';

import type { V2Transaction } from '../../types/snapState';
import type { TransactionsCursor, V2Transaction } from '../../types/snapState';
import { type Network, type Transaction } from '../../types/snapState';
import { InvalidNetworkError } from '../../utils/exceptions';
import {
Expand All @@ -21,7 +21,7 @@ import {
export class StarkScanClient extends ApiClient implements IDataClient {
apiClientName = 'StarkScanClient';

protected limit = 100;
protected limit = 10;

protected network: Network;

Expand Down Expand Up @@ -78,73 +78,58 @@ export class StarkScanClient extends ApiClient implements IDataClient {

/**
* Fetches the transactions for a given contract address.
* The transactions are fetched in descending order and it will include the deploy transaction.
* Transactions are retrieved in descending order for pagination.
*
* @param address - The address of the contract to fetch the transactions for.
* @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.
* @returns A Promise that resolve an array of Transaction object.
* @param address - The contract address to fetch transactions for.
* @param cursor - Optional pagination cursor.
* @param cursor.blockNumber - The block number for pagination.
* @param cursor.txnHash - The transaction hash for pagination.
* @returns A Promise resolving to an object with transactions and a pagination cursor.
*/
async getTransactions(address: string, to: number): Promise<Transaction[]> {
async getTransactions(
address: string,
cursor?: { blockNumber: number; txnHash: string },
): Promise<{ transactions: Transaction[]; cursor: TransactionsCursor }> {
let apiUrl = this.getApiUrl(
`/transactions?contract_address=${address}&order_by=desc&limit=${this.limit}`,
);

if (cursor !== undefined) {
apiUrl += `&to_block=${cursor.blockNumber}`;
}

const txs: Transaction[] = [];
let deployTxFound = false;
let process = true;
let timestamp = 0;

// Scan the transactions in descending order by timestamp
// Include the transaction if:
// - it's timestamp is greater than the `tillTo` AND
// - there is an next data to fetch
while (process && (timestamp === 0 || timestamp >= to)) {
process = false;

const result = await this.sendApiRequest<StarkScanTransactionsResponse>({
apiUrl,
responseStruct: StarkScanTransactionsResponseStruct,
requestName: 'getTransactions',
});
let newCursor: TransactionsCursor = {
blockNumber: -1,
txnHash: '',
};

for (const data of result.data) {
const tx = this.toTransaction(data);
const isDeployTx = this.isDeployTransaction(data);

if (isDeployTx) {
deployTxFound = true;
}

timestamp = tx.timestamp;
// Only include the records that newer than or equal to the `to` timestamp from the same batch of result
// If there is an deploy transaction from the result, it should included too.
// e.g
// to: 1000
// [
// { timestamp: 1100, transaction_type: "invoke" }, <-- include
// { timestamp: 900, transaction_type: "invoke" }, <-- exclude
// { timestamp: 100, transaction_type: "deploy" } <-- include
// ]
if (timestamp >= to || isDeployTx) {
txs.push(tx);
}
}
const result = await this.sendApiRequest<StarkScanTransactionsResponse>({
apiUrl,
responseStruct: StarkScanTransactionsResponseStruct,
requestName: 'getTransactions',
});

if (result.next_url) {
apiUrl = result.next_url;
process = true;
}
const matchingIndex = cursor
? result.data.findIndex((txn) => txn.transaction_hash === cursor.txnHash)
: -1;

const startIndex = matchingIndex >= 0 ? matchingIndex + 1 : 0;
console.log('startIndex', startIndex);
for (let i = startIndex; i < result.data.length; i++) {
const tx = this.toTransaction(result.data[i]);
txs.push(tx);
}

// In case no deploy transaction found from above,
// then scan the transactions in asc order by timestamp,
// the deploy transaction should usually be the first transaction from the list
if (!deployTxFound) {
const deployTx = await this.getDeployTransaction(address);
deployTx && txs.push(deployTx);
if (result.data.length > 0) {
const lastTx = result.data[result.data.length - 1];
newCursor = {
blockNumber: lastTx.block_number,
txnHash: lastTx.transaction_hash,
};
}

return txs;
return { transactions: txs, cursor: newCursor };
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const StarkScanTransactionStruct = object({
transaction_type: enums(Object.values(TransactionType)),
// The transaction version, 1 or 3, where 3 represents the fee will be paid in STRK
version: number(),
block_number: number(),
max_fee: NullableStringStruct,
actual_fee: NullableStringStruct,
nonce: NullableStringStruct,
Expand Down
37 changes: 16 additions & 21 deletions packages/starknet-snap/src/chain/transaction-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,9 @@ describe('TransactionService', () => {
async *getTransactionsOnChain(
address: string,
contractAddress: string,
tillToInDays: number,
cursor?: { blockNumber: number; txnHash: string },
) {
yield* super.getTransactionsOnChain(
address,
contractAddress,
tillToInDays,
);
yield* super.getTransactionsOnChain(address, contractAddress, cursor);
}

async *getTransactionsOnState(address: string, contractAddress: string) {
Expand Down Expand Up @@ -124,7 +120,13 @@ describe('TransactionService', () => {
const { getTransactionsSpy, dataClient } = mockDataClient();
removeTransactionsSpy.mockReturnThis();
findTransactionsSpy.mockResolvedValue(transactionsFromDataClientOrState);
getTransactionsSpy.mockResolvedValue(transactionsFromDataClientOrState);
getTransactionsSpy.mockResolvedValue({
transactions: transactionsFromDataClientOrState,
cursor: {
blockNumber: -1,
txnHash: '',
},
});

const service = mockTransactionService(network, dataClient);

Expand Down Expand Up @@ -160,15 +162,12 @@ describe('TransactionService', () => {
for await (const tx of service.getTransactionsOnChain(
address,
contractAddress,
10,
)) {
transactions.push(tx);
if (tx && typeof tx === 'object' && 'txnHash' in tx) {
transactions.push(tx);
}
}

expect(getTransactionsSpy).toHaveBeenCalledWith(
address,
expect.any(Number),
);
expect(getTransactionsSpy).toHaveBeenCalledWith(address, undefined);
expect(transactions).toStrictEqual(filteredTransactions);
});
});
Expand Down Expand Up @@ -230,15 +229,11 @@ describe('TransactionService', () => {
});
findTransactionsSpy.mockResolvedValue(transactionFromState);

const result = await service.getTransactions(
address,
contractAddress,
10,
);
const result = await service.getTransactions(address, contractAddress);

const expectedResult = transactionFromState.concat(transactionsFromChain);

expect(result).toStrictEqual(expectedResult);
expect(result.transactions).toStrictEqual(expectedResult);
});

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

findTransactionsSpy.mockResolvedValue(duplicatedTransactions);

await service.getTransactions(address, contractAddress, 10);
await service.getTransactions(address, contractAddress);

expect(removeTransactionsSpy).toHaveBeenCalledWith({
txnHash: [duplicatedTransactions[0].txnHash],
Expand Down
Loading