From cfdc79da6a0dda518b2dd6dd3c699de254d8e7f1 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:25:36 +0800 Subject: [PATCH 1/2] feat: add `StarkScan` client to fetch transaction history (#341) * feat: add stark scan client * chore: add starkscan config * chore: lint * chore: add interface * chore: support multiple txn * chore: update starkscan * chore: update stark scan client * chore: update contract func name * chore: fix test * chore: update data client * chore: re-structure starkscan type * chore: add test coverage * chore: factory and config * chore: add backward compatibility for transactions type * chore: add comment * chore: lint * chore: resolve review comment * chore: change dataVersion to enum * chore: lint * chore: update starkscan to handle missing selector_name --------- Co-authored-by: khanti42 --- packages/starknet-snap/.env.example | 3 + packages/starknet-snap/snap.config.ts | 1 + .../__tests__/fixture/stark-scan-example.json | 163 ++++++ .../starknet-snap/src/__tests__/helper.ts | 64 +++ .../starknet-snap/src/chain/api-client.ts | 130 +++++ .../starknet-snap/src/chain/data-client.ts | 6 + .../src/chain/data-client/starkscan.test.ts | 483 ++++++++++++++++++ .../src/chain/data-client/starkscan.ts | 300 +++++++++++ .../src/chain/data-client/starkscan.type.ts | 55 ++ packages/starknet-snap/src/config.ts | 16 + packages/starknet-snap/src/types/snapState.ts | 50 +- .../starknet-snap/src/utils/factory.test.ts | 22 + packages/starknet-snap/src/utils/factory.ts | 25 + 13 files changed, 1314 insertions(+), 4 deletions(-) create mode 100644 packages/starknet-snap/src/__tests__/fixture/stark-scan-example.json create mode 100644 packages/starknet-snap/src/chain/api-client.ts create mode 100644 packages/starknet-snap/src/chain/data-client.ts create mode 100644 packages/starknet-snap/src/chain/data-client/starkscan.test.ts create mode 100644 packages/starknet-snap/src/chain/data-client/starkscan.ts create mode 100644 packages/starknet-snap/src/chain/data-client/starkscan.type.ts create mode 100644 packages/starknet-snap/src/utils/factory.test.ts create mode 100644 packages/starknet-snap/src/utils/factory.ts diff --git a/packages/starknet-snap/.env.example b/packages/starknet-snap/.env.example index c5b657e0..b2098771 100644 --- a/packages/starknet-snap/.env.example +++ b/packages/starknet-snap/.env.example @@ -6,6 +6,9 @@ SNAP_ENV=dev # Description: Environment variables for API key of VOYAGER # Required: false VOYAGER_API_KEY= +# Description: Environment variables for API key of STARKSCAN +# Required: false +STARKSCAN_API_KEY= # Description: Environment variables for API key of ALCHEMY # Required: false ALCHEMY_API_KEY= diff --git a/packages/starknet-snap/snap.config.ts b/packages/starknet-snap/snap.config.ts index 7d8bb771..182cb2fb 100644 --- a/packages/starknet-snap/snap.config.ts +++ b/packages/starknet-snap/snap.config.ts @@ -15,6 +15,7 @@ const config: SnapConfig = { SNAP_ENV: process.env.SNAP_ENV ?? 'prod', VOYAGER_API_KEY: process.env.VOYAGER_API_KEY ?? '', ALCHEMY_API_KEY: process.env.ALCHEMY_API_KEY ?? '', + STARKSCAN_API_KEY: process.env.STARKSCAN_API_KEY ?? '', LOG_LEVEL: process.env.LOG_LEVEL ?? '0', /* eslint-disable */ }, diff --git a/packages/starknet-snap/src/__tests__/fixture/stark-scan-example.json b/packages/starknet-snap/src/__tests__/fixture/stark-scan-example.json new file mode 100644 index 00000000..6a4affdd --- /dev/null +++ b/packages/starknet-snap/src/__tests__/fixture/stark-scan-example.json @@ -0,0 +1,163 @@ +{ + "getTransactionsResp": { + "next_url": null, + "data": [] + }, + "invokeTx": { + "transaction_hash": "0x06d05d58dc35f432f8f5c7ae5972434a00d2e567e154fc1226c444f06e369c7d", + "block_hash": "0x02a44fd749221729c63956309d0df4ba03f4291613248d885870b55baf963e0e", + "block_number": 136140, + "transaction_index": 6, + "transaction_status": "ACCEPTED_ON_L1", + "transaction_finality_status": "ACCEPTED_ON_L1", + "transaction_execution_status": "SUCCEEDED", + "transaction_type": "INVOKE_FUNCTION", + "version": 1, + "signature": [ + "0x555fe1b8e5183be2f6c81e5203ee3928aab894ab0b31279c89a3c7f016865fc", + "0x269d0a83634905be76372d3116733afc8a8f0f29776f57d7400b05ded54c9b1" + ], + "max_fee": "95250978959328", + "actual_fee": "62936888346418", + "nonce": "9", + "contract_address": null, + "entry_point_selector": null, + "entry_point_type": null, + "calldata": [ + "0x1", + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e", + "0x3", + "0x42ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "0x9184e72a000", + "0x0" + ], + "class_hash": null, + "sender_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "constructor_calldata": null, + "contract_address_salt": null, + "timestamp": 1724759407, + "entry_point_selector_name": "__execute__", + "number_of_events": 3, + "revert_error": null, + "account_calls": [ + { + "block_hash": "0x02a44fd749221729c63956309d0df4ba03f4291613248d885870b55baf963e0e", + "block_number": 136140, + "transaction_hash": "0x06d05d58dc35f432f8f5c7ae5972434a00d2e567e154fc1226c444f06e369c7d", + "caller_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "contract_address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "calldata": [ + "0x42ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "0x9184e72a000", + "0x0" + ], + "result": ["0x1"], + "timestamp": 1724759407, + "call_type": "CALL", + "class_hash": "0x07f3777c99f3700505ea966676aac4a0d692c2a9f5e667f4c606b51ca1dd3420", + "selector": "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e", + "entry_point_type": "EXTERNAL", + "selector_name": "transfer" + } + ] + }, + "upgradeTx": { + "transaction_hash": "0x019a7a7bbda2e52f82ffc867488cace31a04d9340ad56bbe9879aab8bc47f0b6", + "block_hash": "0x002dae3ed3cf7763621da170103384d533ed09fb987a232f23b7d8febbbca67f", + "block_number": 77586, + "transaction_index": 33, + "transaction_status": "ACCEPTED_ON_L1", + "transaction_finality_status": "ACCEPTED_ON_L1", + "transaction_execution_status": "SUCCEEDED", + "transaction_type": "INVOKE_FUNCTION", + "version": 1, + "signature": [ + "0x417671c63219250e0c80d53b1e1b3c0dd76ade552806a51fdfd8c06f7c47a12", + "0x91c7ccadec2ba22bfa5c92b62fc6eaccb56c686279f953c5012f7d6f679570" + ], + "max_fee": "191210494208472", + "actual_fee": "148188646762488", + "nonce": "4", + "contract_address": null, + "entry_point_selector": null, + "entry_point_type": null, + "calldata": [ + "0x1", + "0x42ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "0xf2f7c15cbe06c8d94597cd91fd7f3369eae842359235712def5584f8d270cd", + "0x0", + "0x3", + "0x3", + "0x29927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b", + "0x1", + "0x0" + ], + "class_hash": null, + "sender_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "constructor_calldata": null, + "contract_address_salt": null, + "timestamp": 1719830196, + "entry_point_selector_name": "__execute__", + "number_of_events": 4, + "revert_error": null, + "account_calls": [ + { + "block_hash": "0x002dae3ed3cf7763621da170103384d533ed09fb987a232f23b7d8febbbca67f", + "block_number": 77586, + "transaction_hash": "0x019a7a7bbda2e52f82ffc867488cace31a04d9340ad56bbe9879aab8bc47f0b6", + "caller_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "contract_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "calldata": [ + "0x29927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b", + "0x1", + "0x0" + ], + "result": ["0x1", "0x0"], + "timestamp": 1719830196, + "call_type": "CALL", + "class_hash": "0x025ec026985a3bf9d0cc1fe17326b245dfdc3ff89b8fde106542a3ea56c5a918", + "selector": "0xf2f7c15cbe06c8d94597cd91fd7f3369eae842359235712def5584f8d270cd", + "entry_point_type": "EXTERNAL", + "selector_name": "upgrade" + } + ] + }, + "cairo0DeployTx": { + "transaction_hash": "0x06210d8004e1c90723732070c191a3a003f99d1d95e6c7766322ed75d9d83d78", + "block_hash": "0x058a67093c5f642a7910b7aef0c0a846834e1df60f9bf4c0564afb9c8efe3a41", + "block_number": 68074, + "transaction_index": 6, + "transaction_status": "ACCEPTED_ON_L1", + "transaction_finality_status": "ACCEPTED_ON_L1", + "transaction_execution_status": "SUCCEEDED", + "transaction_type": "DEPLOY_ACCOUNT", + "version": 1, + "signature": [ + "0x2de38508b633161a3cdbc0a04b0e09f85c884254552f903417239f95486ceda", + "0x2694930b199802941c996f8aaf48e63a1b2e51ccfaec7864f83f40fcd285286" + ], + "max_fee": "6639218055204", + "actual_fee": "21040570099", + "nonce": null, + "contract_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "entry_point_selector": null, + "entry_point_type": null, + "calldata": null, + "class_hash": "0x025ec026985a3bf9d0cc1fe17326b245dfdc3ff89b8fde106542a3ea56c5a918", + "sender_address": null, + "constructor_calldata": [ + "0x33434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", + "0x79dc0da7c54b95f10aa182ad0a46400db63156920adb65eca2654c0945a463", + "0x2", + "0xbd7fccd6d25df79e3fc8dd539efd03fe448d902b8bc5955e60b3830988ce50", + "0x0" + ], + "contract_address_salt": "334816139481647544515869631733577866188380288661138191555306848313001168464", + "timestamp": 1716355916, + "entry_point_selector_name": "constructor", + "number_of_events": 2, + "revert_error": null, + "account_calls": [] + } +} diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 732ca50f..31bbf1f9 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -19,6 +19,10 @@ import { } from 'starknet'; import { v4 as uuidv4 } from 'uuid'; +import type { + StarkScanTransaction, + StarkScanTransactionsResponse, +} from '../chain/data-client/starkscan.type'; import { FeeToken } from '../types/snapApi'; import type { AccContract, @@ -32,6 +36,7 @@ import { PROXY_CONTRACT_HASH, } from '../utils/constants'; import { grindKey } from '../utils/keyPair'; +import { invokeTx, cairo0DeployTx } from './fixture/stark-scan-example.json'; /* eslint-disable */ export type StarknetAccount = AccContract & { @@ -364,6 +369,65 @@ export function generateTransactionRequests({ return requests; } +/** + * Method to generate starkscan transactions. + * + * @param params + * @param params.address - Address of the account. + * @param params.startFrom - start timestamp of the first transactions. + * @param params.timestampReduction - the deduction timestamp per transactions. + * @param params.txnTypes - Array of txn types. + * @param params.cnt - Number of transaction to generate. + * @returns An array of transaction object. + */ +export function generateStarkScanTransactions({ + address, + startFrom = Date.now(), + timestampReduction = 100, + cnt = 10, + txnTypes = [TransactionType.DEPLOY_ACCOUNT, TransactionType.INVOKE], +}: { + address: string; + startFrom?: number; + timestampReduction?: number; + cnt?: number; + txnTypes?: TransactionType[]; +}): StarkScanTransactionsResponse { + let transactionStartFrom = startFrom; + const txs: StarkScanTransaction[] = []; + let totalRecordCnt = txnTypes.includes(TransactionType.DEPLOY_ACCOUNT) + ? cnt - 1 + : cnt; + + for (let i = 0; i < totalRecordCnt; i++) { + let newTx = { + ...invokeTx, + account_calls: [...invokeTx.account_calls], + }; + newTx.sender_address = address; + newTx.account_calls[0].caller_address = address; + newTx.timestamp = transactionStartFrom; + newTx.transaction_hash = `0x${transactionStartFrom.toString(16)}`; + transactionStartFrom -= timestampReduction; + txs.push(newTx as unknown as StarkScanTransaction); + } + + if (txnTypes.includes(TransactionType.DEPLOY_ACCOUNT)) { + let deployTx = { + ...cairo0DeployTx, + account_calls: [...cairo0DeployTx.account_calls], + }; + deployTx.contract_address = address; + deployTx.transaction_hash = `0x${transactionStartFrom.toString(16)}`; + txs.push(deployTx as unknown as StarkScanTransaction); + } + + return { + next_url: null, + data: txs, + }; +} + /** * Method to generate a mock estimate fee response. * diff --git a/packages/starknet-snap/src/chain/api-client.ts b/packages/starknet-snap/src/chain/api-client.ts new file mode 100644 index 00000000..253af4fe --- /dev/null +++ b/packages/starknet-snap/src/chain/api-client.ts @@ -0,0 +1,130 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type { Struct } from 'superstruct'; +import { mask } from 'superstruct'; + +import { logger } from '../utils/logger'; + +export enum HttpMethod { + Get = 'GET', + Post = 'POST', +} + +export type HttpHeaders = Record; + +export type HttpRequest = { + url: string; + method: HttpMethod; + headers: HttpHeaders; + body?: string; +}; + +export type HttpResponse = globalThis.Response; + +export abstract class ApiClient { + /** + * The name of the API Client. + */ + abstract apiClientName: string; + + /** + * An internal method called internally by `submitRequest()` to verify and convert the HTTP response to the expected API response. + * + * @param response - The HTTP response to verify and convert. + * @returns A promise that resolves to the API response. + */ + protected async parseResponse( + response: HttpResponse, + ): Promise { + try { + return (await response.json()) as unknown as ApiResponse; + } catch (error) { + throw new Error( + 'API response error: response body can not be deserialised.', + ); + } + } + + /** + * An internal method used to build the `HttpRequest` object. + * + * @param params - The request parameters. + * @param params.method - The HTTP method (GET or POST). + * @param params.headers - The HTTP headers. + * @param params.url - The request URL. + * @param [params.body] - The request body (optional). + * @returns A `HttpRequest` object. + */ + protected buildHttpRequest({ + method, + headers = {}, + url, + body, + }: { + method: HttpMethod; + headers?: HttpHeaders; + url: string; + body?: Json; + }): HttpRequest { + const request = { + url, + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: + method === HttpMethod.Post && body ? JSON.stringify(body) : undefined, + }; + + return request; + } + + /** + * An internal method used to send a HTTP request. + * + * @param params - The request parameters. + * @param [params.requestName] - The name of the request (optional). + * @param params.request - The `HttpRequest` object. + * @param params.responseStruct - The superstruct used to verify the API response. + * @returns A promise that resolves to a JSON object. + */ + protected async sendHttpRequest({ + requestName = '', + request, + responseStruct, + }: { + requestName?: string; + request: HttpRequest; + responseStruct: Struct; + }): Promise { + const logPrefix = `[${this.apiClientName}.${requestName}]`; + + try { + logger.debug(`${logPrefix} request: ${request.method}`); // Log HTTP method being used. + + const fetchRequest = { + method: request.method, + headers: request.headers, + body: request.body, + }; + + const httpResponse = await fetch(request.url, fetchRequest); + + const jsonResponse = await this.parseResponse(httpResponse); + + logger.debug(`${logPrefix} response:`, JSON.stringify(jsonResponse)); + + // Safeguard to identify if the response has some unexpected changes from the API client + mask(jsonResponse, responseStruct, `Unexpected response from API client`); + + return jsonResponse; + } catch (error) { + logger.info( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${logPrefix} error: ${error.message}`, + ); + + throw error; + } + } +} diff --git a/packages/starknet-snap/src/chain/data-client.ts b/packages/starknet-snap/src/chain/data-client.ts new file mode 100644 index 00000000..e5cea616 --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client.ts @@ -0,0 +1,6 @@ +import type { Transaction } from '../types/snapState'; + +export type IDataClient = { + getTransactions: (address: string, tillTo: number) => Promise; + getDeployTransaction: (address: string) => Promise; +}; diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts new file mode 100644 index 00000000..e4b24647 --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -0,0 +1,483 @@ +import { TransactionType, constants } from 'starknet'; + +import { + generateAccounts, + generateStarkScanTransactions, +} from '../../__tests__/helper'; +import { + ContractFuncName, + TransactionDataVersion, + type Network, + type Transaction, +} from '../../types/snapState'; +import { + STARKNET_MAINNET_NETWORK, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from '../../utils/constants'; +import { InvalidNetworkError } from '../../utils/exceptions'; +import { StarkScanClient } from './starkscan'; +import { + StarkScanTransactionsResponseStruct, + type StarkScanOptions, + type StarkScanTransaction, + type StarkScanTransactionsResponse, +} from './starkscan.type'; + +jest.mock('../../utils/logger'); + +describe('StarkScanClient', () => { + class MockStarkScanClient extends StarkScanClient { + public toTransaction(data: StarkScanTransaction): Transaction { + return super.toTransaction(data); + } + + get baseUrl(): string { + return super.baseUrl; + } + + async sendApiRequest(request): Promise { + return await super.sendApiRequest(request); + } + + getSenderAddress(tx: StarkScanTransaction): string { + return super.getSenderAddress(tx); + } + } + + const createMockClient = ({ + network = STARKNET_SEPOLIA_TESTNET_NETWORK, + options = { + apiKey: 'api-key', + }, + }: { + network?: Network; + options?: StarkScanOptions; + } = {}) => { + return new MockStarkScanClient(network, options); + }; + + const createMockFetch = () => { + // eslint-disable-next-line no-restricted-globals + Object.defineProperty(global, 'fetch', { + writable: true, + }); + + const fetchSpy = jest.fn(); + // eslint-disable-next-line no-restricted-globals + global.fetch = fetchSpy; + + return { + fetchSpy, + }; + }; + + const mockAccount = async ( + chainId: constants.StarknetChainId = constants.StarknetChainId.SN_SEPOLIA, + ) => { + const [account] = await generateAccounts(chainId, 1); + return account; + }; + + const mockApiSuccess = ({ + fetchSpy, + // eslint-disable-next-line @typescript-eslint/naming-convention + response = { data: [], next_url: null }, + }: { + fetchSpy: jest.SpyInstance; + response?: StarkScanTransactionsResponse; + }) => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(response), + }); + }; + + const mockApiFailure = ({ fetchSpy }: { fetchSpy: jest.SpyInstance }) => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + statusText: 'error', + }); + }; + + const mockTxByType = (txnType: TransactionType, address: string) => { + const mockResponse = generateStarkScanTransactions({ + address, + txnTypes: [txnType], + cnt: 1, + }); + const tx = mockResponse.data[0]; + return tx; + }; + + describe('baseUrl', () => { + it.each([ + { + network: STARKNET_SEPOLIA_TESTNET_NETWORK, + expectedUrl: 'https://api-sepolia.starkscan.co/api/v0', + }, + { + network: STARKNET_MAINNET_NETWORK, + expectedUrl: 'https://api.starkscan.co/api/v0', + }, + ])( + 'returns the api url if the chain id is $network.name', + ({ network, expectedUrl }: { network: Network; expectedUrl: string }) => { + const client = createMockClient({ + network, + }); + + expect(client.baseUrl).toStrictEqual(expectedUrl); + }, + ); + + it('throws `InvalidNetworkError` if the chain id is invalid', () => { + const invalidNetwork: Network = { + name: 'Invalid Network', + chainId: '0x534e5f474f45524c49', + baseUrl: '', + nodeUrl: '', + voyagerUrl: '', + accountClassHash: '', + }; + const client = createMockClient({ + network: invalidNetwork, + }); + + expect(() => client.baseUrl).toThrow(InvalidNetworkError); + }); + }); + + describe('sendApiRequest', () => { + const mockRequest = () => { + return { + apiUrl: `/url`, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions', + }; + }; + + it('fetches data', async () => { + const { fetchSpy } = createMockFetch(); + // eslint-disable-next-line @typescript-eslint/naming-convention + const expectedResponse = { data: [], next_url: null }; + mockApiSuccess({ fetchSpy, response: expectedResponse }); + + const client = createMockClient(); + const result = await client.sendApiRequest(mockRequest()); + + expect(result).toStrictEqual(expectedResponse); + }); + + it('appends a api key to header', async () => { + const { fetchSpy } = createMockFetch(); + mockApiSuccess({ fetchSpy }); + + const apiKey = 'ABCDEFG-API-KEY'; + + const client = createMockClient({ + options: { + apiKey, + }, + }); + await client.sendApiRequest(mockRequest()); + + expect(fetchSpy).toHaveBeenCalledWith(`/url`, { + method: 'GET', + body: undefined, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + }, + }); + }); + + it('throws `API response error: response body can not be deserialised.` error if the response.ok is falsy', async () => { + const { fetchSpy } = createMockFetch(); + mockApiFailure({ fetchSpy }); + + const client = createMockClient(); + await expect(client.sendApiRequest(mockRequest())).rejects.toThrow( + `API response error: response body can not be deserialised.`, + ); + }); + }); + + 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, + ); + expect( + result.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), + ); + }); + + 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(); + }); + }); + + describe('toTransaction', () => { + it('converts an invoke type starkscan transaction to a transaction object', async () => { + const account = await mockAccount(); + const mockTx = mockTxByType(TransactionType.INVOKE, account.address); + + const client = createMockClient(); + const result = client.toTransaction(mockTx); + + const { contract_address: contract, calldata: contractCallData } = + mockTx.account_calls[0]; + + expect(result).toStrictEqual({ + txnHash: mockTx.transaction_hash, + txnType: mockTx.transaction_type, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + senderAddress: account.address, + contractAddress: '', + timestamp: mockTx.timestamp, + finalityStatus: mockTx.transaction_finality_status, + executionStatus: mockTx.transaction_execution_status, + failureReason: mockTx.revert_error ?? '', + maxFee: mockTx.max_fee, + actualFee: mockTx.actual_fee, + accountCalls: { + [contract]: [ + { + contract, + contractFuncName: ContractFuncName.Transfer, + contractCallData, + recipient: contractCallData[0], + amount: contractCallData[1], + }, + ], + }, + version: mockTx.version, + dataVersion: TransactionDataVersion.V2, + }); + }); + + it('converts a deploy type starkscan transaction to a transaction object', async () => { + const account = await mockAccount(); + const mockTx = mockTxByType( + TransactionType.DEPLOY_ACCOUNT, + account.address, + ); + + const client = createMockClient(); + const result = client.toTransaction(mockTx); + + expect(result).toStrictEqual({ + txnHash: mockTx.transaction_hash, + txnType: mockTx.transaction_type, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + senderAddress: account.address, + contractAddress: account.address, + timestamp: mockTx.timestamp, + finalityStatus: mockTx.transaction_finality_status, + executionStatus: mockTx.transaction_execution_status, + failureReason: mockTx.revert_error ?? '', + maxFee: mockTx.max_fee, + actualFee: mockTx.actual_fee, + accountCalls: null, + version: mockTx.version, + dataVersion: TransactionDataVersion.V2, + }); + }); + }); + + describe('getDeployTransaction', () => { + it('returns a deploy transaction', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + // generate 5 invoke transactions with deploy transaction + const mockResponse = generateStarkScanTransactions({ + address: account.address, + cnt: 5, + }); + mockApiSuccess({ fetchSpy, response: mockResponse }); + + const client = createMockClient(); + const result = await client.getDeployTransaction(account.address); + + expect(result).not.toBeNull(); + expect(result?.txnType).toStrictEqual(TransactionType.DEPLOY_ACCOUNT); + }); + + it('returns null if no deploy transaction found', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + // generate 5 invoke transactions with deploy transaction + const mockResponse = generateStarkScanTransactions({ + address: account.address, + cnt: 1, + txnTypes: [TransactionType.INVOKE], + }); + mockApiSuccess({ fetchSpy, response: mockResponse }); + + const client = createMockClient(); + const result = await client.getDeployTransaction(account.address); + + expect(result).toBeNull(); + }); + }); + + describe('getSenderAddress', () => { + const prepareMockTx = async (transactionType = TransactionType.INVOKE) => { + const account = await mockAccount(); + const mockTx = mockTxByType(transactionType, account.address); + return mockTx; + }; + + it('returns the sender address', async () => { + const mockTx = await prepareMockTx(); + + const client = createMockClient(); + expect(client.getSenderAddress(mockTx)).toStrictEqual( + mockTx.sender_address, + ); + }); + + it('returns the contract address if it is a deploy transaction', async () => { + const mockTx = await prepareMockTx(TransactionType.DEPLOY_ACCOUNT); + + const client = createMockClient(); + expect(client.getSenderAddress(mockTx)).toStrictEqual( + mockTx.contract_address, + ); + }); + + it('returns an empty string if the sender address is null', async () => { + const mockTx = await prepareMockTx(); + + const client = createMockClient(); + expect( + client.getSenderAddress({ + ...mockTx, + // eslint-disable-next-line @typescript-eslint/naming-convention + sender_address: null, + }), + ).toBe(''); + }); + }); +}); diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts new file mode 100644 index 00000000..8935c80f --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -0,0 +1,300 @@ +import { TransactionType, constants } from 'starknet'; +import type { Struct } from 'superstruct'; + +import { + ContractFuncName, + TransactionDataVersion, + type Network, + type Transaction, + type TranscationAccountCall, +} from '../../types/snapState'; +import { + TRANSFER_SELECTOR_HEX, + UPGRADE_SELECTOR_HEX, +} from '../../utils/constants'; +import { InvalidNetworkError } from '../../utils/exceptions'; +import type { HttpHeaders } from '../api-client'; +import { ApiClient, HttpMethod } from '../api-client'; +import type { IDataClient } from '../data-client'; +import type { StarkScanTransactionsResponse } from './starkscan.type'; +import { + type StarkScanAccountCall, + type StarkScanTransaction, + type StarkScanOptions, + StarkScanTransactionsResponseStruct, +} from './starkscan.type'; + +export class StarkScanClient extends ApiClient implements IDataClient { + apiClientName = 'StarkScanClient'; + + protected limit = 100; + + protected network: Network; + + protected options: StarkScanOptions; + + protected deploySelectorName = 'constructor'; + + constructor(network: Network, options: StarkScanOptions) { + super(); + this.network = network; + this.options = options; + } + + protected get baseUrl(): string { + switch (this.network.chainId) { + case constants.StarknetChainId.SN_SEPOLIA: + return 'https://api-sepolia.starkscan.co/api/v0'; + case constants.StarknetChainId.SN_MAIN: + return 'https://api.starkscan.co/api/v0'; + default: + throw new InvalidNetworkError(); + } + } + + protected getApiUrl(endpoint: string): string { + return `${this.baseUrl}${endpoint}`; + } + + protected getHttpHeaders(): HttpHeaders { + return { + 'x-api-key': this.options.apiKey, + }; + } + + protected async sendApiRequest({ + apiUrl, + responseStruct, + requestName, + }: { + apiUrl: string; + responseStruct: Struct; + requestName: string; + }): Promise { + return await super.sendHttpRequest({ + request: this.buildHttpRequest({ + method: HttpMethod.Get, + url: apiUrl, + headers: this.getHttpHeaders(), + }), + responseStruct, + requestName, + }); + } + + /** + * Fetches the transactions for a given contract address. + * The transactions are fetched in descending order and it will include the deploy transaction. + * + * @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. + */ + async getTransactions(address: string, to: number): Promise { + let apiUrl = this.getApiUrl( + `/transactions?contract_address=${address}&order_by=desc&limit=${this.limit}`, + ); + + 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({ + apiUrl, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions', + }); + + 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); + } + } + + if (result.next_url) { + apiUrl = result.next_url; + process = true; + } + } + + // 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); + } + + return txs; + } + + /** + * Fetches the deploy transaction for a given contract address. + * + * @param address - The address of the contract to fetch the deploy transaction for. + * @returns A Promise that resolve the Transaction object or null if the transaction can not be found. + */ + async getDeployTransaction(address: string): Promise { + // Fetch the first 5 transactions to find the deploy transaction + // The deploy transaction usually is the first transaction from the list + const apiUrl = this.getApiUrl( + `/transactions?contract_address=${address}&order_by=asc&limit=5`, + ); + + const result = await this.sendApiRequest({ + apiUrl, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions', + }); + + for (const data of result.data) { + if (this.isDeployTransaction(data)) { + return this.toTransaction(data); + } + } + + return null; + } + + protected isDeployTransaction(tx: StarkScanTransaction): boolean { + return tx.transaction_type === TransactionType.DEPLOY_ACCOUNT; + } + + protected isFundTransferTransaction(entrypoint: string): boolean { + return entrypoint === TRANSFER_SELECTOR_HEX; + } + + protected getContractAddress(tx: StarkScanTransaction): string { + // backfill the contract address if it is null + return tx.contract_address ?? ''; + } + + protected getSenderAddress(tx: StarkScanTransaction): string { + let sender = tx.sender_address; + + if (this.isDeployTransaction(tx)) { + // if it is a deploy transaction, the contract address is the sender address + sender = tx.contract_address as unknown as string; + } + + // backfill the sender address if it is null + return sender ?? ''; + } + + protected toTransaction(tx: StarkScanTransaction): Transaction { + /* eslint-disable @typescript-eslint/naming-convention */ + const { + transaction_hash: txnHash, + transaction_type: txnType, + timestamp, + transaction_finality_status: finalityStatus, + transaction_execution_status: executionStatus, + max_fee: maxFee, + actual_fee: actualFee, + revert_error: failureReason, + account_calls: calls, + version, + } = tx; + + // account_calls representing the calls to invoke from the account contract, it can be multiple + // If the transaction is a deploy transaction, the account_calls is a empty array + const accountCalls = this.toAccountCall(calls); + + return { + txnHash, + txnType, + chainId: this.network.chainId, + senderAddress: this.getSenderAddress(tx), + timestamp, + finalityStatus, + executionStatus, + maxFee, + actualFee, + contractAddress: this.getContractAddress(tx), + accountCalls, + failureReason: failureReason ?? '', + version, + dataVersion: TransactionDataVersion.V2, + }; + + /* eslint-enable */ + } + + protected toAccountCall( + accountCalls: StarkScanAccountCall[], + ): Record | null { + if (!accountCalls || accountCalls.length === 0) { + return null; + } + + return accountCalls.reduce( + ( + data: Record, + accountCallArg: StarkScanAccountCall, + ) => { + const { + contract_address: contract, + selector, + calldata: contractCallData, + } = accountCallArg; + + const contractFuncName = this.selectorHexToName(selector); + if (!Object.prototype.hasOwnProperty.call(data, contract)) { + data[contract] = []; + } + + const accountCall: TranscationAccountCall = { + contract, + contractFuncName, + contractCallData, + }; + + if (this.isFundTransferTransaction(selector)) { + accountCall.recipient = accountCallArg.calldata[0]; + accountCall.amount = accountCallArg.calldata[1]; + } + + data[contract].push(accountCall); + + return data; + }, + {}, + ); + } + + protected selectorHexToName(selector: string): string { + switch (selector.toLowerCase()) { + case TRANSFER_SELECTOR_HEX.toLowerCase(): + return ContractFuncName.Transfer; + case UPGRADE_SELECTOR_HEX.toLowerCase(): + return ContractFuncName.Upgrade; + default: + return selector; + } + } +} diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.type.ts b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts new file mode 100644 index 00000000..c15972ea --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts @@ -0,0 +1,55 @@ +import { + TransactionExecutionStatus, + TransactionFinalityStatus, + TransactionType, +} from 'starknet'; +import type { Infer } from 'superstruct'; +import { array, nullable, number, object, string, enums } from 'superstruct'; + +/* eslint-disable @typescript-eslint/naming-convention */ +const NullableStringStruct = nullable(string()); +const NullableStringArrayStruct = nullable(array(string())); + +export const StarkScanAccountCallStruct = object({ + contract_address: string(), + calldata: array(string()), + selector: string(), +}); + +export const StarkScanTransactionStruct = object({ + transaction_hash: string(), + transaction_finality_status: enums(Object.values(TransactionFinalityStatus)), + transaction_execution_status: enums( + Object.values(TransactionExecutionStatus), + ), + transaction_type: enums(Object.values(TransactionType)), + // The transaction version, 1 or 3, where 3 represents the fee will be paid in STRK + version: number(), + max_fee: NullableStringStruct, + actual_fee: NullableStringStruct, + nonce: NullableStringStruct, + contract_address: NullableStringStruct, + calldata: NullableStringArrayStruct, + sender_address: NullableStringStruct, + timestamp: number(), + revert_error: NullableStringStruct, + account_calls: array(StarkScanAccountCallStruct), +}); + +export type StarkScanAccountCall = Infer; + +export type StarkScanTransaction = Infer; + +export const StarkScanTransactionsResponseStruct = object({ + next_url: nullable(string()), + data: array(StarkScanTransactionStruct), +}); + +export type StarkScanTransactionsResponse = Infer< + typeof StarkScanTransactionsResponseStruct +>; + +export type StarkScanOptions = { + apiKey: string; +}; +/* eslint-enable */ diff --git a/packages/starknet-snap/src/config.ts b/packages/starknet-snap/src/config.ts index f92c0935..abe5caa9 100644 --- a/packages/starknet-snap/src/config.ts +++ b/packages/starknet-snap/src/config.ts @@ -25,8 +25,17 @@ export type SnapConfig = { explorer: { [key: string]: string; }; + dataClient: { + [key: string]: { + apiKey: string | undefined; + }; + }; }; +export enum DataClient { + STARKSCAN = 'starkscan', +} + export const Config: SnapConfig = { // eslint-disable-next-line no-restricted-globals logLevel: process.env.LOG_LEVEL ?? LogLevel.OFF.valueOf().toString(), @@ -49,6 +58,13 @@ export const Config: SnapConfig = { 'https://sepolia.voyager.online/contract/${address}', }, + dataClient: { + [DataClient.STARKSCAN]: { + // eslint-disable-next-line no-restricted-globals + apiKey: process.env.STARKSCAN_API_KEY, + }, + }, + preloadTokens: [ ETHER_MAINNET, ETHER_SEPOLIA_TESTNET, diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index fa9f210c..d139df1f 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -1,6 +1,9 @@ import type { - EstimateFee, RawCalldata, + TransactionType as StarkNetTransactionType, + TransactionExecutionStatus, + TransactionFinalityStatus, + EstimateFee, TransactionType as StarknetTransactionType, } from 'starknet'; @@ -116,12 +119,18 @@ export enum TransactionStatusType { // for retrieving txn from StarkNet feeder g DEPRECATION = 'status', } -export type Transaction = { +export type TranscationAccountCall = { + contract: string; + contractFuncName: string; + contractCallData: string[]; + recipient?: string; + amount?: string; +}; + +export type LegacyTransaction = { txnHash: string; // in hex - // TODO: Change the type of txnType to `TransactionType` in the SnapState, when this state manager apply to getTransactions, there is no migration neeeded, as the state is override for every fetch for getTransactions txnType: VoyagerTransactionType | string; chainId: string; // in hex - // TODO: rename it to address to sync with the same naming convention in the AccContract senderAddress: string; // in hex contractAddress: string; // in hex contractFuncName: string; @@ -134,4 +143,37 @@ export type Transaction = { timestamp: number; }; +export enum TransactionDataVersion { + V2 = 'V2', +} + +export enum ContractFuncName { + Upgrade = 'upgrade', + Transfer = 'transfer', +} + +export type V2Transaction = { + txnHash: string; // in hex + txnType: StarkNetTransactionType; + chainId: string; // in hex + senderAddress: string; // in hex + contractAddress: string; // in hex + executionStatus?: TransactionExecutionStatus | string; + finalityStatus?: TransactionFinalityStatus | string; + failureReason: string; + timestamp: number; + maxFee?: string | null; + actualFee?: string | null; + // using Record to support O(1) searching + accountCalls?: Record | null; + version: number; + // Snap data Version to support backward compatibility , migration. + dataVersion: TransactionDataVersion.V2; +}; + +// FIXME: temp solution for backward compatibility before StarkScan implemented in get transactions +export type Transaction = + | LegacyTransaction + | (V2Transaction & { status?: TransactionStatus | string }); + /* eslint-disable */ diff --git a/packages/starknet-snap/src/utils/factory.test.ts b/packages/starknet-snap/src/utils/factory.test.ts new file mode 100644 index 00000000..12466fac --- /dev/null +++ b/packages/starknet-snap/src/utils/factory.test.ts @@ -0,0 +1,22 @@ +import { StarkScanClient } from '../chain/data-client/starkscan'; +import { Config, DataClient } from '../config'; +import { STARKNET_SEPOLIA_TESTNET_NETWORK } from './constants'; +import { createStarkScanClient } from './factory'; + +describe('createStarkScanClient', () => { + const config = Config.dataClient[DataClient.STARKSCAN]; + + it('creates a StarkScan client', () => { + config.apiKey = 'API_KEY'; + expect( + createStarkScanClient(STARKNET_SEPOLIA_TESTNET_NETWORK), + ).toBeInstanceOf(StarkScanClient); + config.apiKey = undefined; + }); + + it('throws `Missing StarkScan API key` error if the StarkScan API key is missing', () => { + expect(() => + createStarkScanClient(STARKNET_SEPOLIA_TESTNET_NETWORK), + ).toThrow('Missing StarkScan API key'); + }); +}); diff --git a/packages/starknet-snap/src/utils/factory.ts b/packages/starknet-snap/src/utils/factory.ts new file mode 100644 index 00000000..41811241 --- /dev/null +++ b/packages/starknet-snap/src/utils/factory.ts @@ -0,0 +1,25 @@ +import type { IDataClient } from '../chain/data-client'; +import { StarkScanClient } from '../chain/data-client/starkscan'; +import { Config, DataClient } from '../config'; +import type { Network } from '../types/snapState'; + +/** + * Create a StarkScan client. + * + * @param network - The network to create the data client for. + * @returns The StarkScan client. + * @throws Error if the StarkScan API key is missing. + */ +export function createStarkScanClient(network: Network): IDataClient { + const { apiKey } = Config.dataClient[DataClient.STARKSCAN]; + + if (!apiKey) { + throw new Error('Missing StarkScan API key'); + } + + const dataClient = new StarkScanClient(network, { + apiKey, + }); + + return dataClient; +} From bc1b20734dc4a8f63159e85baed3e5c1350d3842 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:43:40 +0800 Subject: [PATCH 2/2] chore: refactor `TransactionStateManager` to handle new transaction data structure (#450) * feat: add stark scan client * chore: add starkscan config * chore: lint * chore: add interface * chore: support multiple txn * chore: update starkscan * chore: update stark scan client * chore: update contract func name * chore: fix test * chore: update data client * chore: re-structure starkscan type * chore: add test coverage * chore: factory and config * chore: add backward compatibility for transactions type * chore: add comment * chore: lint * chore: resolve review comment * chore: change dataVersion to enum * chore: lint * chore: update test helper and refactor ContractAddressFilter * chore: lint * chore: add test for dataVersion filter * chore: update txn state mgr test * chore: update search condition * chore: update starkscan to handle missing selector_name --------- Co-authored-by: khanti42 --- .../starknet-snap/src/__tests__/helper.ts | 315 ++++++++++++------ .../src/state/__tests__/helper.ts | 17 + .../state/transaction-state-manager.test.ts | 60 +++- .../src/state/transaction-state-manager.ts | 40 ++- 4 files changed, 321 insertions(+), 111 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 31bbf1f9..b363226c 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -6,8 +6,9 @@ import type { UserInputEvent } from '@metamask/snaps-sdk'; import { UserInputEventType } from '@metamask/snaps-sdk'; import { generateMnemonic } from 'bip39'; import { getRandomValues } from 'crypto'; -import type { constants, EstimateFee } from 'starknet'; +import type { EstimateFee } from 'starknet'; import { + constants, ec, CallData, hash, @@ -24,10 +25,11 @@ import type { StarkScanTransactionsResponse, } from '../chain/data-client/starkscan.type'; import { FeeToken } from '../types/snapApi'; -import type { - AccContract, - Transaction, - TransactionRequest, +import { + TransactionDataVersion, + type AccContract, + type Transaction, + type TransactionRequest, } from '../types/snapState'; import { ACCOUNT_CLASS_HASH, @@ -62,6 +64,30 @@ export function generateRandomValue() { return getRandomValues(u32Arr)[0] / maxU32; } +/** + * Method to get a random value. + * + * @param dataLength - The length of the data. + * @returns An random number. + */ +export function getRandomValue(dataLength: number) { + return Math.floor(generateRandomValue() * dataLength); +} + +/** + * Method to get a random data. + * + * @param data - The data to get a random value. + * @returns A random data. + * */ +export function getRandomData(data: DataType[]) { + return data[getRandomValue(data.length)]; +} + +const SixtyThreeHexInBigInt = BigInt( + '1000000000000000000000000000000000000000000000000000000000000000000000000000', +); + /** * Method to generate Bip44 Entropy. * @@ -169,20 +195,24 @@ export async function generateAccounts( * @param params.finalityStatuses - Array of transaction finality status. * @param params.executionStatuses - Array of transaction execution status. * @param params.cnt - Number of transaction to generate. + * @param params.timestamp - The timestamp of the first transaction. + * @param params.transactionVersions - The transaction version, 1 or 3, where 3 represents the fee will be paid in STRK. * @returns An array of transaction object. */ export function generateTransactions({ chainId, address, + baseTxnHashInBigInt = SixtyThreeHexInBigInt, contractAddresses = PRELOADED_TOKENS.map((token) => token.address), txnTypes = Object.values(TransactionType), finalityStatuses = Object.values(TransactionFinalityStatus), executionStatuses = Object.values(TransactionExecutionStatus), // The timestamp from data source is in seconds timestamp = Math.floor(Date.now() / 1000), + transactionVersions = [1, 3], cnt = 1, }: { - chainId: constants.StarknetChainId; + chainId: constants.StarknetChainId | string; address: string; contractAddresses?: string[]; txnTypes?: TransactionType[]; @@ -190,29 +220,12 @@ export function generateTransactions({ executionStatuses?: TransactionExecutionStatus[]; timestamp?: number; cnt?: number; + transactionVersions?: number[]; + baseTxnHashInBigInt?: bigint; }): Transaction[] { - const transaction = { - chainId: chainId, - contractAddress: '', - contractCallData: [], - contractFuncName: '', - senderAddress: address, - timestamp: timestamp, - txnHash: '', - txnType: '', - failureReason: '', - status: '', - executionStatus: '', - finalityStatus: '', - eventIds: [], - }; - let accumulatedTimestamp = timestamp; - let accumulatedTxnHash = BigInt( - '0x2a8c2d5d4908a6561de87ecb18a76305c64800e3f81b393b9988de1abd37284', - ); - + let baseTimeStamp = timestamp; let createCnt = cnt; - let filteredTxnTypes = txnTypes; + let _txnTypes = txnTypes; const transactions: Transaction[] = []; // only 1 deploy account transaction to generate @@ -220,82 +233,192 @@ export function generateTransactions({ txnTypes.includes(TransactionType.DEPLOY_ACCOUNT) || txnTypes.includes(TransactionType.DEPLOY) ) { - transactions.push({ - ...transaction, - contractAddress: address, - txnType: TransactionType.DEPLOY_ACCOUNT, - finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, - executionStatus: TransactionExecutionStatus.SUCCEEDED, - timestamp: accumulatedTimestamp, - txnHash: '0x' + accumulatedTxnHash.toString(16), - }); + transactions.push( + generateDeployTransaction({ + address, + txnHash: getTransactionHash(baseTxnHashInBigInt), + timestamp: baseTimeStamp, + version: getRandomData(transactionVersions), + chainId, + }), + ); + createCnt -= 1; - // exclude deploy txnType - filteredTxnTypes = filteredTxnTypes.filter( + + // after generate a deploy transaction, we dont need to re-generate another deploy transaction, + // so we can remove it from the txnTypes, to make sure we only random the types that are not deploy. + _txnTypes = txnTypes.filter( (type) => type !== TransactionType.DEPLOY_ACCOUNT && type !== TransactionType.DEPLOY, ); } - if (filteredTxnTypes.length === 0) { - filteredTxnTypes = [TransactionType.INVOKE]; + for (let i = 1; i <= createCnt; i++) { + // Make sure the timestamp is increasing + baseTimeStamp += i * 100; + // Make sure the txn hash is unique + baseTxnHashInBigInt += BigInt(i * 100); + + const executionStatus = getRandomData(executionStatuses); + const finalityStatus = + executionStatus === TransactionExecutionStatus.REJECTED + ? TransactionFinalityStatus.ACCEPTED_ON_L2 + : getRandomData(finalityStatuses); + const txnType = getRandomData(_txnTypes); + const contractFuncName = + txnType == TransactionType.INVOKE + ? getRandomData(['transfer', 'upgrade']) + : ''; + + transactions.push( + generateInvokeTransaction({ + address, + contractAddress: getRandomData(contractAddresses), + txnHash: getTransactionHash(baseTxnHashInBigInt), + timestamp: baseTimeStamp, + version: getRandomData(transactionVersions), + chainId, + txnType, + finalityStatus, + executionStatus, + contractFuncName, + }), + ); } - for (let i = 1; i <= createCnt; i++) { - const randomContractAddress = - contractAddresses[ - Math.floor(generateRandomValue() * contractAddresses.length) - ]; - const randomTxnType = - filteredTxnTypes[ - Math.floor(generateRandomValue() * filteredTxnTypes.length) - ]; - let randomFinalityStatus = - finalityStatuses[ - Math.floor(generateRandomValue() * finalityStatuses.length) - ]; - let randomExecutionStatus = - executionStatuses[ - Math.floor(generateRandomValue() * executionStatuses.length) - ]; - let randomContractFuncName = ['transfer', 'upgrade'][ - Math.floor(generateRandomValue() * 2) - ]; - accumulatedTimestamp += i * 100; - accumulatedTxnHash += BigInt(i * 100); - - if (randomExecutionStatus === TransactionExecutionStatus.REJECTED) { - if ( - [ - TransactionFinalityStatus.NOT_RECEIVED, - TransactionFinalityStatus.RECEIVED, - TransactionFinalityStatus.ACCEPTED_ON_L1, - ].includes(randomFinalityStatus) - ) { - randomFinalityStatus = TransactionFinalityStatus.ACCEPTED_ON_L2; - } - } + return transactions.sort((a, b) => b.timestamp - a.timestamp); +} - if (randomFinalityStatus === TransactionFinalityStatus.NOT_RECEIVED) { - randomFinalityStatus = TransactionFinalityStatus.ACCEPTED_ON_L2; - randomExecutionStatus = TransactionExecutionStatus.SUCCEEDED; - } +function getTransactionTemplate() { + return { + chainId: constants.StarknetChainId.SN_SEPOLIA, + timestamp: 0, + senderAddress: '', + contractAddress: '', + txnHash: '', + txnType: '', + failureReason: '', + executionStatus: '', + finalityStatus: '', + accountCalls: null, + version: 1, + dataVersion: TransactionDataVersion.V2, + }; +} - transactions.push({ - ...transaction, - contractAddress: randomContractAddress, - txnType: randomTxnType, - finalityStatus: randomFinalityStatus, - executionStatus: randomExecutionStatus, - timestamp: accumulatedTimestamp, - contractFuncName: - randomTxnType === TransactionType.INVOKE ? randomContractFuncName : '', - txnHash: '0x' + accumulatedTxnHash.toString(16), - }); - } +/** + * Method to generate a deploy transaction. + * + * @param params + * @param params.address - The address of the account. + * @param params.txnHash - The transaction hash. + * @param params.timestamp - The timestamp of the transaction. + * @param params.version - The version of the transaction. + * @param params.chainId - The chain id of the transaction. + * @returns A transaction object. + * */ +export function generateDeployTransaction({ + address, + txnHash, + timestamp, + version, + chainId, +}: { + address: string; + txnHash: string; + timestamp: number; + version: number; + chainId: constants.StarknetChainId | string; +}): Transaction { + const transaction = getTransactionTemplate(); - return transactions.sort((a, b) => b.timestamp - a.timestamp); + return { + ...transaction, + chainId: chainId, + txnHash, + senderAddress: address, + contractAddress: address, + txnType: TransactionType.DEPLOY_ACCOUNT, + finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, + executionStatus: TransactionExecutionStatus.SUCCEEDED, + timestamp: timestamp, + version: version, + }; +} + +/** + * Method to generate an invoke transaction. + * + * @param params + * @param params.address - The address of the account. + * @param params.contractAddress - The contract address. + * @param params.txnHash - The transaction hash. + * @param params.timestamp - The timestamp of the transaction. + * @param params.version - The version of the transaction. + * @param params.chainId - The chain id of the transaction. + * @param params.txnType - The type of the transaction. + * @param params.finalityStatus - The finality status of the transaction. + * @param params.executionStatus - The execution status of the transaction. + * @param params.contractFuncName - The contract function name. + * @returns A transaction object. + * */ +export function generateInvokeTransaction({ + address, + contractAddress, + txnHash, + timestamp, + version, + chainId, + txnType, + finalityStatus, + executionStatus, + contractFuncName, +}: { + address: string; + txnHash: string; + contractAddress: string; + timestamp: number; + version: number; + chainId: constants.StarknetChainId | string; + finalityStatus: TransactionFinalityStatus; + executionStatus: TransactionExecutionStatus; + txnType: TransactionType; + contractFuncName: string; +}): Transaction { + const transaction = getTransactionTemplate(); + + return { + ...transaction, + chainId: chainId, + contractAddress: '', + txnType, + finalityStatus, + executionStatus, + timestamp, + txnHash, + senderAddress: address, + accountCalls: { + [contractAddress]: [ + { + contract: contractAddress, + contractFuncName, + contractCallData: [address, getRandomValue(1000).toString(16)], + }, + ], + }, + version: version, + }; +} + +/** + * Method to generate a random transaction hash. + * + * @param base - The base number to generate the transaction hash. + * @returns A transaction hash. + * */ +export function getTransactionHash(base = SixtyThreeHexInBigInt) { + return `0x` + base.toString(16); } export function generateTransactionRequests({ @@ -333,16 +456,10 @@ export function generateTransactionRequests({ signer: address, addressIndex: 0, maxFee: '100', - selectedFeeToken: - selectedFeeTokens[ - Math.floor(generateRandomValue() * selectedFeeTokens.length) - ], + selectedFeeToken: getRandomData(selectedFeeTokens), calls: [ { - contractAddress: - contractAddresses[ - Math.floor(generateRandomValue() * contractAddresses.length) - ], + contractAddress: getRandomData(contractAddresses), calldata: CallData.compile({ to: address, amount: '1', diff --git a/packages/starknet-snap/src/state/__tests__/helper.ts b/packages/starknet-snap/src/state/__tests__/helper.ts index 05044039..0efdc479 100644 --- a/packages/starknet-snap/src/state/__tests__/helper.ts +++ b/packages/starknet-snap/src/state/__tests__/helper.ts @@ -15,6 +15,7 @@ import * as snapHelper from '../../utils/snap'; import { NetworkStateManager } from '../network-state-manager'; import { TransactionRequestStateManager } from '../request-state-manager'; import { TokenStateManager } from '../token-state-manager'; +import { TransactionStateManager } from '../transaction-state-manager'; jest.mock('../../utils/snap'); jest.mock('../../utils/logger'); @@ -74,6 +75,22 @@ export const mockTokenStateManager = () => { }; }; +export const mockTransactionStateManager = () => { + const removeTransactionsSpy = jest.spyOn( + TransactionStateManager.prototype, + 'removeTransactions', + ); + const findTransactionsSpy = jest.spyOn( + TransactionStateManager.prototype, + 'findTransactions', + ); + + return { + removeTransactionsSpy, + findTransactionsSpy, + }; +}; + export const mockTransactionRequestStateManager = () => { const upsertTransactionRequestSpy = jest.spyOn( TransactionRequestStateManager.prototype, diff --git a/packages/starknet-snap/src/state/transaction-state-manager.test.ts b/packages/starknet-snap/src/state/transaction-state-manager.test.ts index b67344b4..bc0c90e9 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.test.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.test.ts @@ -6,6 +6,8 @@ import { } from 'starknet'; import { generateTransactions } from '../__tests__/helper'; +import type { V2Transaction } from '../types/snapState'; +import { TransactionDataVersion } from '../types/snapState'; import { PRELOADED_TOKENS } from '../utils/constants'; import { mockAcccounts, mockState } from './__tests__/helper'; import { StateManagerError } from './state-manager'; @@ -132,20 +134,60 @@ describe('TransactionStateManager', () => { expect(result).toStrictEqual(txns); }); + it('returns the list of transaction by data version', async () => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + const { + txns: [legacyData, ...newData], + state, + } = await prepareMockData(chainId); + + const legacyTxn = { + txnHash: legacyData.txnHash, + txnType: legacyData.txnType, + chainId: legacyData.chainId, + senderAddress: legacyData.senderAddress, + contractAddress: legacyData.contractAddress, + contractFuncName: 'transfer', + contractCallData: ['0x123', '0x456'], + executionStatus: legacyData.executionStatus, + finalityStatus: legacyData.finalityStatus, + timestamp: legacyData.timestamp, + eventIds: [], + failureReason: legacyData.failureReason, + }; + // simulate the data source return the legacy data and new data + state.transactions = newData.concat([legacyTxn]); + + const stateManager = new TransactionStateManager(); + + const result = await stateManager.findTransactions({ + dataVersion: [TransactionDataVersion.V2], + }); + + expect(result).toStrictEqual(newData); + }); + it('returns the list of transaction by contract address', async () => { const { txns, stateManager } = await prepareFindTransctions(); const tokenAddress1 = PRELOADED_TOKENS.map((token) => token.address)[0]; const tokenAddress2 = PRELOADED_TOKENS.map((token) => token.address)[2]; + const contractAddress = [ + tokenAddress1.toLowerCase(), + tokenAddress2.toLowerCase(), + ]; + const contractAddressSet = new Set(contractAddress); const result = await stateManager.findTransactions({ - contractAddress: [tokenAddress1, tokenAddress2], + contractAddress, }); expect(result).toStrictEqual( txns.filter( - (txn) => - txn.contractAddress === tokenAddress1 || - txn.contractAddress === tokenAddress2, + (txn: V2Transaction) => + txn.accountCalls && + Object.keys(txn.accountCalls).some((contract) => + contractAddressSet.has(contract.toLowerCase()), + ), ), ); }); @@ -212,8 +254,9 @@ describe('TransactionStateManager', () => { TransactionExecutionStatus.REJECTED, ]; const contractAddressCond = [ - PRELOADED_TOKENS.map((token) => token.address)[0], + PRELOADED_TOKENS.map((token) => token.address.toLowerCase())[0], ]; + const contractAddressSet = new Set(contractAddressCond); const timestampCond = txns[5].timestamp * 1000; const chainIdCond = [ txns[0].chainId as unknown as constants.StarknetChainId, @@ -229,7 +272,7 @@ describe('TransactionStateManager', () => { }); expect(result).toStrictEqual( - txns.filter((txn) => { + txns.filter((txn: V2Transaction) => { return ( (finalityStatusCond.includes( txn.finalityStatus as unknown as TransactionFinalityStatus, @@ -238,7 +281,10 @@ describe('TransactionStateManager', () => { txn.executionStatus as unknown as TransactionExecutionStatus, )) && txn.timestamp >= txns[5].timestamp && - contractAddressCond.includes(txn.contractAddress) && + txn.accountCalls && + Object.keys(txn.accountCalls).some((contract) => + contractAddressSet.has(contract.toLowerCase()), + ) && chainIdCond.includes( txn.chainId as unknown as constants.StarknetChainId, ) && diff --git a/packages/starknet-snap/src/state/transaction-state-manager.ts b/packages/starknet-snap/src/state/transaction-state-manager.ts index a4805bb9..0973ce82 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.ts @@ -5,8 +5,11 @@ import { } from 'starknet'; import { assert, enums, number } from 'superstruct'; -import type { Transaction, SnapState } from '../types/snapState'; -import { TransactionStatusType } from '../types/snapState'; +import type { Transaction, SnapState, V2Transaction } from '../types/snapState'; +import { + TransactionDataVersion, + TransactionStatusType, +} from '../types/snapState'; import type { IFilter } from './filter'; import { BigIntFilter, @@ -23,11 +26,24 @@ export class ChainIdFilter implements ITxFilter {} export class ContractAddressFilter - extends BigIntFilter + extends StringFllter implements ITxFilter { - dataKey = 'contractAddress'; + protected _apply(data: Transaction): boolean { + const txn = data as V2Transaction; + const { accountCalls } = txn; + if (!accountCalls) { + return false; + } + for (const contract in accountCalls) { + if (this.search.has(contract.toLowerCase())) { + return true; + } + } + return false; + } } + export class SenderAddressFilter extends BigIntFilter implements ITxFilter @@ -61,6 +77,13 @@ export class TxnTypeFilter dataKey = 'txnType'; } +export class DataVersionFilter + extends StringFllter + implements ITxFilter +{ + dataKey = 'dataVersion'; +} + // Filter for transaction status // Search for transactions based on the finality status and execution status // It compare the finality status and execution status in OR condition, due to our use case is to find the transactions that fit to the given finality status or the given execution status @@ -112,12 +135,13 @@ export class TxStatusFilter implements ITxFilter { export type SearchFilter = { txnHash?: string[]; txnType?: TransactionType[]; - chainId?: constants.StarknetChainId[]; + chainId?: constants.StarknetChainId[] | string[]; senderAddress?: string[]; contractAddress?: string[]; executionStatus?: TransactionExecutionStatus[]; finalityStatus?: TransactionFinalityStatus[]; timestamp?: number; + dataVersion?: string[]; }; export class TransactionStateManager extends StateManager { @@ -158,10 +182,16 @@ export class TransactionStateManager extends StateManager { executionStatus, finalityStatus, timestamp, + // default return the latest version of the data + dataVersion = [TransactionDataVersion.V2], }: SearchFilter, state?: SnapState, ): Promise { const filters: ITxFilter[] = []; + if (dataVersion !== undefined && dataVersion.length > 0) { + filters.push(new DataVersionFilter(dataVersion)); + } + if (txnHash !== undefined && txnHash.length > 0) { filters.push(new TxHashFilter(txnHash)); }