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; +}