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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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 9c2796d2cb960862943ef14761c33a92664a372f Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Sep 2024 13:18:38 +0800 Subject: [PATCH 07/13] chore: update tx state mgr --- .../starknet-snap/src/__tests__/helper.ts | 35 ++++++++++++++----- .../state/transaction-state-manager.test.ts | 22 +++++++----- .../src/state/transaction-state-manager.ts | 30 ++++++++++------ 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index ad552945..cf567269 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -171,7 +171,7 @@ export function generateTransactions({ finalityStatuses = Object.values(TransactionFinalityStatus), executionStatuses = Object.values(TransactionExecutionStatus), // The timestamp from data source is in seconds - timestamp = Math.floor(Date.now() / 1000), + timestamp = Date.now(), cnt = 1, }: { chainId: constants.StarknetChainId; @@ -197,6 +197,7 @@ export function generateTransactions({ executionStatus: '', finalityStatus: '', eventIds: [], + accountCalls: undefined }; let accumulatedTimestamp = timestamp; let accumulatedTxnHash = BigInt( @@ -237,19 +238,19 @@ export function generateTransactions({ for (let i = 1; i <= createCnt; i++) { const randomContractAddress = contractAddresses[ - Math.floor(generateRandomValue() * contractAddresses.length) + Math.floor(generateRandomValue() * contractAddresses.length) ]; const randomTxnType = filteredTxnTypes[ - Math.floor(generateRandomValue() * filteredTxnTypes.length) + Math.floor(generateRandomValue() * filteredTxnTypes.length) ]; let randomFinalityStatus = finalityStatuses[ - Math.floor(generateRandomValue() * finalityStatuses.length) + Math.floor(generateRandomValue() * finalityStatuses.length) ]; let randomExecutionStatus = executionStatuses[ - Math.floor(generateRandomValue() * executionStatuses.length) + Math.floor(generateRandomValue() * executionStatuses.length) ]; let randomContractFuncName = ['transfer', 'upgrade'][ Math.floor(generateRandomValue() * 2) @@ -276,15 +277,31 @@ export function generateTransactions({ transactions.push({ ...transaction, - contractAddress: randomContractAddress, + contractAddress: '', txnType: randomTxnType, finalityStatus: randomFinalityStatus, executionStatus: randomExecutionStatus, timestamp: accumulatedTimestamp, - contractFuncName: - randomTxnType === TransactionType.INVOKE ? randomContractFuncName : '', + // TODO: when multiple calls are supported, we move this to accountCalls, we keep it for legacy support + contractFuncName: '', + // TODO: when multiple calls are supported, we move this to accountCalls, we keep it for legacy support + contractCallData: [], txnHash: '0x' + accumulatedTxnHash.toString(16), - }); + accountCalls: { + [randomContractAddress]: [ + { + contract: randomContractAddress, + contractFuncName: randomTxnType === TransactionType.INVOKE ? randomContractFuncName : '', + contractCallData: [ + randomContractAddress, + (Math.max(generateRandomValue() * 1000, 100)).toString(16), + "0x0" + ], + }, + ], + }, + } + ); } return transactions.sort((a, b) => b.timestamp - a.timestamp); diff --git a/packages/starknet-snap/src/state/transaction-state-manager.test.ts b/packages/starknet-snap/src/state/transaction-state-manager.test.ts index b67344b4..742edd0d 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.test.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.test.ts @@ -144,8 +144,12 @@ describe('TransactionStateManager', () => { expect(result).toStrictEqual( txns.filter( (txn) => - txn.contractAddress === tokenAddress1 || - txn.contractAddress === tokenAddress2, + txn.accountCalls && + Object.keys(txn.accountCalls).some( + (contractAddress: string) => + contractAddress === tokenAddress1 || + contractAddress === tokenAddress2 + ) ), ); }); @@ -154,8 +158,7 @@ describe('TransactionStateManager', () => { const { txns, stateManager } = await prepareFindTransctions(); const result = await stateManager.findTransactions({ - // The timestamp from data source is in seconds, but we are comparing it in milliseconds - timestamp: txns[5].timestamp * 1000, + timestamp: txns[5].timestamp, }); expect(result).toStrictEqual( @@ -214,7 +217,7 @@ describe('TransactionStateManager', () => { const contractAddressCond = [ PRELOADED_TOKENS.map((token) => token.address)[0], ]; - const timestampCond = txns[5].timestamp * 1000; + const timestampCond = txns[5].timestamp; const chainIdCond = [ txns[0].chainId as unknown as constants.StarknetChainId, ]; @@ -238,7 +241,10 @@ describe('TransactionStateManager', () => { txn.executionStatus as unknown as TransactionExecutionStatus, )) && txn.timestamp >= txns[5].timestamp && - contractAddressCond.includes(txn.contractAddress) && + txn.accountCalls && + Object.keys(txn.accountCalls).some( + (contractAddress: string) => contractAddressCond.includes(contractAddress) + ) && chainIdCond.includes( txn.chainId as unknown as constants.StarknetChainId, ) && @@ -270,7 +276,7 @@ describe('TransactionStateManager', () => { ...txn, executionStatus: TransactionExecutionStatus.REJECTED, finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, - timestamp: Math.floor(Date.now() / 1000), + timestamp: Date.now(), }; await stateManager.updateTransaction(updatedEntity); @@ -289,7 +295,7 @@ describe('TransactionStateManager', () => { const txn = txns[2]; const updatedEntity = { ...txn, - timestamp: Math.floor(Date.now() / 1000), + timestamp: Date.now(), txnHash: '0x123', }; diff --git a/packages/starknet-snap/src/state/transaction-state-manager.ts b/packages/starknet-snap/src/state/transaction-state-manager.ts index a4805bb9..d11219ec 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.ts @@ -3,9 +3,9 @@ import { TransactionFinalityStatus, TransactionExecutionStatus, } from 'starknet'; -import { assert, enums, number } from 'superstruct'; +import { assert, enums, number, string } from 'superstruct'; -import type { Transaction, SnapState } from '../types/snapState'; +import type { Transaction, SnapState, TranscationAccountCall } from '../types/snapState'; import { TransactionStatusType } from '../types/snapState'; import type { IFilter } from './filter'; import { @@ -13,6 +13,7 @@ import { ChainIdFilter as BaseChainIdFilter, StringFllter, Filter, + MultiFilter, } from './filter'; import { StateManager, StateManagerError } from './state-manager'; @@ -23,11 +24,23 @@ export class ChainIdFilter implements ITxFilter {} export class ContractAddressFilter - extends BigIntFilter - implements ITxFilter -{ - dataKey = 'contractAddress'; + extends MultiFilter< + string, + bigint, + Transaction +> { + protected _prepareSearch(search: string[]): void { + this.search = new Set(search?.map((val) => BigInt(val))); + } + + protected _apply(data: Transaction): boolean { + if (!data.accountCalls) { + return false; + } + return Object.keys(data.accountCalls).some( (contractAddress:string) => this.search.has(BigInt(contractAddress))); + } } + export class SenderAddressFilter extends BigIntFilter implements ITxFilter @@ -47,10 +60,7 @@ export class TxTimestampFilter implements ITxFilter { _apply(data: Transaction): boolean { - // The timestamp from the data source is in seconds, but we are comparing it in milliseconds - // e.g if the search is 1630000000, it means we return the txns where the timestamp is greater than or equal to 1630000000 * 1000 - // example use case: search for txns for the last 7 days, the search will be Date.now() - 7 * 24 * 60 * 60 * 1000 - return this.search !== undefined && data.timestamp * 1000 >= this.search; + return this.search !== undefined && data.timestamp >= this.search; } } From edde40e4cf8e48926e2600f02e4502d0e00e3bfe Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Sep 2024 13:52:02 +0800 Subject: [PATCH 08/13] fix: lint --- .../src/state/transaction-state-manager.test.ts | 14 +++++++------- .../src/state/transaction-state-manager.ts | 11 ++++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/starknet-snap/src/state/transaction-state-manager.test.ts b/packages/starknet-snap/src/state/transaction-state-manager.test.ts index 742edd0d..2e5284d2 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.test.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.test.ts @@ -144,12 +144,12 @@ describe('TransactionStateManager', () => { expect(result).toStrictEqual( txns.filter( (txn) => - txn.accountCalls && + txn.accountCalls && Object.keys(txn.accountCalls).some( - (contractAddress: string) => + (contractAddress: string) => contractAddress === tokenAddress1 || - contractAddress === tokenAddress2 - ) + contractAddress === tokenAddress2, + ), ), ); }); @@ -241,9 +241,9 @@ describe('TransactionStateManager', () => { txn.executionStatus as unknown as TransactionExecutionStatus, )) && txn.timestamp >= txns[5].timestamp && - txn.accountCalls && - Object.keys(txn.accountCalls).some( - (contractAddress: string) => contractAddressCond.includes(contractAddress) + txn.accountCalls && + Object.keys(txn.accountCalls).some((contractAddress: string) => + contractAddressCond.includes(contractAddress), ) && chainIdCond.includes( txn.chainId as unknown as constants.StarknetChainId, diff --git a/packages/starknet-snap/src/state/transaction-state-manager.ts b/packages/starknet-snap/src/state/transaction-state-manager.ts index d11219ec..81ac0b35 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.ts @@ -3,9 +3,9 @@ import { TransactionFinalityStatus, TransactionExecutionStatus, } from 'starknet'; -import { assert, enums, number, string } from 'superstruct'; +import { assert, enums, number } from 'superstruct'; -import type { Transaction, SnapState, TranscationAccountCall } from '../types/snapState'; +import type { Transaction, SnapState } from '../types/snapState'; import { TransactionStatusType } from '../types/snapState'; import type { IFilter } from './filter'; import { @@ -23,8 +23,7 @@ export class ChainIdFilter extends BaseChainIdFilter implements ITxFilter {} -export class ContractAddressFilter - extends MultiFilter< +export class ContractAddressFilter extends MultiFilter< string, bigint, Transaction @@ -37,7 +36,9 @@ export class ContractAddressFilter if (!data.accountCalls) { return false; } - return Object.keys(data.accountCalls).some( (contractAddress:string) => this.search.has(BigInt(contractAddress))); + return Object.keys(data.accountCalls).some((contractAddress: string) => + this.search.has(BigInt(contractAddress)), + ); } } From b53a7c98261e753b4d2bd09c63862441010984db Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Sep 2024 14:04:57 +0800 Subject: [PATCH 09/13] chore: lint --- .../starknet-snap/src/__tests__/helper.ts | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index cf567269..bf1dd52f 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -35,8 +35,6 @@ export type StarknetAccount = AccContract & { privateKey: string; }; -/* eslint-disable */ - /** * Using pseudorandom number generators (PRNGs) to generate a security-sensitive random value that recommended by sonarcloud. * It has led in the past to the following vulnerabilities: @@ -61,10 +59,7 @@ export function generateRandomValue() { * @param coinType - The coin type of the bip44, default is 9004 - Starknet Coin. * @returns An Bip44 Node. */ -export async function generateBip44Entropy( - mnemonic: string, - coinType: number = 9004, -) { +export async function generateBip44Entropy(mnemonic: string, coinType = 9004) { return await BIP44CoinTypeNode.fromDerivationPath([ `bip39:${mnemonic}`, "bip32:44'", @@ -78,11 +73,12 @@ export async function generateBip44Entropy( * @param network - Starknet Chain Id. * @param cnt - Number of accounts to generate. * @param cairoVersion - Cairo version of the generated accounts. + * @param [mnemonic] - The random mnemonic of the wallet. * @returns An array of StarknetAccount object. */ export async function generateAccounts( network: constants.StarknetChainId, - cnt: number = 1, + cnt = 1, cairoVersion = '1', mnemonic?: string, ) { @@ -140,7 +136,7 @@ export async function generateAccounts( addressSalt: pubKey, privateKey: numUtils.toHex(addressKey), publicKey: pubKey, - address: address, + address, addressIndex: i, derivationPath: keyDeriver.path, deployTxnHash: '', @@ -155,12 +151,13 @@ export async function generateAccounts( * * @param params * @param params.chainId - Starknet Chain Id. - * @param params.address - Address of the account. - * @param params.contractAddresses - Contract addresses to generate transactions. - * @param params.txnTypes - Array of transaction types. - * @param params.finalityStatuses - Array of transaction finality status. - * @param params.executionStatuses - Array of transaction execution status. - * @param params.cnt - Number of transaction to generate. + * @param [params.address] - Address of the account. + * @param [params.contractAddresses] - Contract addresses to generate transactions. + * @param [params.txnTypes] - Array of transaction types. + * @param [params.finalityStatuses] - Array of transaction finality status. + * @param [params.executionStatuses] - Array of transaction execution status. + * @param [params.cnt] - Number of transaction to generate. + * @param [params.timestamp] - The timestamp of the transaction. * @returns An array of transaction object. */ export function generateTransactions({ @@ -184,12 +181,12 @@ export function generateTransactions({ cnt?: number; }): Transaction[] { const transaction = { - chainId: chainId, + chainId, contractAddress: '', contractCallData: [], contractFuncName: '', senderAddress: address, - timestamp: timestamp, + timestamp, txnHash: '', txnType: '', failureReason: '', @@ -197,7 +194,7 @@ export function generateTransactions({ executionStatus: '', finalityStatus: '', eventIds: [], - accountCalls: undefined + accountCalls: undefined, }; let accumulatedTimestamp = timestamp; let accumulatedTxnHash = BigInt( @@ -220,7 +217,7 @@ export function generateTransactions({ finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, executionStatus: TransactionExecutionStatus.SUCCEEDED, timestamp: accumulatedTimestamp, - txnHash: '0x' + accumulatedTxnHash.toString(16), + txnHash: `0x${accumulatedTxnHash.toString(16)}`, }); createCnt -= 1; // exclude deploy txnType @@ -238,21 +235,21 @@ export function generateTransactions({ for (let i = 1; i <= createCnt; i++) { const randomContractAddress = contractAddresses[ - Math.floor(generateRandomValue() * contractAddresses.length) + Math.floor(generateRandomValue() * contractAddresses.length) ]; const randomTxnType = filteredTxnTypes[ - Math.floor(generateRandomValue() * filteredTxnTypes.length) + Math.floor(generateRandomValue() * filteredTxnTypes.length) ]; let randomFinalityStatus = finalityStatuses[ - Math.floor(generateRandomValue() * finalityStatuses.length) + Math.floor(generateRandomValue() * finalityStatuses.length) ]; let randomExecutionStatus = executionStatuses[ - Math.floor(generateRandomValue() * executionStatuses.length) + Math.floor(generateRandomValue() * executionStatuses.length) ]; - let randomContractFuncName = ['transfer', 'upgrade'][ + const randomContractFuncName = ['transfer', 'upgrade'][ Math.floor(generateRandomValue() * 2) ]; accumulatedTimestamp += i * 100; @@ -286,27 +283,40 @@ export function generateTransactions({ contractFuncName: '', // TODO: when multiple calls are supported, we move this to accountCalls, we keep it for legacy support contractCallData: [], - txnHash: '0x' + accumulatedTxnHash.toString(16), + txnHash: `0x${accumulatedTxnHash.toString(16)}`, accountCalls: { [randomContractAddress]: [ { contract: randomContractAddress, - contractFuncName: randomTxnType === TransactionType.INVOKE ? randomContractFuncName : '', + contractFuncName: + randomTxnType === TransactionType.INVOKE + ? randomContractFuncName + : '', contractCallData: [ randomContractAddress, - (Math.max(generateRandomValue() * 1000, 100)).toString(16), - "0x0" + Math.max(generateRandomValue() * 1000, 100).toString(16), + '0x0', ], }, ], }, - } - ); + }); } return transactions.sort((a, b) => b.timestamp - a.timestamp); } +/** + * Method to generate starkscan transactions. + * + * @param params + * @param params.address - The address of the account. + * @param params.startFrom - The timestamp to start from. + * @param params.timestampReduction - The timestamp reduction. + * @param params.cnt - The number of transactions to generate. + * @param params.txnTypes - The array of transaction types. + * @returns An array of starkscan transaction object. + */ export function generateStarkScanTranscations({ address, startFrom = Date.now(), @@ -322,12 +332,12 @@ export function generateStarkScanTranscations({ }): StarkScanTransactionsResponse { let transactionStartFrom = startFrom; const txs: StarkScanTransaction[] = []; - let totalRecordCnt = txnTypes.includes(TransactionType.DEPLOY_ACCOUNT) + const totalRecordCnt = txnTypes.includes(TransactionType.DEPLOY_ACCOUNT) ? cnt - 1 : cnt; for (let i = 0; i < totalRecordCnt; i++) { - let newTx = { + const newTx = { ...invokeTx, account_calls: [...invokeTx.account_calls], }; @@ -340,7 +350,7 @@ export function generateStarkScanTranscations({ } if (txnTypes.includes(TransactionType.DEPLOY_ACCOUNT)) { - let deployTx = { + const deployTx = { ...cairo0DeployTx, account_calls: [...cairo0DeployTx.account_calls], }; 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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 */