From 77f4b7bb8d4db055c8ab5b507fb8ed24b1d3a5d9 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Sep 2024 15:09:09 +0800 Subject: [PATCH 01/20] feat: add stark scan client --- .../__tests__/fixture/stark-scan-example.json | 163 ++++++++ .../starknet-snap/src/__tests__/helper.ts | 53 +++ .../src/chain/data-client/starkscan.test.ts | 389 ++++++++++++++++++ .../src/chain/data-client/starkscan.ts | 224 ++++++++++ packages/starknet-snap/src/types/snapState.ts | 28 +- 5 files changed, 849 insertions(+), 8 deletions(-) create mode 100644 packages/starknet-snap/src/__tests__/fixture/stark-scan-example.json 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 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 6f5c1e55..ad552945 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -16,6 +16,10 @@ import { TransactionType, } from 'starknet'; +import type { + StarkScanTransaction, + StarkScanTransactionsResponse, +} from '../chain/data-client/starkscan'; import type { AccContract, Transaction } from '../types/snapState'; import { ACCOUNT_CLASS_HASH, @@ -24,6 +28,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 & { @@ -284,3 +289,51 @@ export function generateTransactions({ return transactions.sort((a, b) => b.timestamp - a.timestamp); } + +export function generateStarkScanTranscations({ + 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, + }; +} 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..7a092e73 --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -0,0 +1,389 @@ +import { TransactionType, constants } from 'starknet'; + +import { + generateAccounts, + generateStarkScanTranscations, +} from '../../__tests__/helper'; +import type { Network, Transaction } from '../../types/snapState'; +import { + STARKNET_MAINNET_NETWORK, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from '../../utils/constants'; +import type { StarkScanOptions } from './starkscan'; +import { StarkScanClient, type StarkScanTransaction } from './starkscan'; + +describe('StarkScanClient', () => { + class MockStarkScanClient extends StarkScanClient { + public toTransaction(data: StarkScanTransaction): Transaction { + return super.toTransaction(data); + } + + get baseUrl(): string { + return super.baseUrl; + } + + async get(url: string): Promise { + return super.get(url); + } + } + + 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 mSecsFor24Hours = 1000 * 60 * 60 * 24; + + 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 `Invalid Network` error 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('Invalid Network'); + }); + }); + + describe('get', () => { + it('fetches data', async () => { + const { fetchSpy } = createMockFetch(); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ data: 'data' }), + }); + + const client = createMockClient(); + const result = await client.get(`${client.baseUrl}/url`); + + expect(result).toStrictEqual({ data: 'data' }); + }); + + it('append api key to header', async () => { + const { fetchSpy } = createMockFetch(); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ data: 'data' }), + }); + const apiKey = 'ABCDEFG-API-KEY'; + + const client = createMockClient({ + options: { + apiKey, + }, + }); + await client.get(`${client.baseUrl}/url`); + + expect(fetchSpy).toHaveBeenCalledWith(`${client.baseUrl}/url`, { + method: 'GET', + headers: { + 'x-api-key': apiKey, + }, + }); + }); + + it('throws `Failed to fetch data` error if the response.ok is falsy', async () => { + const { fetchSpy } = createMockFetch(); + fetchSpy.mockResolvedValueOnce({ + ok: false, + statusText: 'error', + }); + + const client = createMockClient(); + + await expect(client.get(`${client.baseUrl}/url`)).rejects.toThrow( + `Failed to fetch data: error`, + ); + }); + }); + + describe('getTransactions', () => { + const getFromAndToTimestamp = (tillToInDay: number) => { + const from = Date.now(); + const to = from - mSecsFor24Hours * tillToInDay; + 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 = generateStarkScanTranscations({ + address: account.address, + startFrom: from, + timestampReduction: mSecsFor24Hours, + }); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(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('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); + // generate 10 invoke transactions within 100 days if the timestamp is not provided + const mockPage1Response = generateStarkScanTranscations({ + address: account.address, + txnTypes: [TransactionType.INVOKE], + cnt: 10, + }); + // generate another 10 invoke + deploy transactions within 100 days if the timestamp is not provided + const mockPage2Response = generateStarkScanTranscations({ + 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`; + const fetchOptions = { + method: 'GET', + headers: { + 'x-api-key': 'api-key', + }, + }; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ + data: mockPage1Response.data, + // eslint-disable-next-line @typescript-eslint/naming-convention + next_url: nextPageUrl, + }), + }); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockPage2Response), + }); + + const client = createMockClient(); + await client.getTransactions(account.address, to); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenNthCalledWith(1, firstPageUrl, fetchOptions); + expect(fetchSpy).toHaveBeenNthCalledWith(2, nextPageUrl, fetchOptions); + }); + + it('fetchs the deploy transaction if it is not present', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + const { from, to } = getFromAndToTimestamp(5); + // generate 10 invoke transactions + const mockInvokeResponse = generateStarkScanTranscations({ + address: account.address, + startFrom: from, + timestampReduction: mSecsFor24Hours, + txnTypes: [TransactionType.INVOKE], + }); + // generate 5 invoke transactions + deploy transactions + const mockDeployResponse = generateStarkScanTranscations({ + address: account.address, + // generate transactions which not overlap with above invoke transactions + startFrom: from - mSecsFor24Hours * 100, + timestampReduction: mSecsFor24Hours, + txnTypes: [TransactionType.INVOKE, TransactionType.DEPLOY_ACCOUNT], + cnt: 5, + }); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockInvokeResponse), + }); + fetchSpy.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockDeployResponse), + }); + + const client = createMockClient(); + // We only fetch the transactions from the last 5 days + const result = await client.getTransactions(account.address, to); + + // However the result should include a deploy transaction, even the deploy transaction is not in the last 5 days + expect( + result.find((tx) => tx.txnType === TransactionType.DEPLOY_ACCOUNT), + ).toBeDefined(); + }); + }); + + describe('toTransaction', () => { + const mockTxByType = (txnType: TransactionType, address: string) => { + const mockResponse = generateStarkScanTranscations({ + address, + txnTypes: [txnType], + cnt: 1, + }); + const tx = mockResponse.data[0]; + return tx; + }; + + it('converts an invoke type starkscan transaction to a transaction', async () => { + const account = await mockAccount(); + const mockTx = mockTxByType(TransactionType.INVOKE, 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: mockTx.account_calls[0].contract_address, + contractFuncName: mockTx.account_calls[0].selector_name, + contractCallData: mockTx.account_calls[0].calldata, + timestamp: mockTx.timestamp, + finalityStatus: mockTx.transaction_finality_status, + executionStatus: mockTx.transaction_execution_status, + failureReason: mockTx.revert_error ?? undefined, + maxFee: BigInt(mockTx.max_fee), + actualFee: BigInt(mockTx.actual_fee), + }); + }); + + it('converts a deploy type starkscan transaction to a transaction', 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, + contractFuncName: '', + contractCallData: mockTx.constructor_calldata, + timestamp: mockTx.timestamp, + finalityStatus: mockTx.transaction_finality_status, + executionStatus: mockTx.transaction_execution_status, + failureReason: mockTx.revert_error ?? undefined, + maxFee: BigInt(mockTx.max_fee), + actualFee: BigInt(mockTx.actual_fee), + }); + }); + }); + + describe('getDeployTransaction', () => { + it('returns a deploy transaction', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + // generate 5 invoke transactions with deploy transaction + const mockResponse = generateStarkScanTranscations({ + address: account.address, + cnt: 5, + }); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const client = createMockClient(); + const result = await client.getDeployTransaction(account.address); + + expect(result.txnType).toStrictEqual(TransactionType.DEPLOY_ACCOUNT); + }); + + it('throws `Deploy transaction not found` error if no deploy transaction found', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + // generate 5 invoke transactions with deploy transaction + const mockResponse = generateStarkScanTranscations({ + address: account.address, + cnt: 1, + txnTypes: [TransactionType.INVOKE], + }); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockResponse), + }); + + const client = createMockClient(); + + await expect( + client.getDeployTransaction(account.address), + ).rejects.toThrow('Deploy transaction not found'); + }); + }); +}); 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..9847a637 --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -0,0 +1,224 @@ +import { + TransactionType, + type TransactionFinalityStatus, + type TransactionExecutionStatus, + constants, +} from 'starknet'; + +import type { Network, Transaction } from '../../types/snapState'; + +/* eslint-disable */ +export type StarkScanTransaction = { + transaction_hash: string; + block_hash: string; + block_number: number; + transaction_index: number; + transaction_status: string; + transaction_finality_status: TransactionExecutionStatus; + transaction_execution_status: TransactionFinalityStatus; + transaction_type: TransactionType; + version: number; + signature: string[]; + max_fee: string; + actual_fee: string; + nonce: string; + contract_address: string | null; + entry_point_selector: string | null; + entry_point_type: string | null; + calldata: string[]; + class_hash: string | null; + sender_address: string | null; + constructor_calldata: string[] | null; + contract_address_salt: string | null; + timestamp: number; + entry_point_selector_name: string; + number_of_events: number; + revert_error: string | null; + account_calls: StarkScanAccountCall[]; +}; + +export type StarkScanAccountCall = { + block_hash: string; + block_number: number; + transaction_hash: string; + caller_address: string; + contract_address: string; + calldata: string[]; + result: string[]; + timestamp: number; + call_type: string; + class_hash: string; + selector: string; + entry_point_type: string; + selector_name: string; +}; +/* eslint-disable */ + +export type StarkScanTransactionsResponse = { + next_url: string | null; + data: StarkScanTransaction[]; +}; + +export type StarkScanOptions = { + apiKey: string; +}; + +export class StarkScanClient { + protected network: Network; + protected options: StarkScanOptions; + + protected deploySelectorName: string = 'constructor'; + + constructor(network: Network, options: StarkScanOptions) { + 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 Error(`Invalid Network`); + } + } + + protected getApiUrl(endpoint: string): string { + return `${this.baseUrl}${endpoint}`; + } + + protected getCredential(): Record { + return { + 'x-api-key': this.options.apiKey, + }; + } + + protected async get(url: string): Promise { + const response = await fetch(url, { + method: 'GET', + headers: this.getCredential(), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch data: ${response.statusText}`); + } + return response.json() as unknown as Resp; + } + + async getTransactions( + address: string, + tillTo: number, + ): Promise { + let apiUrl = this.getApiUrl( + `/transactions?contract_address=${address}&order_by=desc&limit=100`, + ); + + const txs: Transaction[] = []; + let deployTxFound = false; + let process = true; + let timestamp = 0; + + // Fetch the transactions if: + // - the timestamp is greater than the `tillTo` AND + // - there is an next data to fetch + while (process && (timestamp === 0 || timestamp >= tillTo)) { + process = false; + + const result = await this.get(apiUrl); + + for (const data of result.data) { + const tx = this.toTransaction(data); + const isDeployTx = this.isDeployTransaction(data); + + if (isDeployTx) { + deployTxFound = true; + } + + timestamp = tx.timestamp; + // If the timestamp is smaller than the `tillTo` + // We don't need those records + // But if the record is an deploy transaction, we should include it to reduce the number of requests + if (timestamp >= tillTo || isDeployTx) { + txs.push(tx); + } + } + + if (result.next_url) { + apiUrl = result.next_url; + process = true; + } + } + + // If the deploy transaction is not found from above traverse, we need to fetch it separately + if (!deployTxFound) { + txs.push(await this.getDeployTransaction(address)); + } + + return txs; + } + + 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 + let apiUrl = this.getApiUrl( + `/transactions?contract_address=${address}&order_by=asc&limit=5`, + ); + + const result = await this.get(apiUrl); + + for (const data of result.data) { + if (this.isDeployTransaction(data)) { + return this.toTransaction(data); + } + } + + throw new Error(`Deploy transaction not found`); + } + + protected isDeployTransaction(tx: StarkScanTransaction): boolean { + return tx.transaction_type === TransactionType.DEPLOY_ACCOUNT; + } + + protected toTransaction(tx: StarkScanTransaction): Transaction { + let sender: string, + contract: string, + contractFuncName: string, + contractCallData: null | string[]; + /* eslint-disable */ + if (!this.isDeployTransaction(tx)) { + // When an account deployed, it invokes the transaction from the account contract, hence the account_calls[0] is the main invoke call from the contract + const contractCallArg = tx.account_calls[0]; + + sender = contractCallArg.caller_address; + contract = contractCallArg.contract_address; + contractFuncName = contractCallArg.selector_name; + contractCallData = contractCallArg.calldata; + } else { + // In case of deploy transaction, the contract address is the sender address + contract = sender = tx.contract_address as unknown as string; + + contractFuncName = ''; + // In case of deploy transaction, the contract call data is the constructor calldata + contractCallData = tx.constructor_calldata; + } + + return { + txnHash: tx.transaction_hash, + txnType: tx.transaction_type, + chainId: this.network.chainId, + senderAddress: sender, + contractAddress: contract, + contractFuncName: contractFuncName, + contractCallData: contractCallData ?? [], + timestamp: tx.timestamp, + finalityStatus: tx.transaction_finality_status, + executionStatus: tx.transaction_execution_status, + failureReason: tx.revert_error ?? undefined, + maxFee: BigInt(tx.max_fee), + actualFee: BigInt(tx.actual_fee), + }; + /* eslint-disable */ + } +} diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index a1ae56e7..843041b8 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -1,4 +1,9 @@ -import type { RawCalldata } from 'starknet'; +import type { + RawCalldata, + TransactionType as StarkNetTransactionType, + TransactionExecutionStatus, + TransactionFinalityStatus, +} from 'starknet'; /* eslint-disable */ export type SnapState = { @@ -81,20 +86,27 @@ export enum TransactionStatusType { // for retrieving txn from StarkNet feeder g export type Transaction = { 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; + // TEMP: add StarkNetTransactionType as optional to support the legacy data + txnType: VoyagerTransactionType | string | StarkNetTransactionType; 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; - contractCallData: RawCalldata; + contractCallData: RawCalldata | string[]; status?: TransactionStatus | string; - executionStatus?: TransactionStatus | string; - finalityStatus?: TransactionStatus | string; - failureReason: string; - eventIds: string[]; + // TEMP: add TransactionFinalityStatus as optional to support the legacy data + executionStatus?: TransactionStatus | string | TransactionFinalityStatus; + // TEMP: add TransactionExecutionStatus as optional to support the legacy data + finalityStatus?: TransactionStatus | string | TransactionExecutionStatus; + failureReason?: string; + // TEMP: add it as optional to support the legacy data + eventIds?: string[]; timestamp: number; + // TEMP: add it as optional to support the legacy data + maxFee?: BigInt; + // TEMP: add it as optional to support the legacy data + actualFee?: BigInt; }; /* eslint-disable */ From 6b7a5e255cfbbb3aa25e3125b9429b1e2d63d3d6 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Sep 2024 15:10:08 +0800 Subject: [PATCH 02/20] chore: add starkscan config --- packages/starknet-snap/snap.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/starknet-snap/snap.config.ts b/packages/starknet-snap/snap.config.ts index f8484fc3..a34f03c8 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 ?? '', /* eslint-disable */ }, polyfills: true, From 3c9a5238ac657668162fb36f47f0c1860cc7717f Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Sep 2024 15:24:05 +0800 Subject: [PATCH 03/20] chore: lint --- .../src/chain/data-client/starkscan.ts | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 9847a637..ee52372f 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -52,7 +52,6 @@ export type StarkScanAccountCall = { entry_point_type: string; selector_name: string; }; -/* eslint-disable */ export type StarkScanTransactionsResponse = { next_url: string | null; @@ -62,12 +61,14 @@ export type StarkScanTransactionsResponse = { export type StarkScanOptions = { apiKey: string; }; +/* eslint-enable */ export class StarkScanClient { protected network: Network; + protected options: StarkScanOptions; - protected deploySelectorName: string = 'constructor'; + protected deploySelectorName = 'constructor'; constructor(network: Network, options: StarkScanOptions) { this.network = network; @@ -107,6 +108,14 @@ export class StarkScanClient { return response.json() as unknown as Resp; } + /** + * 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 tillTo - The timestamp to fetch the transactions until. + * @returns A Promise that resolve an array of Transaction object. + */ async getTransactions( address: string, tillTo: number, @@ -159,10 +168,17 @@ export class StarkScanClient { 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. + * @throws Throws an error if the deploy transaction is not 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 - let apiUrl = this.getApiUrl( + const apiUrl = this.getApiUrl( `/transactions?contract_address=${address}&order_by=asc&limit=5`, ); @@ -182,11 +198,12 @@ export class StarkScanClient { } protected toTransaction(tx: StarkScanTransaction): Transaction { - let sender: string, - contract: string, - contractFuncName: string, - contractCallData: null | string[]; - /* eslint-disable */ + let sender = ''; + let contract = ''; + let contractFuncName = ''; + let contractCallData: null | string[] = null; + + // eslint-disable-next-line no-negated-condition if (!this.isDeployTransaction(tx)) { // When an account deployed, it invokes the transaction from the account contract, hence the account_calls[0] is the main invoke call from the contract const contractCallArg = tx.account_calls[0]; @@ -197,13 +214,14 @@ export class StarkScanClient { contractCallData = contractCallArg.calldata; } else { // In case of deploy transaction, the contract address is the sender address - contract = sender = tx.contract_address as unknown as string; - + sender = tx.contract_address as unknown as string; + contract = tx.contract_address as unknown as string; contractFuncName = ''; // In case of deploy transaction, the contract call data is the constructor calldata contractCallData = tx.constructor_calldata; } + /* eslint-disable */ return { txnHash: tx.transaction_hash, txnType: tx.transaction_type, @@ -219,6 +237,6 @@ export class StarkScanClient { maxFee: BigInt(tx.max_fee), actualFee: BigInt(tx.actual_fee), }; - /* eslint-disable */ + /* eslint-enable */ } } From 576f302813a382673af1b37d14396c7539e9e8d1 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Sep 2024 15:27:14 +0800 Subject: [PATCH 04/20] chore: add interface --- packages/starknet-snap/src/chain/data-client.ts | 6 ++++++ packages/starknet-snap/src/chain/data-client/starkscan.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 packages/starknet-snap/src/chain/data-client.ts 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..f63ad9b9 --- /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.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index ee52372f..fb921355 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -6,6 +6,7 @@ import { } from 'starknet'; import type { Network, Transaction } from '../../types/snapState'; +import type { IDataClient } from '../data-client'; /* eslint-disable */ export type StarkScanTransaction = { @@ -63,7 +64,7 @@ export type StarkScanOptions = { }; /* eslint-enable */ -export class StarkScanClient { +export class StarkScanClient implements IDataClient { protected network: Network; protected options: StarkScanOptions; From 5d446cba037d800d7429431bb800384d35d88cd5 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Sep 2024 10:22:05 +0800 Subject: [PATCH 05/20] chore: support multiple txn --- .../src/chain/data-client/starkscan.test.ts | 22 +++++--- .../src/chain/data-client/starkscan.ts | 50 ++++++++++++------- packages/starknet-snap/src/types/snapState.ts | 14 +++++- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index 7a092e73..f8b85120 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -293,7 +293,7 @@ describe('StarkScanClient', () => { return tx; }; - it('converts an invoke type starkscan transaction to a transaction', async () => { + it('converts an invoke type starkscan transaction to a transaction object', async () => { const account = await mockAccount(); const mockTx = mockTxByType(TransactionType.INVOKE, account.address); @@ -305,19 +305,28 @@ describe('StarkScanClient', () => { txnType: mockTx.transaction_type, chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, senderAddress: account.address, - contractAddress: mockTx.account_calls[0].contract_address, - contractFuncName: mockTx.account_calls[0].selector_name, - contractCallData: mockTx.account_calls[0].calldata, + contractAddress: '', + contractFuncName: '', + contractCallData: mockTx.calldata, timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, executionStatus: mockTx.transaction_execution_status, failureReason: mockTx.revert_error ?? undefined, maxFee: BigInt(mockTx.max_fee), actualFee: BigInt(mockTx.actual_fee), + accountCalls: [ + { + contract: mockTx.account_calls[0].contract_address, + contractFuncName: mockTx.account_calls[0].selector_name, + contractCallData: mockTx.account_calls[0].calldata, + recipient: mockTx.account_calls[0].calldata[0], + amount: mockTx.account_calls[0].calldata[1], + }, + ], }); }); - it('converts a deploy type starkscan transaction to a transaction', async () => { + it('converts a deploy type starkscan transaction to a transaction object', async () => { const account = await mockAccount(); const mockTx = mockTxByType( TransactionType.DEPLOY_ACCOUNT, @@ -334,13 +343,14 @@ describe('StarkScanClient', () => { senderAddress: account.address, contractAddress: account.address, contractFuncName: '', - contractCallData: mockTx.constructor_calldata, + contractCallData: [], timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, executionStatus: mockTx.transaction_execution_status, failureReason: mockTx.revert_error ?? undefined, maxFee: BigInt(mockTx.max_fee), actualFee: BigInt(mockTx.actual_fee), + accountCalls: [], }); }); }); diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index fb921355..73feabe9 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -5,7 +5,11 @@ import { constants, } from 'starknet'; -import type { Network, Transaction } from '../../types/snapState'; +import type { + Network, + Transaction, + TranscationAccountCall, +} from '../../types/snapState'; import type { IDataClient } from '../data-client'; /* eslint-disable */ @@ -198,28 +202,32 @@ export class StarkScanClient implements IDataClient { return tx.transaction_type === TransactionType.DEPLOY_ACCOUNT; } + protected isFundTransferTransaction(call: StarkScanAccountCall): boolean { + return call.selector_name === 'transfer'; + } + protected toTransaction(tx: StarkScanTransaction): Transaction { - let sender = ''; - let contract = ''; - let contractFuncName = ''; - let contractCallData: null | string[] = null; + let sender = tx.sender_address ?? ''; + const accountCalls: TranscationAccountCall[] = []; // eslint-disable-next-line no-negated-condition if (!this.isDeployTransaction(tx)) { - // When an account deployed, it invokes the transaction from the account contract, hence the account_calls[0] is the main invoke call from the contract - const contractCallArg = tx.account_calls[0]; - - sender = contractCallArg.caller_address; - contract = contractCallArg.contract_address; - contractFuncName = contractCallArg.selector_name; - contractCallData = contractCallArg.calldata; + // account_calls representing the calls to invoke from the account contract, it can be multiple + for (const accountCallArg of tx.account_calls) { + const accountCall: TranscationAccountCall = { + contract: accountCallArg.contract_address, + contractFuncName: accountCallArg.selector_name, + contractCallData: accountCallArg.calldata, + }; + if (this.isFundTransferTransaction(accountCallArg)) { + accountCall.recipient = accountCallArg.calldata[0]; + accountCall.amount = accountCallArg.calldata[1]; + } + accountCalls.push(accountCall); + } } else { // In case of deploy transaction, the contract address is the sender address sender = tx.contract_address as unknown as string; - contract = tx.contract_address as unknown as string; - contractFuncName = ''; - // In case of deploy transaction, the contract call data is the constructor calldata - contractCallData = tx.constructor_calldata; } /* eslint-disable */ @@ -228,15 +236,19 @@ export class StarkScanClient implements IDataClient { txnType: tx.transaction_type, chainId: this.network.chainId, senderAddress: sender, - contractAddress: contract, - contractFuncName: contractFuncName, - contractCallData: contractCallData ?? [], + // In case of deploy transaction, the contract address is the sender address, else it will be empty string + contractAddress: tx.contract_address ?? '', + // TODO: when multiple calls are supported, we move this to accountCalls, hence we keep it for legacy support + contractFuncName: '', + // TODO: when multiple calls are supported, we move this to accountCalls, hence we keep it for legacy support + contractCallData: tx.calldata ?? [], timestamp: tx.timestamp, finalityStatus: tx.transaction_finality_status, executionStatus: tx.transaction_execution_status, failureReason: tx.revert_error ?? undefined, maxFee: BigInt(tx.max_fee), actualFee: BigInt(tx.actual_fee), + accountCalls, }; /* eslint-enable */ } diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index 843041b8..df2ac275 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -84,6 +84,14 @@ export enum TransactionStatusType { // for retrieving txn from StarkNet feeder g DEPRECATION = 'status', } +export type TranscationAccountCall = { + contract: string; + contractFuncName: string; + contractCallData: string[]; + recipient?: string; + amount?: string; +}; + export type Transaction = { txnHash: string; // in hex // TEMP: add StarkNetTransactionType as optional to support the legacy data @@ -103,10 +111,12 @@ export type Transaction = { // TEMP: add it as optional to support the legacy data eventIds?: string[]; timestamp: number; - // TEMP: add it as optional to support the legacy data + // TEMP: put it as optional to support the legacy data maxFee?: BigInt; - // TEMP: add it as optional to support the legacy data + // TEMP: put it as optional to support the legacy data actualFee?: BigInt; + // TEMP: put it as optional to support the legacy data + accountCalls?: TranscationAccountCall[]; }; /* eslint-disable */ From 3dbdf3293e23bcaba28d7e6ea8320258bc52977c Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Sep 2024 11:55:12 +0800 Subject: [PATCH 06/20] chore: update starkscan --- .../src/chain/data-client/starkscan.test.ts | 28 ++++++--- .../src/chain/data-client/starkscan.ts | 63 ++++++++++++++----- packages/starknet-snap/src/types/snapState.ts | 3 +- 3 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index f8b85120..164ac60f 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -300,6 +300,12 @@ describe('StarkScanClient', () => { const client = createMockClient(); const result = client.toTransaction(mockTx); + const { + contract_address: contract, + selector_name: contractFuncName, + calldata: contractCallData, + } = mockTx.account_calls[0]; + expect(result).toStrictEqual({ txnHash: mockTx.transaction_hash, txnType: mockTx.transaction_type, @@ -314,15 +320,17 @@ describe('StarkScanClient', () => { failureReason: mockTx.revert_error ?? undefined, maxFee: BigInt(mockTx.max_fee), actualFee: BigInt(mockTx.actual_fee), - accountCalls: [ - { - contract: mockTx.account_calls[0].contract_address, - contractFuncName: mockTx.account_calls[0].selector_name, - contractCallData: mockTx.account_calls[0].calldata, - recipient: mockTx.account_calls[0].calldata[0], - amount: mockTx.account_calls[0].calldata[1], - }, - ], + accountCalls: { + [contract]: [ + { + contract, + contractFuncName, + contractCallData, + recipient: contractCallData[0], + amount: contractCallData[1], + }, + ], + }, }); }); @@ -350,7 +358,7 @@ describe('StarkScanClient', () => { failureReason: mockTx.revert_error ?? undefined, maxFee: BigInt(mockTx.max_fee), actualFee: BigInt(mockTx.actual_fee), - accountCalls: [], + accountCalls: undefined, }); }); }); diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 73feabe9..204fd3cd 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -208,24 +208,12 @@ export class StarkScanClient implements IDataClient { protected toTransaction(tx: StarkScanTransaction): Transaction { let sender = tx.sender_address ?? ''; - const accountCalls: TranscationAccountCall[] = []; + + // account_calls representing the calls to invoke from the account contract, it can be multiple + const accountCalls = this.toAccountCall(tx.account_calls); // eslint-disable-next-line no-negated-condition - if (!this.isDeployTransaction(tx)) { - // account_calls representing the calls to invoke from the account contract, it can be multiple - for (const accountCallArg of tx.account_calls) { - const accountCall: TranscationAccountCall = { - contract: accountCallArg.contract_address, - contractFuncName: accountCallArg.selector_name, - contractCallData: accountCallArg.calldata, - }; - if (this.isFundTransferTransaction(accountCallArg)) { - accountCall.recipient = accountCallArg.calldata[0]; - accountCall.amount = accountCallArg.calldata[1]; - } - accountCalls.push(accountCall); - } - } else { + if (this.isDeployTransaction(tx)) { // In case of deploy transaction, the contract address is the sender address sender = tx.contract_address as unknown as string; } @@ -248,8 +236,49 @@ export class StarkScanClient implements IDataClient { failureReason: tx.revert_error ?? undefined, maxFee: BigInt(tx.max_fee), actualFee: BigInt(tx.actual_fee), - accountCalls, + accountCalls: accountCalls, }; /* eslint-enable */ } + + protected toAccountCall( + calls: StarkScanAccountCall[], + ): Record | undefined { + if (!calls || calls.length === 0) { + return undefined; + } + + return calls.reduce( + ( + data: Record, + accountCallArg: StarkScanAccountCall, + ) => { + const { + contract_address: contract, + selector_name: contractFuncName, + calldata: contractCallData, + } = accountCallArg; + + if (!Object.prototype.hasOwnProperty.call(data, contract)) { + data[contract] = []; + } + + const accountCall: TranscationAccountCall = { + contract, + contractFuncName, + contractCallData, + }; + + if (this.isFundTransferTransaction(accountCallArg)) { + accountCall.recipient = accountCallArg.calldata[0]; + accountCall.amount = accountCallArg.calldata[1]; + } + + data[contract].push(accountCall); + + return data; + }, + {}, + ); + } } diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index df2ac275..ebf238aa 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -116,7 +116,8 @@ export type Transaction = { // TEMP: put it as optional to support the legacy data actualFee?: BigInt; // TEMP: put it as optional to support the legacy data - accountCalls?: TranscationAccountCall[]; + // using Record to support O(1) searching + accountCalls?: Record; }; /* eslint-disable */ From 924ea45b39b0bfff8d65f0d89f2bb9ac68d7de7c Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:15:26 +0800 Subject: [PATCH 07/20] chore: update stark scan client --- .../src/chain/data-client/starkscan.test.ts | 10 ++++------ .../starknet-snap/src/chain/data-client/starkscan.ts | 6 ++---- packages/starknet-snap/src/types/snapState.ts | 7 ++++--- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index 164ac60f..233efaad 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -312,14 +312,13 @@ describe('StarkScanClient', () => { chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, senderAddress: account.address, contractAddress: '', - contractFuncName: '', contractCallData: mockTx.calldata, timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, executionStatus: mockTx.transaction_execution_status, failureReason: mockTx.revert_error ?? undefined, - maxFee: BigInt(mockTx.max_fee), - actualFee: BigInt(mockTx.actual_fee), + maxFee: mockTx.max_fee, + actualFee: mockTx.actual_fee, accountCalls: { [contract]: [ { @@ -350,14 +349,13 @@ describe('StarkScanClient', () => { chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, senderAddress: account.address, contractAddress: account.address, - contractFuncName: '', contractCallData: [], timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, executionStatus: mockTx.transaction_execution_status, failureReason: mockTx.revert_error ?? undefined, - maxFee: BigInt(mockTx.max_fee), - actualFee: BigInt(mockTx.actual_fee), + maxFee: mockTx.max_fee, + actualFee: mockTx.actual_fee, accountCalls: undefined, }); }); diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 204fd3cd..9f729ef0 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -227,15 +227,13 @@ export class StarkScanClient implements IDataClient { // In case of deploy transaction, the contract address is the sender address, else it will be empty string contractAddress: tx.contract_address ?? '', // TODO: when multiple calls are supported, we move this to accountCalls, hence we keep it for legacy support - contractFuncName: '', - // TODO: when multiple calls are supported, we move this to accountCalls, hence we keep it for legacy support contractCallData: tx.calldata ?? [], timestamp: tx.timestamp, finalityStatus: tx.transaction_finality_status, executionStatus: tx.transaction_execution_status, failureReason: tx.revert_error ?? undefined, - maxFee: BigInt(tx.max_fee), - actualFee: BigInt(tx.actual_fee), + maxFee: tx.max_fee, + actualFee: tx.actual_fee, accountCalls: accountCalls, }; /* eslint-enable */ diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index ebf238aa..71118461 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -100,7 +100,8 @@ export type Transaction = { // 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; + // TEMP: add contractFuncName as optional, as it will move to `accountCalls` + contractFuncName?: string; contractCallData: RawCalldata | string[]; status?: TransactionStatus | string; // TEMP: add TransactionFinalityStatus as optional to support the legacy data @@ -112,9 +113,9 @@ export type Transaction = { eventIds?: string[]; timestamp: number; // TEMP: put it as optional to support the legacy data - maxFee?: BigInt; + maxFee?: string; // TEMP: put it as optional to support the legacy data - actualFee?: BigInt; + actualFee?: string; // TEMP: put it as optional to support the legacy data // using Record to support O(1) searching accountCalls?: Record; From bcf34c7a6e13ed44c385a842adf2ee06748c9f46 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:21:40 +0800 Subject: [PATCH 08/20] chore: update contract func name --- packages/starknet-snap/src/types/snapState.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index 71118461..0bb209d7 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -100,8 +100,7 @@ export type Transaction = { // TODO: rename it to address to sync with the same naming convention in the AccContract senderAddress: string; // in hex contractAddress: string; // in hex - // TEMP: add contractFuncName as optional, as it will move to `accountCalls` - contractFuncName?: string; + contractFuncName: string; contractCallData: RawCalldata | string[]; status?: TransactionStatus | string; // TEMP: add TransactionFinalityStatus as optional to support the legacy data From d1ad70cc1e9d9a7ff1eb93a53bccfc850faa9e69 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:26:57 +0800 Subject: [PATCH 09/20] chore: fix test --- .../starknet-snap/src/chain/data-client/starkscan.test.ts | 2 ++ packages/starknet-snap/src/chain/data-client/starkscan.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index 233efaad..8572449d 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -312,6 +312,7 @@ describe('StarkScanClient', () => { chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, senderAddress: account.address, contractAddress: '', + contractFuncName: '', contractCallData: mockTx.calldata, timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, @@ -349,6 +350,7 @@ describe('StarkScanClient', () => { chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, senderAddress: account.address, contractAddress: account.address, + contractFuncName: '', contractCallData: [], timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 9f729ef0..90190af8 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -224,9 +224,12 @@ export class StarkScanClient implements IDataClient { txnType: tx.transaction_type, chainId: this.network.chainId, senderAddress: sender, + // In case of deploy transaction, the contract address is the sender address, else it will be empty string contractAddress: tx.contract_address ?? '', - // TODO: when multiple calls are supported, we move this to accountCalls, hence we keep it for legacy support + // TODO: when multiple calls are supported, we move this to accountCalls + contractFuncName: '', + // TODO: when multiple calls are supported, we move this to accountCalls contractCallData: tx.calldata ?? [], timestamp: tx.timestamp, finalityStatus: tx.transaction_finality_status, From 1a061d32107ae553d257c776b20aae85784c3a7d Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 6 Sep 2024 20:31:47 +0800 Subject: [PATCH 10/20] chore: update data client --- .../starknet-snap/src/chain/data-client.ts | 2 +- .../src/chain/data-client/starkscan.test.ts | 53 +++++++++++++++---- .../src/chain/data-client/starkscan.ts | 16 +++--- packages/starknet-snap/src/types/snapState.ts | 14 ++--- 4 files changed, 58 insertions(+), 27 deletions(-) diff --git a/packages/starknet-snap/src/chain/data-client.ts b/packages/starknet-snap/src/chain/data-client.ts index f63ad9b9..e5cea616 100644 --- a/packages/starknet-snap/src/chain/data-client.ts +++ b/packages/starknet-snap/src/chain/data-client.ts @@ -2,5 +2,5 @@ import type { Transaction } from '../types/snapState'; export type IDataClient = { getTransactions: (address: string, tillTo: number) => Promise; - getDeployTransaction: (address: string) => 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 index 8572449d..abcc3aab 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -155,8 +155,8 @@ describe('StarkScanClient', () => { describe('getTransactions', () => { const getFromAndToTimestamp = (tillToInDay: number) => { - const from = Date.now(); - const to = from - mSecsFor24Hours * tillToInDay; + const from = Math.floor(Date.now() / 1000); + const to = from - tillToInDay * 24 * 60 * 60; return { from, to, @@ -196,6 +196,37 @@ describe('StarkScanClient', () => { ).toBeDefined(); }); + it('returns empty array if no result found', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + const { to } = getFromAndToTimestamp(5); + // generate 0 transactions + const mockInvokeResponse = generateStarkScanTranscations({ + address: account.address, + cnt: 0, + txnTypes: [TransactionType.INVOKE], + }); + // generate 0 transactions + const mockDeployResponse = generateStarkScanTranscations({ + address: account.address, + cnt: 0, + txnTypes: [TransactionType.INVOKE], + }); + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(mockInvokeResponse), + }); + fetchSpy.mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(mockDeployResponse), + }); + + 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(); @@ -256,7 +287,7 @@ describe('StarkScanClient', () => { // generate 5 invoke transactions + deploy transactions const mockDeployResponse = generateStarkScanTranscations({ address: account.address, - // generate transactions which not overlap with above invoke transactions + // generate transactions that start from 100 days ago, to ensure not overlap with above invoke transactions startFrom: from - mSecsFor24Hours * 100, timestampReduction: mSecsFor24Hours, txnTypes: [TransactionType.INVOKE, TransactionType.DEPLOY_ACCOUNT], @@ -317,7 +348,7 @@ describe('StarkScanClient', () => { timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, executionStatus: mockTx.transaction_execution_status, - failureReason: mockTx.revert_error ?? undefined, + failureReason: mockTx.revert_error ?? '', maxFee: mockTx.max_fee, actualFee: mockTx.actual_fee, accountCalls: { @@ -355,10 +386,10 @@ describe('StarkScanClient', () => { timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, executionStatus: mockTx.transaction_execution_status, - failureReason: mockTx.revert_error ?? undefined, + failureReason: mockTx.revert_error ?? '', maxFee: mockTx.max_fee, actualFee: mockTx.actual_fee, - accountCalls: undefined, + accountCalls: null, }); }); }); @@ -380,10 +411,11 @@ describe('StarkScanClient', () => { const client = createMockClient(); const result = await client.getDeployTransaction(account.address); - expect(result.txnType).toStrictEqual(TransactionType.DEPLOY_ACCOUNT); + expect(result).not.toBeNull(); + expect(result?.txnType).toStrictEqual(TransactionType.DEPLOY_ACCOUNT); }); - it('throws `Deploy transaction not found` error if no deploy transaction found', async () => { + it('returns null if no deploy transaction found', async () => { const account = await mockAccount(); const { fetchSpy } = createMockFetch(); // generate 5 invoke transactions with deploy transaction @@ -398,10 +430,9 @@ describe('StarkScanClient', () => { }); const client = createMockClient(); + const result = await client.getDeployTransaction(account.address); - await expect( - client.getDeployTransaction(account.address), - ).rejects.toThrow('Deploy transaction not found'); + expect(result).toBeNull(); }); }); }); diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 90190af8..12db0cb1 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -167,7 +167,8 @@ export class StarkScanClient implements IDataClient { // If the deploy transaction is not found from above traverse, we need to fetch it separately if (!deployTxFound) { - txs.push(await this.getDeployTransaction(address)); + const deployTx = await this.getDeployTransaction(address); + deployTx && txs.push(deployTx); } return txs; @@ -177,10 +178,9 @@ export class StarkScanClient implements IDataClient { * 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. - * @throws Throws an error if the deploy transaction is not found. + * @returns A Promise that resolve the Transaction object or null if the transaction can not be found. */ - async getDeployTransaction(address: string): Promise { + 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( @@ -195,7 +195,7 @@ export class StarkScanClient implements IDataClient { } } - throw new Error(`Deploy transaction not found`); + return null; } protected isDeployTransaction(tx: StarkScanTransaction): boolean { @@ -234,7 +234,7 @@ export class StarkScanClient implements IDataClient { timestamp: tx.timestamp, finalityStatus: tx.transaction_finality_status, executionStatus: tx.transaction_execution_status, - failureReason: tx.revert_error ?? undefined, + failureReason: tx.revert_error ?? '', maxFee: tx.max_fee, actualFee: tx.actual_fee, accountCalls: accountCalls, @@ -244,9 +244,9 @@ export class StarkScanClient implements IDataClient { protected toAccountCall( calls: StarkScanAccountCall[], - ): Record | undefined { + ): Record | null { if (!calls || calls.length === 0) { - return undefined; + return null; } return calls.reduce( diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index 0bb209d7..fa53bdf0 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -101,7 +101,7 @@ export type Transaction = { senderAddress: string; // in hex contractAddress: string; // in hex contractFuncName: string; - contractCallData: RawCalldata | string[]; + contractCallData: RawCalldata; status?: TransactionStatus | string; // TEMP: add TransactionFinalityStatus as optional to support the legacy data executionStatus?: TransactionStatus | string | TransactionFinalityStatus; @@ -111,13 +111,13 @@ export type Transaction = { // TEMP: add it as optional to support the legacy data eventIds?: string[]; timestamp: number; - // TEMP: put it as optional to support the legacy data - maxFee?: string; - // TEMP: put it as optional to support the legacy data - actualFee?: string; - // TEMP: put it as optional to support the legacy data + + // New fields + // TEMP: put those new fields as optional to support the legacy data + maxFee?: string | null; + actualFee?: string | null; // using Record to support O(1) searching - accountCalls?: Record; + accountCalls?: Record | null; }; /* eslint-disable */ From ed699ff8d9f4e26503e78bcfd15ef57145e7b55f Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:52:23 +0800 Subject: [PATCH 11/20] chore: re-structure starkscan type --- .../starknet-snap/src/chain/api-client.ts | 129 +++++++++++ .../src/chain/data-client/starkscan.test.ts | 8 +- .../src/chain/data-client/starkscan.ts | 218 +++++++++--------- .../src/chain/data-client/starkscan.type.ts | 46 ++++ 4 files changed, 287 insertions(+), 114 deletions(-) create mode 100644 packages/starknet-snap/src/chain/api-client.ts create mode 100644 packages/starknet-snap/src/chain/data-client/starkscan.type.ts 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..4bb2d4a8 --- /dev/null +++ b/packages/starknet-snap/src/chain/api-client.ts @@ -0,0 +1,129 @@ +import type { Json } from '@metamask/snaps-sdk'; +import { logger } from 'ethers'; +import type { Struct } from 'superstruct'; +import { mask } from 'superstruct'; + +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 getResponse( + 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 submit the API 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 submitHttpRequest({ + 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.getResponse(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/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index abcc3aab..06e3be27 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -9,8 +9,8 @@ import { STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, } from '../../utils/constants'; -import type { StarkScanOptions } from './starkscan'; -import { StarkScanClient, type StarkScanTransaction } from './starkscan'; +import type { StarkScanOptions, StarkScanTransaction } from './starkscan.type'; +import { StarkScanClient } from './starkscan'; describe('StarkScanClient', () => { class MockStarkScanClient extends StarkScanClient { @@ -22,8 +22,8 @@ describe('StarkScanClient', () => { return super.baseUrl; } - async get(url: string): Promise { - return super.get(url); + async submitGetApiRequest(request): Promise { + return await super.submitGetApiRequest(request); } } diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 12db0cb1..82d03af2 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -1,74 +1,23 @@ import { TransactionType, - type TransactionFinalityStatus, - type TransactionExecutionStatus, constants, } from 'starknet'; +import { Struct } from 'superstruct'; import type { Network, Transaction, TranscationAccountCall, } from '../../types/snapState'; +import { type StarkScanAccountCall, type StarkScanTransaction, type StarkScanOptions, StarkScanTransactionsResponseStruct, StarkScanTransactionsResponse } from './starkscan.type'; import type { IDataClient } from '../data-client'; +import { ApiClient, HttpHeaders, HttpMethod, HttpResponse } from '../api-client'; + +export class StarkScanClient extends ApiClient implements IDataClient { + apiClientName = 'StarkScanClient'; + + protected limit: number = 100; -/* eslint-disable */ -export type StarkScanTransaction = { - transaction_hash: string; - block_hash: string; - block_number: number; - transaction_index: number; - transaction_status: string; - transaction_finality_status: TransactionExecutionStatus; - transaction_execution_status: TransactionFinalityStatus; - transaction_type: TransactionType; - version: number; - signature: string[]; - max_fee: string; - actual_fee: string; - nonce: string; - contract_address: string | null; - entry_point_selector: string | null; - entry_point_type: string | null; - calldata: string[]; - class_hash: string | null; - sender_address: string | null; - constructor_calldata: string[] | null; - contract_address_salt: string | null; - timestamp: number; - entry_point_selector_name: string; - number_of_events: number; - revert_error: string | null; - account_calls: StarkScanAccountCall[]; -}; - -export type StarkScanAccountCall = { - block_hash: string; - block_number: number; - transaction_hash: string; - caller_address: string; - contract_address: string; - calldata: string[]; - result: string[]; - timestamp: number; - call_type: string; - class_hash: string; - selector: string; - entry_point_type: string; - selector_name: string; -}; - -export type StarkScanTransactionsResponse = { - next_url: string | null; - data: StarkScanTransaction[]; -}; - -export type StarkScanOptions = { - apiKey: string; -}; -/* eslint-enable */ - -export class StarkScanClient implements IDataClient { protected network: Network; protected options: StarkScanOptions; @@ -76,6 +25,7 @@ export class StarkScanClient implements IDataClient { protected deploySelectorName = 'constructor'; constructor(network: Network, options: StarkScanOptions) { + super(); this.network = network; this.options = options; } @@ -95,22 +45,42 @@ export class StarkScanClient implements IDataClient { return `${this.baseUrl}${endpoint}`; } - protected getCredential(): Record { + protected getHttpHeaders(): HttpHeaders { return { 'x-api-key': this.options.apiKey, }; } - protected async get(url: string): Promise { - const response = await fetch(url, { - method: 'GET', - headers: this.getCredential(), - }); - - if (!response.ok) { - throw new Error(`Failed to fetch data: ${response.statusText}`); + protected async getResponse( + response: HttpResponse, + ): Promise { + // For successful requests, Simplehash will return a 200 status code. + // Any other status code should be considered an error. + if (response.status !== 200) { + throw new Error(`API response error`); } - return response.json() as unknown as Resp; + + return await super.getResponse(response); + } + + protected async submitGetApiRequest({ + apiUrl, + responseStruct, + requestName, + }: { + apiUrl: string; + responseStruct: Struct; + requestName: string; + }): Promise { + return await super.submitHttpRequest({ + request: this.buildHttpRequest({ + method: HttpMethod.Get, + url: this.getApiUrl(apiUrl), + headers: this.getHttpHeaders(), + }), + responseStruct, + requestName, + }); } /** @@ -118,29 +88,32 @@ export class StarkScanClient implements IDataClient { * 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 tillTo - The timestamp to fetch the transactions until. + * @param to - The timestamp to fetch the transactions until. * @returns A Promise that resolve an array of Transaction object. */ async getTransactions( address: string, - tillTo: number, + to: number, ): Promise { - let apiUrl = this.getApiUrl( - `/transactions?contract_address=${address}&order_by=desc&limit=100`, - ); + 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; - // Fetch the transactions if: - // - the timestamp is greater than the `tillTo` AND + // 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 >= tillTo)) { + while (process && (timestamp === 0 || timestamp >= to)) { process = false; - const result = await this.get(apiUrl); + const result = await this.submitGetApiRequest({ + apiUrl, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions', + }); for (const data of result.data) { const tx = this.toTransaction(data); @@ -154,7 +127,7 @@ export class StarkScanClient implements IDataClient { // If the timestamp is smaller than the `tillTo` // We don't need those records // But if the record is an deploy transaction, we should include it to reduce the number of requests - if (timestamp >= tillTo || isDeployTx) { + if (timestamp >= to || isDeployTx) { txs.push(tx); } } @@ -165,7 +138,8 @@ export class StarkScanClient implements IDataClient { } } - // If the deploy transaction is not found from above traverse, we need to fetch it separately + // If no deploy transaction found, + // we scan the transactions in asc order by timestamp, as deploy transaction is usually the first transaction if (!deployTxFound) { const deployTx = await this.getDeployTransaction(address); deployTx && txs.push(deployTx); @@ -183,11 +157,13 @@ export class StarkScanClient implements IDataClient { 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 apiUrl = this.getApiUrl(`/transactions?contract_address=${address}&order_by=asc&limit=5`); - const result = await this.get(apiUrl); + const result = await this.submitGetApiRequest({ + apiUrl, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions' + }); for (const data of result.data) { if (this.isDeployTransaction(data)) { @@ -202,43 +178,65 @@ export class StarkScanClient implements IDataClient { return tx.transaction_type === TransactionType.DEPLOY_ACCOUNT; } - protected isFundTransferTransaction(call: StarkScanAccountCall): boolean { - return call.selector_name === 'transfer'; + protected isFundTransferTransaction(entrypoint: string): boolean { + return entrypoint === 'transfer'; } - protected toTransaction(tx: StarkScanTransaction): Transaction { - let sender = tx.sender_address ?? ''; + protected getContractAddress(tx: StarkScanTransaction): string { + // backfill the contract address if it is null + return tx.contract_address ?? ''; + } - // account_calls representing the calls to invoke from the account contract, it can be multiple - const accountCalls = this.toAccountCall(tx.account_calls); + protected getSenderAddress(tx: StarkScanTransaction): string { + let sender = tx.sender_address; - // eslint-disable-next-line no-negated-condition if (this.isDeployTransaction(tx)) { - // In case of deploy transaction, the contract address is the sender address + // if it is a deploy transaction, the contract address is the sender address sender = tx.contract_address as unknown as string; } - /* eslint-disable */ + // 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 + } = 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: tx.transaction_hash, - txnType: tx.transaction_type, + txnHash, + txnType, chainId: this.network.chainId, - senderAddress: sender, - - // In case of deploy transaction, the contract address is the sender address, else it will be empty string - contractAddress: tx.contract_address ?? '', - // TODO: when multiple calls are supported, we move this to accountCalls + senderAddress: this.getSenderAddress(tx), + timestamp, + finalityStatus, + executionStatus, + maxFee, + actualFee, + contractAddress: this.getContractAddress(tx), + accountCalls, + // the entry point selector name is moved to accountCalls contractFuncName: '', - // TODO: when multiple calls are supported, we move this to accountCalls - contractCallData: tx.calldata ?? [], - timestamp: tx.timestamp, - finalityStatus: tx.transaction_finality_status, - executionStatus: tx.transaction_execution_status, - failureReason: tx.revert_error ?? '', - maxFee: tx.max_fee, - actualFee: tx.actual_fee, - accountCalls: accountCalls, + // the account call data is moved to accountCalls + contractCallData: [], + failureReason: failureReason ?? '', }; + /* eslint-enable */ } @@ -270,7 +268,7 @@ export class StarkScanClient implements IDataClient { contractCallData, }; - if (this.isFundTransferTransaction(accountCallArg)) { + if (this.isFundTransferTransaction(contractFuncName)) { accountCall.recipient = accountCallArg.calldata[0]; accountCall.amount = accountCallArg.calldata[1]; } 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..44282e0a --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts @@ -0,0 +1,46 @@ +import { TransactionExecutionStatus, TransactionFinalityStatus, TransactionType } from "starknet"; +import { array, Infer, 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_name: 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, + +export type StarkScanOptions = { + apiKey: string, +} +/* eslint-enable */ \ No newline at end of file From 93adb3680d25a22b803803bb03e25af603f25238 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:56:01 +0800 Subject: [PATCH 12/20] chore: add test coverage --- .../starknet-snap/src/__tests__/helper.ts | 2 +- .../starknet-snap/src/chain/api-client.ts | 11 +- .../src/chain/data-client/starkscan.test.ts | 250 ++++++++++-------- .../src/chain/data-client/starkscan.ts | 91 +++---- .../src/chain/data-client/starkscan.type.ts | 61 +++-- packages/starknet-snap/src/types/snapState.ts | 33 ++- 6 files changed, 251 insertions(+), 197 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 33d5364e..8ef6580c 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -22,7 +22,7 @@ import { v4 as uuidv4 } from 'uuid'; import type { StarkScanTransaction, StarkScanTransactionsResponse, -} from '../chain/data-client/starkscan'; +} from '../chain/data-client/starkscan.type'; import { FeeToken } from '../types/snapApi'; import type { AccContract, diff --git a/packages/starknet-snap/src/chain/api-client.ts b/packages/starknet-snap/src/chain/api-client.ts index 4bb2d4a8..253af4fe 100644 --- a/packages/starknet-snap/src/chain/api-client.ts +++ b/packages/starknet-snap/src/chain/api-client.ts @@ -1,8 +1,9 @@ import type { Json } from '@metamask/snaps-sdk'; -import { logger } from 'ethers'; import type { Struct } from 'superstruct'; import { mask } from 'superstruct'; +import { logger } from '../utils/logger'; + export enum HttpMethod { Get = 'GET', Post = 'POST', @@ -31,7 +32,7 @@ export abstract class ApiClient { * @param response - The HTTP response to verify and convert. * @returns A promise that resolves to the API response. */ - protected async getResponse( + protected async parseResponse( response: HttpResponse, ): Promise { try { @@ -79,7 +80,7 @@ export abstract class ApiClient { } /** - * An internal method used to submit the API request. + * An internal method used to send a HTTP request. * * @param params - The request parameters. * @param [params.requestName] - The name of the request (optional). @@ -87,7 +88,7 @@ export abstract class ApiClient { * @param params.responseStruct - The superstruct used to verify the API response. * @returns A promise that resolves to a JSON object. */ - protected async submitHttpRequest({ + protected async sendHttpRequest({ requestName = '', request, responseStruct, @@ -109,7 +110,7 @@ export abstract class ApiClient { const httpResponse = await fetch(request.url, fetchRequest); - const jsonResponse = await this.getResponse(httpResponse); + const jsonResponse = await this.parseResponse(httpResponse); logger.debug(`${logPrefix} response:`, JSON.stringify(jsonResponse)); diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index 06e3be27..33fdfb0b 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -9,8 +9,16 @@ import { STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, } from '../../utils/constants'; -import type { StarkScanOptions, StarkScanTransaction } from './starkscan.type'; +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 { @@ -22,8 +30,12 @@ describe('StarkScanClient', () => { return super.baseUrl; } - async submitGetApiRequest(request): Promise { - return await super.submitGetApiRequest(request); + async sendApiRequest(request): Promise { + return await super.sendApiRequest(request); + } + + getSenderAddress(tx: StarkScanTransaction): string { + return super.getSenderAddress(tx); } } @@ -61,7 +73,36 @@ describe('StarkScanClient', () => { return account; }; - const mSecsFor24Hours = 1000 * 60 * 60 * 24; + 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 = generateStarkScanTranscations({ + address, + txnTypes: [txnType], + cnt: 1, + }); + const tx = mockResponse.data[0]; + return tx; + }; describe('baseUrl', () => { it.each([ @@ -84,7 +125,7 @@ describe('StarkScanClient', () => { }, ); - it('throws `Invalid Network` error if the chain id is invalid', () => { + it('throws `InvalidNetworkError` if the chain id is invalid', () => { const invalidNetwork: Network = { name: 'Invalid Network', chainId: '0x534e5f474f45524c49', @@ -97,30 +138,35 @@ describe('StarkScanClient', () => { network: invalidNetwork, }); - expect(() => client.baseUrl).toThrow('Invalid Network'); + expect(() => client.baseUrl).toThrow(InvalidNetworkError); }); }); - describe('get', () => { + describe('sendApiRequest', () => { + const mockRequest = () => { + return { + apiUrl: `/url`, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions', + }; + }; + it('fetches data', async () => { const { fetchSpy } = createMockFetch(); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ data: 'data' }), - }); + // 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.get(`${client.baseUrl}/url`); + const result = await client.sendApiRequest(mockRequest()); - expect(result).toStrictEqual({ data: 'data' }); + expect(result).toStrictEqual(expectedResponse); }); - it('append api key to header', async () => { + it('appends a api key to header', async () => { const { fetchSpy } = createMockFetch(); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ data: 'data' }), - }); + mockApiSuccess({ fetchSpy }); + const apiKey = 'ABCDEFG-API-KEY'; const client = createMockClient({ @@ -128,32 +174,32 @@ describe('StarkScanClient', () => { apiKey, }, }); - await client.get(`${client.baseUrl}/url`); + await client.sendApiRequest(mockRequest()); - expect(fetchSpy).toHaveBeenCalledWith(`${client.baseUrl}/url`, { + expect(fetchSpy).toHaveBeenCalledWith(`/url`, { method: 'GET', + body: undefined, headers: { + 'Content-Type': 'application/json', 'x-api-key': apiKey, }, }); }); - it('throws `Failed to fetch data` error if the response.ok is falsy', async () => { + it('throws `API response error: response body can not be deserialised.` error if the response.ok is falsy', async () => { const { fetchSpy } = createMockFetch(); - fetchSpy.mockResolvedValueOnce({ - ok: false, - statusText: 'error', - }); + mockApiFailure({ fetchSpy }); const client = createMockClient(); - - await expect(client.get(`${client.baseUrl}/url`)).rejects.toThrow( - `Failed to fetch data: error`, + 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; @@ -171,12 +217,8 @@ describe('StarkScanClient', () => { const mockResponse = generateStarkScanTranscations({ address: account.address, startFrom: from, - timestampReduction: mSecsFor24Hours, - }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), }); + mockApiSuccess({ fetchSpy, response: mockResponse }); const client = createMockClient(); const result = await client.getTransactions(account.address, to); @@ -200,26 +242,10 @@ describe('StarkScanClient', () => { const account = await mockAccount(); const { fetchSpy } = createMockFetch(); const { to } = getFromAndToTimestamp(5); - // generate 0 transactions - const mockInvokeResponse = generateStarkScanTranscations({ - address: account.address, - cnt: 0, - txnTypes: [TransactionType.INVOKE], - }); - // generate 0 transactions - const mockDeployResponse = generateStarkScanTranscations({ - address: account.address, - cnt: 0, - txnTypes: [TransactionType.INVOKE], - }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockInvokeResponse), - }); - fetchSpy.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockDeployResponse), - }); + // 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); @@ -232,81 +258,75 @@ describe('StarkScanClient', () => { const { fetchSpy } = createMockFetch(); // generate the to timestamp which is 100 days ago const { to } = getFromAndToTimestamp(100); - // generate 10 invoke transactions within 100 days if the timestamp is not provided const mockPage1Response = generateStarkScanTranscations({ address: account.address, txnTypes: [TransactionType.INVOKE], cnt: 10, }); - // generate another 10 invoke + deploy transactions within 100 days if the timestamp is not provided const mockPage2Response = generateStarkScanTranscations({ 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`; - const fetchOptions = { - method: 'GET', - headers: { - 'x-api-key': 'api-key', - }, - }; - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ + // 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, - }), - }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockPage2Response), + }, }); + // 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, fetchOptions); - expect(fetchSpy).toHaveBeenNthCalledWith(2, nextPageUrl, fetchOptions); + 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 + // generate 10 invoke transactions, and 1 day time gap between each transaction const mockInvokeResponse = generateStarkScanTranscations({ address: account.address, startFrom: from, timestampReduction: mSecsFor24Hours, txnTypes: [TransactionType.INVOKE], }); - // generate 5 invoke transactions + deploy transactions + // generate another 5 invoke transactions + deploy transactions for testing the fallback case const mockDeployResponse = generateStarkScanTranscations({ address: account.address, // generate transactions that start from 100 days ago, to ensure not overlap with above invoke transactions - startFrom: from - mSecsFor24Hours * 100, + startFrom: mSecsFor24Hours * 100, timestampReduction: mSecsFor24Hours, txnTypes: [TransactionType.INVOKE, TransactionType.DEPLOY_ACCOUNT], cnt: 5, }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockInvokeResponse), - }); - fetchSpy.mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(mockDeployResponse), - }); + 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); - // However the result should include a deploy transaction, even the deploy transaction is not in the last 5 days + // 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(); @@ -314,16 +334,6 @@ describe('StarkScanClient', () => { }); describe('toTransaction', () => { - const mockTxByType = (txnType: TransactionType, address: string) => { - const mockResponse = generateStarkScanTranscations({ - address, - txnTypes: [txnType], - cnt: 1, - }); - const tx = mockResponse.data[0]; - return tx; - }; - it('converts an invoke type starkscan transaction to a transaction object', async () => { const account = await mockAccount(); const mockTx = mockTxByType(TransactionType.INVOKE, account.address); @@ -343,8 +353,6 @@ describe('StarkScanClient', () => { chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, senderAddress: account.address, contractAddress: '', - contractFuncName: '', - contractCallData: mockTx.calldata, timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, executionStatus: mockTx.transaction_execution_status, @@ -362,6 +370,7 @@ describe('StarkScanClient', () => { }, ], }, + version: 'V2', }); }); @@ -381,8 +390,6 @@ describe('StarkScanClient', () => { chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, senderAddress: account.address, contractAddress: account.address, - contractFuncName: '', - contractCallData: [], timestamp: mockTx.timestamp, finalityStatus: mockTx.transaction_finality_status, executionStatus: mockTx.transaction_execution_status, @@ -390,6 +397,7 @@ describe('StarkScanClient', () => { maxFee: mockTx.max_fee, actualFee: mockTx.actual_fee, accountCalls: null, + version: 'V2', }); }); }); @@ -403,10 +411,7 @@ describe('StarkScanClient', () => { address: account.address, cnt: 5, }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); + mockApiSuccess({ fetchSpy, response: mockResponse }); const client = createMockClient(); const result = await client.getDeployTransaction(account.address); @@ -424,10 +429,7 @@ describe('StarkScanClient', () => { cnt: 1, txnTypes: [TransactionType.INVOKE], }); - fetchSpy.mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue(mockResponse), - }); + mockApiSuccess({ fetchSpy, response: mockResponse }); const client = createMockClient(); const result = await client.getDeployTransaction(account.address); @@ -435,4 +437,42 @@ describe('StarkScanClient', () => { 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, + 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 index 82d03af2..98eab071 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -1,22 +1,27 @@ -import { - TransactionType, - constants, -} from 'starknet'; -import { Struct } from 'superstruct'; +import { TransactionType, constants } from 'starknet'; +import type { Struct } from 'superstruct'; import type { Network, Transaction, TranscationAccountCall, } from '../../types/snapState'; -import { type StarkScanAccountCall, type StarkScanTransaction, type StarkScanOptions, StarkScanTransactionsResponseStruct, StarkScanTransactionsResponse } from './starkscan.type'; +import { InvalidNetworkError } from '../../utils/exceptions'; +import type { HttpHeaders } from '../api-client'; +import { ApiClient, HttpMethod } from '../api-client'; import type { IDataClient } from '../data-client'; -import { ApiClient, HttpHeaders, HttpMethod, HttpResponse } from '../api-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: number = 100; + protected limit = 100; protected network: Network; @@ -37,7 +42,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { case constants.StarknetChainId.SN_MAIN: return 'https://api.starkscan.co/api/v0'; default: - throw new Error(`Invalid Network`); + throw new InvalidNetworkError(); } } @@ -51,19 +56,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { }; } - protected async getResponse( - response: HttpResponse, - ): Promise { - // For successful requests, Simplehash will return a 200 status code. - // Any other status code should be considered an error. - if (response.status !== 200) { - throw new Error(`API response error`); - } - - return await super.getResponse(response); - } - - protected async submitGetApiRequest({ + protected async sendApiRequest({ apiUrl, responseStruct, requestName, @@ -72,10 +65,10 @@ export class StarkScanClient extends ApiClient implements IDataClient { responseStruct: Struct; requestName: string; }): Promise { - return await super.submitHttpRequest({ + return await super.sendHttpRequest({ request: this.buildHttpRequest({ method: HttpMethod.Get, - url: this.getApiUrl(apiUrl), + url: apiUrl, headers: this.getHttpHeaders(), }), responseStruct, @@ -91,11 +84,10 @@ export class StarkScanClient extends ApiClient implements IDataClient { * @param to - The timestamp to fetch the transactions until. * @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}`); + 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; @@ -109,7 +101,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { while (process && (timestamp === 0 || timestamp >= to)) { process = false; - const result = await this.submitGetApiRequest({ + const result = await this.sendApiRequest({ apiUrl, responseStruct: StarkScanTransactionsResponseStruct, requestName: 'getTransactions', @@ -124,9 +116,15 @@ export class StarkScanClient extends ApiClient implements IDataClient { } timestamp = tx.timestamp; - // If the timestamp is smaller than the `tillTo` - // We don't need those records - // But if the record is an deploy transaction, we should include it to reduce the number of requests + // 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); } @@ -138,8 +136,9 @@ export class StarkScanClient extends ApiClient implements IDataClient { } } - // If no deploy transaction found, - // we scan the transactions in asc order by timestamp, as deploy transaction is usually the first transaction + // 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); @@ -157,12 +156,14 @@ export class StarkScanClient extends ApiClient implements IDataClient { 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 apiUrl = this.getApiUrl( + `/transactions?contract_address=${address}&order_by=asc&limit=5`, + ); - const result = await this.submitGetApiRequest({ + const result = await this.sendApiRequest({ apiUrl, responseStruct: StarkScanTransactionsResponseStruct, - requestName: 'getTransactions' + requestName: 'getTransactions', }); for (const data of result.data) { @@ -201,7 +202,6 @@ export class StarkScanClient extends ApiClient implements IDataClient { protected toTransaction(tx: StarkScanTransaction): Transaction { /* eslint-disable @typescript-eslint/naming-convention */ - const { transaction_hash: txnHash, transaction_type: txnType, @@ -211,7 +211,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { max_fee: maxFee, actual_fee: actualFee, revert_error: failureReason, - account_calls: calls + account_calls: calls, } = tx; // account_calls representing the calls to invoke from the account contract, it can be multiple @@ -230,24 +230,21 @@ export class StarkScanClient extends ApiClient implements IDataClient { actualFee, contractAddress: this.getContractAddress(tx), accountCalls, - // the entry point selector name is moved to accountCalls - contractFuncName: '', - // the account call data is moved to accountCalls - contractCallData: [], failureReason: failureReason ?? '', + version: 'V2', }; /* eslint-enable */ } protected toAccountCall( - calls: StarkScanAccountCall[], + accountCalls: StarkScanAccountCall[], ): Record | null { - if (!calls || calls.length === 0) { + if (!accountCalls || accountCalls.length === 0) { return null; } - return calls.reduce( + return accountCalls.reduce( ( data: Record, accountCallArg: StarkScanAccountCall, diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.type.ts b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts index 44282e0a..a54272d4 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.type.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts @@ -1,32 +1,39 @@ -import { TransactionExecutionStatus, TransactionFinalityStatus, TransactionType } from "starknet"; -import { array, Infer, nullable, number, object, string, enums } from "superstruct"; +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_name: string(), + contract_address: string(), + calldata: array(string()), + selector_name: 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), + 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; @@ -34,13 +41,15 @@ export type StarkScanAccountCall = Infer; export type StarkScanTransaction = Infer; export const StarkScanTransactionsResponseStruct = object({ - next_url: nullable(string()), - data: array(StarkScanTransactionStruct) + next_url: nullable(string()), + data: array(StarkScanTransactionStruct), }); -export type StarkScanTransactionsResponse = Infer, +export type StarkScanTransactionsResponse = Infer< + typeof StarkScanTransactionsResponseStruct +>; export type StarkScanOptions = { - apiKey: string, -} -/* eslint-enable */ \ No newline at end of file + apiKey: string; +}; +/* eslint-enable */ diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index f5202b56..8f163df8 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -127,32 +127,39 @@ export type TranscationAccountCall = { amount?: string; }; -export type Transaction = { +export type LegacyTransaction = { txnHash: string; // in hex - // TEMP: add StarkNetTransactionType as optional to support the legacy data - txnType: VoyagerTransactionType | string | StarkNetTransactionType; + 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; contractCallData: RawCalldata; status?: TransactionStatus | string; - // TEMP: add TransactionFinalityStatus as optional to support the legacy data - executionStatus?: TransactionStatus | string | TransactionFinalityStatus; - // TEMP: add TransactionExecutionStatus as optional to support the legacy data - finalityStatus?: TransactionStatus | string | TransactionExecutionStatus; - failureReason?: string; - // TEMP: add it as optional to support the legacy data - eventIds?: string[]; + executionStatus?: TransactionStatus | string; + finalityStatus?: TransactionStatus | string; + failureReason: string; + eventIds: string[]; timestamp: number; +}; - // New fields - // TEMP: put those new fields as optional to support the legacy data +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: 'V2'; }; +export type Transaction = LegacyTransaction | V2Transaction; + /* eslint-disable */ From dadb4935be0ac7184ef9a5169b41bac68c77b6d5 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 16:32:10 +0800 Subject: [PATCH 13/20] chore: factory and config --- packages/starknet-snap/.env.example | 3 +++ .../src/chain/data-client/starkscan.test.ts | 1 + packages/starknet-snap/src/config.ts | 16 ++++++++++++ .../starknet-snap/src/utils/factory.test.ts | 22 ++++++++++++++++ packages/starknet-snap/src/utils/factory.ts | 25 +++++++++++++++++++ 5 files changed, 67 insertions(+) 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/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index 33fdfb0b..24dd6302 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -470,6 +470,7 @@ describe('StarkScanClient', () => { expect( client.getSenderAddress({ ...mockTx, + // eslint-disable-next-line @typescript-eslint/naming-convention sender_address: null, }), ).toBe(''); 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/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 b21a5d28a8529493f93a8f8e43ade7190a34e5ca Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:00:21 +0800 Subject: [PATCH 14/20] chore: add backward compatibility for transactions type --- packages/starknet-snap/src/types/snapState.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index 8f163df8..b66841fd 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -160,6 +160,7 @@ export type V2Transaction = { version: 'V2'; }; -export type Transaction = LegacyTransaction | V2Transaction; +// for backward compatibility before StarkScan implmented in get transactions +export type Transaction = LegacyTransaction | (V2Transaction & { status?: TransactionStatus | string }); /* eslint-disable */ From 07f0232142f2502d1fc29bc1583c3ece3210723b Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:01:17 +0800 Subject: [PATCH 15/20] chore: add comment --- packages/starknet-snap/src/types/snapState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index b66841fd..d400e5a0 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -160,7 +160,7 @@ export type V2Transaction = { version: 'V2'; }; -// for backward compatibility before StarkScan implmented in get transactions +// FIXME: temp solution for backward compatibility before StarkScan implemented in get transactions export type Transaction = LegacyTransaction | (V2Transaction & { status?: TransactionStatus | string }); /* eslint-disable */ From 7a26c70d98683de150c27f775ff053fc1b4279d9 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:02:03 +0800 Subject: [PATCH 16/20] chore: lint --- packages/starknet-snap/src/types/snapState.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index d400e5a0..ea6cbf1b 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -161,6 +161,8 @@ export type V2Transaction = { }; // FIXME: temp solution for backward compatibility before StarkScan implemented in get transactions -export type Transaction = LegacyTransaction | (V2Transaction & { status?: TransactionStatus | string }); +export type Transaction = + | LegacyTransaction + | (V2Transaction & { status?: TransactionStatus | string }); /* eslint-disable */ From 804a2bd567f51ecc5d9536e5bd1755836b83364b Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:19:14 +0800 Subject: [PATCH 17/20] chore: resolve review comment --- .../starknet-snap/src/__tests__/helper.ts | 2 +- .../src/chain/data-client/starkscan.test.ts | 24 ++++++++++--------- .../src/chain/data-client/starkscan.ts | 6 +++-- packages/starknet-snap/src/types/snapState.ts | 4 +++- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 8ef6580c..31bbf1f9 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -380,7 +380,7 @@ export function generateTransactionRequests({ * @param params.cnt - Number of transaction to generate. * @returns An array of transaction object. */ -export function generateStarkScanTranscations({ +export function generateStarkScanTransactions({ address, startFrom = Date.now(), timestampReduction = 100, diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index 24dd6302..61594dc4 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -2,7 +2,7 @@ import { TransactionType, constants } from 'starknet'; import { generateAccounts, - generateStarkScanTranscations, + generateStarkScanTransactions, } from '../../__tests__/helper'; import type { Network, Transaction } from '../../types/snapState'; import { @@ -95,7 +95,7 @@ describe('StarkScanClient', () => { }; const mockTxByType = (txnType: TransactionType, address: string) => { - const mockResponse = generateStarkScanTranscations({ + const mockResponse = generateStarkScanTransactions({ address, txnTypes: [txnType], cnt: 1, @@ -214,7 +214,7 @@ describe('StarkScanClient', () => { const { fetchSpy } = createMockFetch(); const { from, to } = getFromAndToTimestamp(5); // generate 10 invoke transactions - const mockResponse = generateStarkScanTranscations({ + const mockResponse = generateStarkScanTransactions({ address: account.address, startFrom: from, }); @@ -258,12 +258,12 @@ describe('StarkScanClient', () => { const { fetchSpy } = createMockFetch(); // generate the to timestamp which is 100 days ago const { to } = getFromAndToTimestamp(100); - const mockPage1Response = generateStarkScanTranscations({ + const mockPage1Response = generateStarkScanTransactions({ address: account.address, txnTypes: [TransactionType.INVOKE], cnt: 10, }); - const mockPage2Response = generateStarkScanTranscations({ + const mockPage2Response = generateStarkScanTransactions({ address: account.address, cnt: 10, }); @@ -304,14 +304,14 @@ describe('StarkScanClient', () => { // 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 = generateStarkScanTranscations({ + 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 = generateStarkScanTranscations({ + 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, @@ -370,7 +370,8 @@ describe('StarkScanClient', () => { }, ], }, - version: 'V2', + version: mockTx.version, + dataVersion: 'V2', }); }); @@ -397,7 +398,8 @@ describe('StarkScanClient', () => { maxFee: mockTx.max_fee, actualFee: mockTx.actual_fee, accountCalls: null, - version: 'V2', + version: mockTx.version, + dataVersion: 'V2', }); }); }); @@ -407,7 +409,7 @@ describe('StarkScanClient', () => { const account = await mockAccount(); const { fetchSpy } = createMockFetch(); // generate 5 invoke transactions with deploy transaction - const mockResponse = generateStarkScanTranscations({ + const mockResponse = generateStarkScanTransactions({ address: account.address, cnt: 5, }); @@ -424,7 +426,7 @@ describe('StarkScanClient', () => { const account = await mockAccount(); const { fetchSpy } = createMockFetch(); // generate 5 invoke transactions with deploy transaction - const mockResponse = generateStarkScanTranscations({ + const mockResponse = generateStarkScanTransactions({ address: account.address, cnt: 1, txnTypes: [TransactionType.INVOKE], diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 98eab071..d1dac1e6 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -81,7 +81,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { * 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 timestamp to fetch the transactions until. + * @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 { @@ -212,6 +212,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { 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 @@ -231,7 +232,8 @@ export class StarkScanClient extends ApiClient implements IDataClient { contractAddress: this.getContractAddress(tx), accountCalls, failureReason: failureReason ?? '', - version: 'V2', + version, + dataVersion: 'V2', }; /* eslint-enable */ diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index ea6cbf1b..5be52899 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -157,7 +157,9 @@ export type V2Transaction = { actualFee?: string | null; // using Record to support O(1) searching accountCalls?: Record | null; - version: 'V2'; + version: number; + // Snap data Version to support backward compatibility , migration. + dataVersion: 'V2'; }; // FIXME: temp solution for backward compatibility before StarkScan implemented in get transactions From 8e9e16320511235c2d779ec94679dea9fac57b31 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 4 Dec 2024 20:38:47 +0800 Subject: [PATCH 18/20] chore: change dataVersion to enum --- .../starknet-snap/src/chain/data-client/starkscan.test.ts | 6 +++--- packages/starknet-snap/src/types/snapState.ts | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index 61594dc4..f998cacd 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -4,7 +4,7 @@ import { generateAccounts, generateStarkScanTransactions, } from '../../__tests__/helper'; -import type { Network, Transaction } from '../../types/snapState'; +import { TransactionDataVersion, type Network, type Transaction } from '../../types/snapState'; import { STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, @@ -371,7 +371,7 @@ describe('StarkScanClient', () => { ], }, version: mockTx.version, - dataVersion: 'V2', + dataVersion: TransactionDataVersion.V2, }); }); @@ -399,7 +399,7 @@ describe('StarkScanClient', () => { actualFee: mockTx.actual_fee, accountCalls: null, version: mockTx.version, - dataVersion: 'V2', + dataVersion: TransactionDataVersion.V2, }); }); }); diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index 5be52899..24a7e3b8 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -143,6 +143,10 @@ export type LegacyTransaction = { timestamp: number; }; +export enum TransactionDataVersion { + V2='V2' +} + export type V2Transaction = { txnHash: string; // in hex txnType: StarkNetTransactionType; @@ -159,7 +163,7 @@ export type V2Transaction = { accountCalls?: Record | null; version: number; // Snap data Version to support backward compatibility , migration. - dataVersion: 'V2'; + dataVersion: TransactionDataVersion.V2; }; // FIXME: temp solution for backward compatibility before StarkScan implemented in get transactions From b09361f6cf8b7647a50b10fc482ec130db9f291e Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 4 Dec 2024 20:40:12 +0800 Subject: [PATCH 19/20] chore: lint --- .../src/chain/data-client/starkscan.test.ts | 6 +++++- .../starknet-snap/src/chain/data-client/starkscan.ts | 11 ++++++----- packages/starknet-snap/src/types/snapState.ts | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index f998cacd..b8d8eee4 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -4,7 +4,11 @@ import { generateAccounts, generateStarkScanTransactions, } from '../../__tests__/helper'; -import { TransactionDataVersion, type Network, type Transaction } from '../../types/snapState'; +import { + TransactionDataVersion, + type Network, + type Transaction, +} from '../../types/snapState'; import { STARKNET_MAINNET_NETWORK, STARKNET_SEPOLIA_TESTNET_NETWORK, diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index d1dac1e6..03942192 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -1,10 +1,11 @@ import { TransactionType, constants } from 'starknet'; import type { Struct } from 'superstruct'; -import type { - Network, - Transaction, - TranscationAccountCall, +import { + TransactionDataVersion, + type Network, + type Transaction, + type TranscationAccountCall, } from '../../types/snapState'; import { InvalidNetworkError } from '../../utils/exceptions'; import type { HttpHeaders } from '../api-client'; @@ -233,7 +234,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { accountCalls, failureReason: failureReason ?? '', version, - dataVersion: 'V2', + dataVersion: TransactionDataVersion.V2, }; /* eslint-enable */ diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index 24a7e3b8..041c4652 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -144,7 +144,7 @@ export type LegacyTransaction = { }; export enum TransactionDataVersion { - V2='V2' + V2 = 'V2', } export type V2Transaction = { From 63e3030e10f5c1fb89828ada70c6979e13e1c06c Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Dec 2024 14:52:39 +0800 Subject: [PATCH 20/20] chore: update starkscan to handle missing selector_name --- .../src/chain/data-client/starkscan.test.ts | 10 ++++---- .../src/chain/data-client/starkscan.ts | 23 ++++++++++++++++--- .../src/chain/data-client/starkscan.type.ts | 2 +- packages/starknet-snap/src/types/snapState.ts | 5 ++++ 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts index b8d8eee4..e4b24647 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -5,6 +5,7 @@ import { generateStarkScanTransactions, } from '../../__tests__/helper'; import { + ContractFuncName, TransactionDataVersion, type Network, type Transaction, @@ -345,11 +346,8 @@ describe('StarkScanClient', () => { const client = createMockClient(); const result = client.toTransaction(mockTx); - const { - contract_address: contract, - selector_name: contractFuncName, - calldata: contractCallData, - } = mockTx.account_calls[0]; + const { contract_address: contract, calldata: contractCallData } = + mockTx.account_calls[0]; expect(result).toStrictEqual({ txnHash: mockTx.transaction_hash, @@ -367,7 +365,7 @@ describe('StarkScanClient', () => { [contract]: [ { contract, - contractFuncName, + contractFuncName: ContractFuncName.Transfer, contractCallData, recipient: contractCallData[0], amount: contractCallData[1], diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 03942192..8935c80f 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -2,11 +2,16 @@ 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'; @@ -181,7 +186,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { } protected isFundTransferTransaction(entrypoint: string): boolean { - return entrypoint === 'transfer'; + return entrypoint === TRANSFER_SELECTOR_HEX; } protected getContractAddress(tx: StarkScanTransaction): string { @@ -254,10 +259,11 @@ export class StarkScanClient extends ApiClient implements IDataClient { ) => { const { contract_address: contract, - selector_name: contractFuncName, + selector, calldata: contractCallData, } = accountCallArg; + const contractFuncName = this.selectorHexToName(selector); if (!Object.prototype.hasOwnProperty.call(data, contract)) { data[contract] = []; } @@ -268,7 +274,7 @@ export class StarkScanClient extends ApiClient implements IDataClient { contractCallData, }; - if (this.isFundTransferTransaction(contractFuncName)) { + if (this.isFundTransferTransaction(selector)) { accountCall.recipient = accountCallArg.calldata[0]; accountCall.amount = accountCallArg.calldata[1]; } @@ -280,4 +286,15 @@ export class StarkScanClient extends ApiClient implements IDataClient { {}, ); } + + 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 index a54272d4..c15972ea 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.type.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts @@ -13,7 +13,7 @@ const NullableStringArrayStruct = nullable(array(string())); export const StarkScanAccountCallStruct = object({ contract_address: string(), calldata: array(string()), - selector_name: string(), + selector: string(), }); export const StarkScanTransactionStruct = object({ diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index 041c4652..d139df1f 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -147,6 +147,11 @@ export enum TransactionDataVersion { V2 = 'V2', } +export enum ContractFuncName { + Upgrade = 'upgrade', + Transfer = 'transfer', +} + export type V2Transaction = { txnHash: string; // in hex txnType: StarkNetTransactionType;