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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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 7cbd66f2fe69a34abea532ce0ac5a53f6cc75176 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:34:19 +0800 Subject: [PATCH 20/36] chore: update test helper and refactor ContractAddressFilter --- .../starknet-snap/src/__tests__/helper.ts | 301 ++++++++++++------ .../src/state/__tests__/helper.ts | 17 + .../src/state/transaction-state-manager.ts | 49 ++- 3 files changed, 264 insertions(+), 103 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 31bbf1f9..bf962e0b 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -6,7 +6,7 @@ import type { UserInputEvent } from '@metamask/snaps-sdk'; import { UserInputEventType } from '@metamask/snaps-sdk'; import { generateMnemonic } from 'bip39'; import { getRandomValues } from 'crypto'; -import type { constants, EstimateFee } from 'starknet'; +import { constants, EstimateFee } from 'starknet'; import { ec, CallData, @@ -24,10 +24,11 @@ import type { StarkScanTransactionsResponse, } from '../chain/data-client/starkscan.type'; import { FeeToken } from '../types/snapApi'; -import type { - AccContract, - Transaction, - TransactionRequest, +import { + TransactionDataVersion, + type AccContract, + type Transaction, + type TransactionRequest, } from '../types/snapState'; import { ACCOUNT_CLASS_HASH, @@ -62,6 +63,28 @@ export function generateRandomValue() { return getRandomValues(u32Arr)[0] / maxU32; } +/** + * Method to get a random value. + * + * @param dataLength - The length of the data. + * @returns An random number. + */ +export function getRandomValue(dataLength:number) { + return Math.floor(generateRandomValue() * dataLength); +} + +/** + * Method to get a random data. + * + * @param data - The data to get a random value. + * @returns A random data. + * */ +export function getRandomData(data:DataType[]) { + return data[getRandomValue(data.length)]; +} + +const SixtyThreeHexInBigInt = BigInt("1000000000000000000000000000000000000000000000000000000000000000000000000000"); + /** * Method to generate Bip44 Entropy. * @@ -169,20 +192,24 @@ export async function generateAccounts( * @param params.finalityStatuses - Array of transaction finality status. * @param params.executionStatuses - Array of transaction execution status. * @param params.cnt - Number of transaction to generate. + * @param params.timestamp - The timestamp of the first transaction. + * @param params.transactionVersions - The transaction version, 1 or 3, where 3 represents the fee will be paid in STRK. * @returns An array of transaction object. */ export function generateTransactions({ chainId, address, + baseTxnHashInBigInt = SixtyThreeHexInBigInt, contractAddresses = PRELOADED_TOKENS.map((token) => token.address), txnTypes = Object.values(TransactionType), finalityStatuses = Object.values(TransactionFinalityStatus), executionStatuses = Object.values(TransactionExecutionStatus), // The timestamp from data source is in seconds timestamp = Math.floor(Date.now() / 1000), + transactionVersions = [1,3], cnt = 1, }: { - chainId: constants.StarknetChainId; + chainId: constants.StarknetChainId | string; address: string; contractAddresses?: string[]; txnTypes?: TransactionType[]; @@ -190,29 +217,12 @@ export function generateTransactions({ executionStatuses?: TransactionExecutionStatus[]; timestamp?: number; cnt?: number; + transactionVersions?: number[]; + baseTxnHashInBigInt?: bigint; }): Transaction[] { - const transaction = { - chainId: chainId, - contractAddress: '', - contractCallData: [], - contractFuncName: '', - senderAddress: address, - timestamp: timestamp, - txnHash: '', - txnType: '', - failureReason: '', - status: '', - executionStatus: '', - finalityStatus: '', - eventIds: [], - }; - let accumulatedTimestamp = timestamp; - let accumulatedTxnHash = BigInt( - '0x2a8c2d5d4908a6561de87ecb18a76305c64800e3f81b393b9988de1abd37284', - ); - + let baseTimeStamp = timestamp; let createCnt = cnt; - let filteredTxnTypes = txnTypes; + let _txnTypes = txnTypes; const transactions: Transaction[] = []; // only 1 deploy account transaction to generate @@ -220,82 +230,185 @@ export function generateTransactions({ txnTypes.includes(TransactionType.DEPLOY_ACCOUNT) || txnTypes.includes(TransactionType.DEPLOY) ) { - transactions.push({ - ...transaction, - contractAddress: address, - txnType: TransactionType.DEPLOY_ACCOUNT, - finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, - executionStatus: TransactionExecutionStatus.SUCCEEDED, - timestamp: accumulatedTimestamp, - txnHash: '0x' + accumulatedTxnHash.toString(16), - }); + + transactions.push(generateDeployTransaction({ + address, + txnHash: getTransactionHash(baseTxnHashInBigInt), + timestamp: baseTimeStamp, + version: getRandomData(transactionVersions), + chainId + })); + createCnt -= 1; - // exclude deploy txnType - filteredTxnTypes = filteredTxnTypes.filter( + + // after generate a deploy transaction, we dont need to re-generate another deploy transaction, + // so we can remove it from the txnTypes, to make sure we only random the types that are not deploy. + _txnTypes = txnTypes.filter( (type) => type !== TransactionType.DEPLOY_ACCOUNT && type !== TransactionType.DEPLOY, ); } - if (filteredTxnTypes.length === 0) { - filteredTxnTypes = [TransactionType.INVOKE]; + for (let i = 1; i <= createCnt; i++) { + // Make sure the timestamp is increasing + baseTimeStamp += i * 100; + // Make sure the txn hash is unique + baseTxnHashInBigInt += BigInt(i * 100); + + const executionStatus = getRandomData(executionStatuses) + const finalityStatus = executionStatus === TransactionExecutionStatus.REJECTED ? TransactionFinalityStatus.ACCEPTED_ON_L2 : getRandomData(finalityStatuses) + const txnType = getRandomData(_txnTypes); + const contractFuncName = txnType == TransactionType.INVOKE ? getRandomData(['transfer', 'upgrade']) : '' + + transactions.push(generateInvokeTransaction({ + address, + contractAddress: getRandomData(contractAddresses), + txnHash: getTransactionHash(baseTxnHashInBigInt), + timestamp: baseTimeStamp, + version: getRandomData(transactionVersions), + chainId, + txnType, + finalityStatus, + executionStatus, + contractFuncName, + })); } - for (let i = 1; i <= createCnt; i++) { - const randomContractAddress = - contractAddresses[ - Math.floor(generateRandomValue() * contractAddresses.length) - ]; - const randomTxnType = - filteredTxnTypes[ - Math.floor(generateRandomValue() * filteredTxnTypes.length) - ]; - let randomFinalityStatus = - finalityStatuses[ - Math.floor(generateRandomValue() * finalityStatuses.length) - ]; - let randomExecutionStatus = - executionStatuses[ - Math.floor(generateRandomValue() * executionStatuses.length) - ]; - let randomContractFuncName = ['transfer', 'upgrade'][ - Math.floor(generateRandomValue() * 2) - ]; - accumulatedTimestamp += i * 100; - accumulatedTxnHash += BigInt(i * 100); - - if (randomExecutionStatus === TransactionExecutionStatus.REJECTED) { - if ( - [ - TransactionFinalityStatus.NOT_RECEIVED, - TransactionFinalityStatus.RECEIVED, - TransactionFinalityStatus.ACCEPTED_ON_L1, - ].includes(randomFinalityStatus) - ) { - randomFinalityStatus = TransactionFinalityStatus.ACCEPTED_ON_L2; - } - } + return transactions.sort((a, b) => b.timestamp - a.timestamp); +} + +function getTransactionTemplate() { + return { + chainId: constants.StarknetChainId.SN_SEPOLIA, + timestamp: 0, + senderAddress: '', + contractAddress: '', + txnHash: '', + txnType: '', + failureReason: '', + executionStatus: '', + finalityStatus: '', + accountCalls: null, + version: 1, + dataVersion: TransactionDataVersion.V2, + }; +} - if (randomFinalityStatus === TransactionFinalityStatus.NOT_RECEIVED) { - randomFinalityStatus = TransactionFinalityStatus.ACCEPTED_ON_L2; - randomExecutionStatus = TransactionExecutionStatus.SUCCEEDED; +/** + * Method to generate a deploy transaction. + * + * @param params + * @param params.address - The address of the account. + * @param params.txnHash - The transaction hash. + * @param params.timestamp - The timestamp of the transaction. + * @param params.version - The version of the transaction. + * @param params.chainId - The chain id of the transaction. + * @returns A transaction object. + * */ +export function generateDeployTransaction({ + address, + txnHash, + timestamp, + version, + chainId +}: { + address: string; + txnHash: string; + timestamp: number; + version: number; + chainId: constants.StarknetChainId | string; +}):Transaction { + + const transaction = getTransactionTemplate() + + return { + ...transaction, + chainId: chainId, + txnHash, + senderAddress: address, + contractAddress: address, + txnType: TransactionType.DEPLOY_ACCOUNT, + finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, + executionStatus: TransactionExecutionStatus.SUCCEEDED, + timestamp: timestamp, + version: version } +} + +/** + * Method to generate an invoke transaction. + * + * @param params + * @param params.address - The address of the account. + * @param params.contractAddress - The contract address. + * @param params.txnHash - The transaction hash. + * @param params.timestamp - The timestamp of the transaction. + * @param params.version - The version of the transaction. + * @param params.chainId - The chain id of the transaction. + * @param params.txnType - The type of the transaction. + * @param params.finalityStatus - The finality status of the transaction. + * @param params.executionStatus - The execution status of the transaction. + * @param params.contractFuncName - The contract function name. + * @returns A transaction object. + * */ +export function generateInvokeTransaction({ + address, + contractAddress, + txnHash, + timestamp, + version, + chainId, + txnType, + finalityStatus, + executionStatus, + contractFuncName, +}: { + address: string; + txnHash: string; + contractAddress:string, + timestamp: number; + version: number; + chainId: constants.StarknetChainId | string; + finalityStatus: TransactionFinalityStatus, + executionStatus: TransactionExecutionStatus, + txnType: TransactionType, + contractFuncName : string +}):Transaction { + + const transaction = getTransactionTemplate() - transactions.push({ + return { ...transaction, - contractAddress: randomContractAddress, - txnType: randomTxnType, - finalityStatus: randomFinalityStatus, - executionStatus: randomExecutionStatus, - timestamp: accumulatedTimestamp, - contractFuncName: - randomTxnType === TransactionType.INVOKE ? randomContractFuncName : '', - txnHash: '0x' + accumulatedTxnHash.toString(16), - }); - } + chainId: chainId, + contractAddress: '', + txnType, + finalityStatus, + executionStatus, + timestamp, + txnHash, + senderAddress: address, + accountCalls: { + [contractAddress] : [ + { + contract: contractAddress, + contractFuncName, + contractCallData: [address, getRandomValue(1000).toString(16)], + } + ] + }, + version: version + } +} - return transactions.sort((a, b) => b.timestamp - a.timestamp); +/** + * Method to generate a random transaction hash. + * + * @param base - The base number to generate the transaction hash. + * @returns A transaction hash. + * */ +export function getTransactionHash(base = SixtyThreeHexInBigInt) { + return `0x` + base.toString(16) } export function generateTransactionRequests({ @@ -333,16 +446,10 @@ export function generateTransactionRequests({ signer: address, addressIndex: 0, maxFee: '100', - selectedFeeToken: - selectedFeeTokens[ - Math.floor(generateRandomValue() * selectedFeeTokens.length) - ], + selectedFeeToken: getRandomData(selectedFeeTokens), calls: [ { - contractAddress: - contractAddresses[ - Math.floor(generateRandomValue() * contractAddresses.length) - ], + contractAddress: getRandomData(contractAddresses), calldata: CallData.compile({ to: address, amount: '1', diff --git a/packages/starknet-snap/src/state/__tests__/helper.ts b/packages/starknet-snap/src/state/__tests__/helper.ts index 05044039..0efdc479 100644 --- a/packages/starknet-snap/src/state/__tests__/helper.ts +++ b/packages/starknet-snap/src/state/__tests__/helper.ts @@ -15,6 +15,7 @@ import * as snapHelper from '../../utils/snap'; import { NetworkStateManager } from '../network-state-manager'; import { TransactionRequestStateManager } from '../request-state-manager'; import { TokenStateManager } from '../token-state-manager'; +import { TransactionStateManager } from '../transaction-state-manager'; jest.mock('../../utils/snap'); jest.mock('../../utils/logger'); @@ -74,6 +75,22 @@ export const mockTokenStateManager = () => { }; }; +export const mockTransactionStateManager = () => { + const removeTransactionsSpy = jest.spyOn( + TransactionStateManager.prototype, + 'removeTransactions', + ); + const findTransactionsSpy = jest.spyOn( + TransactionStateManager.prototype, + 'findTransactions', + ); + + return { + removeTransactionsSpy, + findTransactionsSpy, + }; +}; + export const mockTransactionRequestStateManager = () => { const upsertTransactionRequestSpy = jest.spyOn( TransactionRequestStateManager.prototype, diff --git a/packages/starknet-snap/src/state/transaction-state-manager.ts b/packages/starknet-snap/src/state/transaction-state-manager.ts index a4805bb9..1a12abf2 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.ts @@ -1,18 +1,20 @@ -import type { constants, TransactionType } from 'starknet'; +import type { constants } from 'starknet'; import { TransactionFinalityStatus, TransactionExecutionStatus, + TransactionType } from 'starknet'; import { assert, enums, number } from 'superstruct'; -import type { Transaction, SnapState } from '../types/snapState'; -import { TransactionStatusType } from '../types/snapState'; +import type { Transaction, SnapState, V2Transaction } from '../types/snapState'; +import { TransactionStatusType, VoyagerTransactionType } from '../types/snapState'; import type { IFilter } from './filter'; import { BigIntFilter, ChainIdFilter as BaseChainIdFilter, StringFllter, Filter, + MultiFilter, } from './filter'; import { StateManager, StateManagerError } from './state-manager'; @@ -23,11 +25,32 @@ export class ChainIdFilter implements ITxFilter {} export class ContractAddressFilter - extends BigIntFilter + extends MultiFilter< + string, + string, + Transaction +> implements ITxFilter { - dataKey = 'contractAddress'; + protected _prepareSearch(search: string[]): void { + this.search = new Set(search?.map((val) => val)); + } + + protected _apply(data: Transaction): boolean { + const txn = (data as V2Transaction) + const accountCalls = txn.accountCalls; + if (!accountCalls) { + return false; + } + for (const contract in this.search) { + if (accountCalls[contract]) { + return true; + } + } + return false; + } } + export class SenderAddressFilter extends BigIntFilter implements ITxFilter @@ -61,6 +84,14 @@ export class TxnTypeFilter dataKey = 'txnType'; } +export class DataVersionFilter + extends StringFllter + implements ITxFilter +{ + dataKey = 'dataVersion'; +} + + // Filter for transaction status // Search for transactions based on the finality status and execution status // It compare the finality status and execution status in OR condition, due to our use case is to find the transactions that fit to the given finality status or the given execution status @@ -112,12 +143,13 @@ export class TxStatusFilter implements ITxFilter { export type SearchFilter = { txnHash?: string[]; txnType?: TransactionType[]; - chainId?: constants.StarknetChainId[]; + chainId?: constants.StarknetChainId[] | string[]; senderAddress?: string[]; contractAddress?: string[]; executionStatus?: TransactionExecutionStatus[]; finalityStatus?: TransactionFinalityStatus[]; timestamp?: number; + dataVersion?: string[]; }; export class TransactionStateManager extends StateManager { @@ -158,10 +190,15 @@ export class TransactionStateManager extends StateManager { executionStatus, finalityStatus, timestamp, + dataVersion, }: SearchFilter, state?: SnapState, ): Promise { const filters: ITxFilter[] = []; + if (dataVersion !== undefined && dataVersion.length > 0) { + filters.push(new DataVersionFilter(dataVersion)); + } + if (txnHash !== undefined && txnHash.length > 0) { filters.push(new TxHashFilter(txnHash)); } From f65742200e51400b21f2e0a2044d209285ea934e Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:36:17 +0800 Subject: [PATCH 21/36] chore: lint --- .../starknet-snap/src/__tests__/helper.ts | 174 +++++++++--------- .../src/state/transaction-state-manager.ts | 16 +- 2 files changed, 97 insertions(+), 93 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index bf962e0b..b363226c 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -6,8 +6,9 @@ import type { UserInputEvent } from '@metamask/snaps-sdk'; import { UserInputEventType } from '@metamask/snaps-sdk'; import { generateMnemonic } from 'bip39'; import { getRandomValues } from 'crypto'; -import { constants, EstimateFee } from 'starknet'; +import type { EstimateFee } from 'starknet'; import { + constants, ec, CallData, hash, @@ -69,21 +70,23 @@ export function generateRandomValue() { * @param dataLength - The length of the data. * @returns An random number. */ -export function getRandomValue(dataLength:number) { +export function getRandomValue(dataLength: number) { return Math.floor(generateRandomValue() * dataLength); } /** * Method to get a random data. - * + * * @param data - The data to get a random value. * @returns A random data. * */ -export function getRandomData(data:DataType[]) { +export function getRandomData(data: DataType[]) { return data[getRandomValue(data.length)]; } -const SixtyThreeHexInBigInt = BigInt("1000000000000000000000000000000000000000000000000000000000000000000000000000"); +const SixtyThreeHexInBigInt = BigInt( + '1000000000000000000000000000000000000000000000000000000000000000000000000000', +); /** * Method to generate Bip44 Entropy. @@ -206,7 +209,7 @@ export function generateTransactions({ executionStatuses = Object.values(TransactionExecutionStatus), // The timestamp from data source is in seconds timestamp = Math.floor(Date.now() / 1000), - transactionVersions = [1,3], + transactionVersions = [1, 3], cnt = 1, }: { chainId: constants.StarknetChainId | string; @@ -230,17 +233,18 @@ export function generateTransactions({ txnTypes.includes(TransactionType.DEPLOY_ACCOUNT) || txnTypes.includes(TransactionType.DEPLOY) ) { - - transactions.push(generateDeployTransaction({ - address, - txnHash: getTransactionHash(baseTxnHashInBigInt), - timestamp: baseTimeStamp, - version: getRandomData(transactionVersions), - chainId - })); + transactions.push( + generateDeployTransaction({ + address, + txnHash: getTransactionHash(baseTxnHashInBigInt), + timestamp: baseTimeStamp, + version: getRandomData(transactionVersions), + chainId, + }), + ); createCnt -= 1; - + // after generate a deploy transaction, we dont need to re-generate another deploy transaction, // so we can remove it from the txnTypes, to make sure we only random the types that are not deploy. _txnTypes = txnTypes.filter( @@ -256,23 +260,31 @@ export function generateTransactions({ // Make sure the txn hash is unique baseTxnHashInBigInt += BigInt(i * 100); - const executionStatus = getRandomData(executionStatuses) - const finalityStatus = executionStatus === TransactionExecutionStatus.REJECTED ? TransactionFinalityStatus.ACCEPTED_ON_L2 : getRandomData(finalityStatuses) + const executionStatus = getRandomData(executionStatuses); + const finalityStatus = + executionStatus === TransactionExecutionStatus.REJECTED + ? TransactionFinalityStatus.ACCEPTED_ON_L2 + : getRandomData(finalityStatuses); const txnType = getRandomData(_txnTypes); - const contractFuncName = txnType == TransactionType.INVOKE ? getRandomData(['transfer', 'upgrade']) : '' - - transactions.push(generateInvokeTransaction({ - address, - contractAddress: getRandomData(contractAddresses), - txnHash: getTransactionHash(baseTxnHashInBigInt), - timestamp: baseTimeStamp, - version: getRandomData(transactionVersions), - chainId, - txnType, - finalityStatus, - executionStatus, - contractFuncName, - })); + const contractFuncName = + txnType == TransactionType.INVOKE + ? getRandomData(['transfer', 'upgrade']) + : ''; + + transactions.push( + generateInvokeTransaction({ + address, + contractAddress: getRandomData(contractAddresses), + txnHash: getTransactionHash(baseTxnHashInBigInt), + timestamp: baseTimeStamp, + version: getRandomData(transactionVersions), + chainId, + txnType, + finalityStatus, + executionStatus, + contractFuncName, + }), + ); } return transactions.sort((a, b) => b.timestamp - a.timestamp); @@ -297,7 +309,7 @@ function getTransactionTemplate() { /** * Method to generate a deploy transaction. - * + * * @param params * @param params.address - The address of the account. * @param params.txnHash - The transaction hash. @@ -311,34 +323,33 @@ export function generateDeployTransaction({ txnHash, timestamp, version, - chainId + chainId, }: { address: string; txnHash: string; timestamp: number; version: number; chainId: constants.StarknetChainId | string; -}):Transaction { - - const transaction = getTransactionTemplate() - - return { - ...transaction, - chainId: chainId, - txnHash, - senderAddress: address, - contractAddress: address, - txnType: TransactionType.DEPLOY_ACCOUNT, - finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, - executionStatus: TransactionExecutionStatus.SUCCEEDED, - timestamp: timestamp, - version: version - } +}): Transaction { + const transaction = getTransactionTemplate(); + + return { + ...transaction, + chainId: chainId, + txnHash, + senderAddress: address, + contractAddress: address, + txnType: TransactionType.DEPLOY_ACCOUNT, + finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, + executionStatus: TransactionExecutionStatus.SUCCEEDED, + timestamp: timestamp, + version: version, + }; } /** * Method to generate an invoke transaction. - * + * * @param params * @param params.address - The address of the account. * @param params.contractAddress - The contract address. @@ -366,49 +377,48 @@ export function generateInvokeTransaction({ }: { address: string; txnHash: string; - contractAddress:string, + contractAddress: string; timestamp: number; version: number; chainId: constants.StarknetChainId | string; - finalityStatus: TransactionFinalityStatus, - executionStatus: TransactionExecutionStatus, - txnType: TransactionType, - contractFuncName : string -}):Transaction { - - const transaction = getTransactionTemplate() - - return { - ...transaction, - chainId: chainId, - contractAddress: '', - txnType, - finalityStatus, - executionStatus, - timestamp, - txnHash, - senderAddress: address, - accountCalls: { - [contractAddress] : [ - { - contract: contractAddress, - contractFuncName, - contractCallData: [address, getRandomValue(1000).toString(16)], - } - ] - }, - version: version - } + finalityStatus: TransactionFinalityStatus; + executionStatus: TransactionExecutionStatus; + txnType: TransactionType; + contractFuncName: string; +}): Transaction { + const transaction = getTransactionTemplate(); + + return { + ...transaction, + chainId: chainId, + contractAddress: '', + txnType, + finalityStatus, + executionStatus, + timestamp, + txnHash, + senderAddress: address, + accountCalls: { + [contractAddress]: [ + { + contract: contractAddress, + contractFuncName, + contractCallData: [address, getRandomValue(1000).toString(16)], + }, + ], + }, + version: version, + }; } /** * Method to generate a random transaction hash. - * + * * @param base - The base number to generate the transaction hash. * @returns A transaction hash. * */ export function getTransactionHash(base = SixtyThreeHexInBigInt) { - return `0x` + base.toString(16) + return `0x` + base.toString(16); } export function generateTransactionRequests({ diff --git a/packages/starknet-snap/src/state/transaction-state-manager.ts b/packages/starknet-snap/src/state/transaction-state-manager.ts index 1a12abf2..faaf19b6 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.ts @@ -1,13 +1,12 @@ -import type { constants } from 'starknet'; +import type { constants, TransactionType } from 'starknet'; import { TransactionFinalityStatus, TransactionExecutionStatus, - TransactionType } from 'starknet'; import { assert, enums, number } from 'superstruct'; import type { Transaction, SnapState, V2Transaction } from '../types/snapState'; -import { TransactionStatusType, VoyagerTransactionType } from '../types/snapState'; +import { TransactionStatusType } from '../types/snapState'; import type { IFilter } from './filter'; import { BigIntFilter, @@ -25,11 +24,7 @@ export class ChainIdFilter implements ITxFilter {} export class ContractAddressFilter - extends MultiFilter< - string, - string, - Transaction -> + extends MultiFilter implements ITxFilter { protected _prepareSearch(search: string[]): void { @@ -37,8 +32,8 @@ export class ContractAddressFilter } protected _apply(data: Transaction): boolean { - const txn = (data as V2Transaction) - const accountCalls = txn.accountCalls; + const txn = data as V2Transaction; + const { accountCalls } = txn; if (!accountCalls) { return false; } @@ -91,7 +86,6 @@ export class DataVersionFilter dataKey = 'dataVersion'; } - // Filter for transaction status // Search for transactions based on the finality status and execution status // It compare the finality status and execution status in OR condition, due to our use case is to find the transactions that fit to the given finality status or the given execution status From 4ee01bcc44c661d5fb20b2e2e186c4b709af1bc9 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:50:46 +0800 Subject: [PATCH 22/36] chore: add test for dataVersion filter --- .../state/transaction-state-manager.test.ts | 22 +++++++++++++++++++ .../src/state/transaction-state-manager.ts | 8 +++++-- 2 files changed, 28 insertions(+), 2 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 b67344b4..d80cd7d5 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.test.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.test.ts @@ -6,6 +6,7 @@ import { } from 'starknet'; import { generateTransactions } from '../__tests__/helper'; +import { TransactionDataVersion } from '../types/snapState'; import { PRELOADED_TOKENS } from '../utils/constants'; import { mockAcccounts, mockState } from './__tests__/helper'; import { StateManagerError } from './state-manager'; @@ -132,6 +133,27 @@ describe('TransactionStateManager', () => { expect(result).toStrictEqual(txns); }); + it('returns the list of transaction by data version', async () => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + const { + txns: [legacyData, ...newData], + getDataSpy, + } = await prepareMockData(chainId); + + // legacy data does not have dataVersion + delete legacyData.dataVersion; + // simulate the data source return the legacy data and new data + getDataSpy.mockResolvedValue(newData.concat([legacyData])); + + const stateManager = new TransactionStateManager(); + + const result = await stateManager.findTransactions({ + dataVersion: [TransactionDataVersion.V2], + }); + + expect(result).toStrictEqual(newData); + }); + it('returns the list of transaction by contract address', async () => { const { txns, stateManager } = await prepareFindTransctions(); const tokenAddress1 = PRELOADED_TOKENS.map((token) => token.address)[0]; diff --git a/packages/starknet-snap/src/state/transaction-state-manager.ts b/packages/starknet-snap/src/state/transaction-state-manager.ts index faaf19b6..597a98e1 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.ts @@ -6,7 +6,10 @@ import { import { assert, enums, number } from 'superstruct'; import type { Transaction, SnapState, V2Transaction } from '../types/snapState'; -import { TransactionStatusType } from '../types/snapState'; +import { + TransactionDataVersion, + TransactionStatusType, +} from '../types/snapState'; import type { IFilter } from './filter'; import { BigIntFilter, @@ -184,7 +187,8 @@ export class TransactionStateManager extends StateManager { executionStatus, finalityStatus, timestamp, - dataVersion, + // default return the latest version of the data + dataVersion = [TransactionDataVersion.V2], }: SearchFilter, state?: SnapState, ): Promise { From 3a99e761a00624b697510a2a36542b1c518653a4 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Dec 2024 12:15:18 +0800 Subject: [PATCH 23/36] chore: update txn state mgr test --- .../state/transaction-state-manager.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 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 d80cd7d5..aee2264c 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.test.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.test.ts @@ -140,10 +140,22 @@ describe('TransactionStateManager', () => { getDataSpy, } = await prepareMockData(chainId); - // legacy data does not have dataVersion - delete legacyData.dataVersion; + const legacyTxn = { + txnHash: legacyData.txnHash, + txnType: legacyData.txnType, + chainId: legacyData.chainId, + senderAddress: legacyData.senderAddress, + contractAddress: legacyData.contractAddress, + contractFuncName: 'transfer', + contractCallData: ['0x123', '0x456'], + executionStatus: legacyData.executionStatus, + finalityStatus: legacyData.finalityStatus, + timestamp: legacyData.timestamp, + eventIds: [], + failureReason: legacyData.failureReason, + }; // simulate the data source return the legacy data and new data - getDataSpy.mockResolvedValue(newData.concat([legacyData])); + getDataSpy.mockResolvedValue(newData.concat([legacyTxn])); const stateManager = new TransactionStateManager(); From 38e5d639026e17da2c34d4804fca0587573bb120 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Dec 2024 13:07:50 +0800 Subject: [PATCH 24/36] chore: update search condition --- .../state/transaction-state-manager.test.ts | 30 +++++++++++++------ .../src/state/transaction-state-manager.ts | 11 ++----- 2 files changed, 24 insertions(+), 17 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 aee2264c..bc0c90e9 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.test.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.test.ts @@ -6,6 +6,7 @@ import { } from 'starknet'; import { generateTransactions } from '../__tests__/helper'; +import type { V2Transaction } from '../types/snapState'; import { TransactionDataVersion } from '../types/snapState'; import { PRELOADED_TOKENS } from '../utils/constants'; import { mockAcccounts, mockState } from './__tests__/helper'; @@ -137,7 +138,7 @@ describe('TransactionStateManager', () => { const chainId = constants.StarknetChainId.SN_SEPOLIA; const { txns: [legacyData, ...newData], - getDataSpy, + state, } = await prepareMockData(chainId); const legacyTxn = { @@ -155,7 +156,7 @@ describe('TransactionStateManager', () => { failureReason: legacyData.failureReason, }; // simulate the data source return the legacy data and new data - getDataSpy.mockResolvedValue(newData.concat([legacyTxn])); + state.transactions = newData.concat([legacyTxn]); const stateManager = new TransactionStateManager(); @@ -170,16 +171,23 @@ describe('TransactionStateManager', () => { const { txns, stateManager } = await prepareFindTransctions(); const tokenAddress1 = PRELOADED_TOKENS.map((token) => token.address)[0]; const tokenAddress2 = PRELOADED_TOKENS.map((token) => token.address)[2]; + const contractAddress = [ + tokenAddress1.toLowerCase(), + tokenAddress2.toLowerCase(), + ]; + const contractAddressSet = new Set(contractAddress); const result = await stateManager.findTransactions({ - contractAddress: [tokenAddress1, tokenAddress2], + contractAddress, }); expect(result).toStrictEqual( txns.filter( - (txn) => - txn.contractAddress === tokenAddress1 || - txn.contractAddress === tokenAddress2, + (txn: V2Transaction) => + txn.accountCalls && + Object.keys(txn.accountCalls).some((contract) => + contractAddressSet.has(contract.toLowerCase()), + ), ), ); }); @@ -246,8 +254,9 @@ describe('TransactionStateManager', () => { TransactionExecutionStatus.REJECTED, ]; const contractAddressCond = [ - PRELOADED_TOKENS.map((token) => token.address)[0], + PRELOADED_TOKENS.map((token) => token.address.toLowerCase())[0], ]; + const contractAddressSet = new Set(contractAddressCond); const timestampCond = txns[5].timestamp * 1000; const chainIdCond = [ txns[0].chainId as unknown as constants.StarknetChainId, @@ -263,7 +272,7 @@ describe('TransactionStateManager', () => { }); expect(result).toStrictEqual( - txns.filter((txn) => { + txns.filter((txn: V2Transaction) => { return ( (finalityStatusCond.includes( txn.finalityStatus as unknown as TransactionFinalityStatus, @@ -272,7 +281,10 @@ describe('TransactionStateManager', () => { txn.executionStatus as unknown as TransactionExecutionStatus, )) && txn.timestamp >= txns[5].timestamp && - contractAddressCond.includes(txn.contractAddress) && + txn.accountCalls && + Object.keys(txn.accountCalls).some((contract) => + contractAddressSet.has(contract.toLowerCase()), + ) && chainIdCond.includes( txn.chainId as unknown as constants.StarknetChainId, ) && diff --git a/packages/starknet-snap/src/state/transaction-state-manager.ts b/packages/starknet-snap/src/state/transaction-state-manager.ts index 597a98e1..0973ce82 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.ts @@ -16,7 +16,6 @@ import { ChainIdFilter as BaseChainIdFilter, StringFllter, Filter, - MultiFilter, } from './filter'; import { StateManager, StateManagerError } from './state-manager'; @@ -27,21 +26,17 @@ export class ChainIdFilter implements ITxFilter {} export class ContractAddressFilter - extends MultiFilter + extends StringFllter implements ITxFilter { - protected _prepareSearch(search: string[]): void { - this.search = new Set(search?.map((val) => val)); - } - protected _apply(data: Transaction): boolean { const txn = data as V2Transaction; const { accountCalls } = txn; if (!accountCalls) { return false; } - for (const contract in this.search) { - if (accountCalls[contract]) { + for (const contract in accountCalls) { + if (this.search.has(contract.toLowerCase())) { return true; } } 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 25/36] 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; From 956b10e6167eabafcfac8ecfd86f66343108a5d7 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:05:57 +0800 Subject: [PATCH 26/36] chore: apply starkscan for list transaction --- .../starknet-snap/src/__tests__/helper.ts | 2 + .../src/chain/transaction-service.test.ts | 271 ++++++++++++++++++ .../src/chain/transaction-service.ts | 141 +++++++++ packages/starknet-snap/src/config.ts | 11 + .../src/rpcs/__tests__/helper.ts | 2 +- .../src/rpcs/list-transaction.test.ts | 88 ++++++ .../src/rpcs/list-transactions.ts | 75 +++++ packages/starknet-snap/src/types/snapState.ts | 25 +- .../starknet-snap/src/utils/factory.test.ts | 17 +- packages/starknet-snap/src/utils/factory.ts | 21 ++ .../src/utils/formatter-utils.test.ts | 20 ++ .../src/utils/formatter-utils.ts | 20 ++ .../src/utils/superstruct.test.ts | 30 ++ .../starknet-snap/src/utils/superstruct.ts | 59 +++- 14 files changed, 750 insertions(+), 32 deletions(-) create mode 100644 packages/starknet-snap/src/chain/transaction-service.test.ts create mode 100644 packages/starknet-snap/src/chain/transaction-service.ts create mode 100644 packages/starknet-snap/src/rpcs/list-transaction.test.ts create mode 100644 packages/starknet-snap/src/rpcs/list-transactions.ts diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index b363226c..5636e5fe 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -303,6 +303,8 @@ function getTransactionTemplate() { finalityStatus: '', accountCalls: null, version: 1, + maxFee: null, + actualFee: null, dataVersion: TransactionDataVersion.V2, }; } diff --git a/packages/starknet-snap/src/chain/transaction-service.test.ts b/packages/starknet-snap/src/chain/transaction-service.test.ts new file mode 100644 index 00000000..2c30c20f --- /dev/null +++ b/packages/starknet-snap/src/chain/transaction-service.test.ts @@ -0,0 +1,271 @@ +import { TransactionFinalityStatus, TransactionType } from 'starknet'; + +import { generateAccounts, generateTransactions } from '../__tests__/helper'; +import { mockTransactionStateManager } from '../state/__tests__/helper'; +import type { Network, Transaction } from '../types/snapState'; +import { TransactionDataVersion } from '../types/snapState'; +import { + ETHER_SEPOLIA_TESTNET, + STARKNET_SEPOLIA_TESTNET_NETWORK, + STRK_SEPOLIA_TESTNET, +} from '../utils/constants'; +import type { IDataClient } from './data-client'; +import { TransactionService } from './transaction-service'; + +describe('TransactionService', () => { + class MockTransactionService extends TransactionService { + async *getTransactionsOnChain( + address: string, + contractAddress: string, + tillToInDays: number, + ) { + yield* super.getTransactionsOnChain( + address, + contractAddress, + tillToInDays, + ); + } + + async *getTransactionsOnState(address: string, contractAddress: string) { + yield* super.getTransactionsOnState(address, contractAddress); + } + + async *filterTransactionsByContractAddress( + transactions: Transaction[], + contractAddress: string, + ) { + yield* super.filterTransactionsByContractAddress( + transactions, + contractAddress, + ); + } + + isTransactionHasSameContractOrAnDeploy( + tx: Transaction, + contractAddress: string, + ) { + return super.isTransactionHasSameContractOrAnDeploy(tx, contractAddress); + } + } + + const mockDataClient = () => { + const getTransactionsSpy = jest.fn(); + + const dataClient: IDataClient = { + getTransactions: getTransactionsSpy, + getDeployTransaction: jest.fn(), + }; + + return { + dataClient, + getTransactionsSpy, + }; + }; + + const mockTransactionService = ( + network: Network, + dataClient: IDataClient, + ) => { + const service = new MockTransactionService({ + dataClient, + network, + }); + + return service; + }; + + const mockAddress = async (network: Network) => { + const [{ address }] = await generateAccounts(network.chainId, 1); + return address; + }; + + const generateEthAndStrkContractTransactions = ({ address, chainId }) => { + const ethContractAddress = ETHER_SEPOLIA_TESTNET.address; + const strkContractAddress = STRK_SEPOLIA_TESTNET.address; + + // generate transactions for eth contract, include deploy and invoke transactions + const mockedEthTrasactions = generateTransactions({ + cnt: 10, + address, + txnTypes: [TransactionType.DEPLOY_ACCOUNT, TransactionType.INVOKE], + chainId, + contractAddresses: [ethContractAddress], + }); + + const lastTx = mockedEthTrasactions[mockedEthTrasactions.length - 1]; + const lastTxHashInBigInt = BigInt(lastTx.txnHash); + + // generate transactions for strk contract, include invoke transactions only + const mockedStrkTrasactions = generateTransactions({ + cnt: 10, + address, + chainId, + txnTypes: [TransactionType.INVOKE], + contractAddresses: [strkContractAddress], + // make sure the txnHash is unique for the transactions in strk contract + baseTxnHashInBigInt: lastTxHashInBigInt + BigInt(1), + }); + + return mockedEthTrasactions + .concat(mockedStrkTrasactions) + .sort((tx1, tx2) => tx2.timestamp - tx1.timestamp); + }; + + const prepareGetTransactions = async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const address = await mockAddress(network); + const transactionsFromDataClientOrState = + generateEthAndStrkContractTransactions({ + address, + chainId: network.chainId, + }); + // the given contract address + const contractAddress = ETHER_SEPOLIA_TESTNET.address; + + const { findTransactionsSpy, removeTransactionsSpy } = + mockTransactionStateManager(); + const { getTransactionsSpy, dataClient } = mockDataClient(); + removeTransactionsSpy.mockReturnThis(); + findTransactionsSpy.mockResolvedValue(transactionsFromDataClientOrState); + getTransactionsSpy.mockResolvedValue(transactionsFromDataClientOrState); + + const service = mockTransactionService(network, dataClient); + + const filteredTransactions = transactionsFromDataClientOrState.filter( + (tx) => + service.isTransactionHasSameContractOrAnDeploy(tx, contractAddress), + ); + + return { + removeTransactionsSpy, + findTransactionsSpy, + getTransactionsSpy, + service, + transactionsFromDataClientOrState, + filteredTransactions, + network, + address, + contractAddress, + }; + }; + + describe('getTransactionsOnChain', () => { + it('returns transactions on chain', async () => { + const { + service, + getTransactionsSpy, + filteredTransactions, + address, + contractAddress, + } = await prepareGetTransactions(); + + const transactions: Transaction[] = []; + + for await (const tx of service.getTransactionsOnChain( + address, + contractAddress, + 10, + )) { + transactions.push(tx); + } + + expect(getTransactionsSpy).toHaveBeenCalledWith( + address, + expect.any(Number), + ); + expect(transactions).toStrictEqual(filteredTransactions); + }); + }); + + describe('getTransactionsOnState', () => { + it('returns transactions on state', async () => { + const { + service, + findTransactionsSpy, + filteredTransactions, + network, + address, + contractAddress, + } = await prepareGetTransactions(); + + const transactions: Transaction[] = []; + for await (const tx of service.getTransactionsOnState( + address, + contractAddress, + )) { + transactions.push(tx); + } + + expect(findTransactionsSpy).toHaveBeenCalledWith({ + senderAddress: [address], + chainId: [network.chainId], + finalityStatus: [TransactionFinalityStatus.RECEIVED], + dataVersion: [TransactionDataVersion.V2], + }); + expect(transactions).toStrictEqual(filteredTransactions); + }); + }); + + describe('getTransactions', () => { + it('returns and merge the transactions from chain and state', async () => { + const { + service, + filteredTransactions: transactionsFromChain, + findTransactionsSpy, + network, + address, + contractAddress, + } = await prepareGetTransactions(); + + const lastTransactionFromChain = + transactionsFromChain[transactionsFromChain.length - 1]; + const lastTransactionHashInBigInt = BigInt( + lastTransactionFromChain.txnHash, + ); + const transactionFromState = generateTransactions({ + cnt: 5, + address, + chainId: network.chainId, + txnTypes: [TransactionType.INVOKE], + // make sure the contract address is match to the given contract address, so we can merge it with the transactions from chain + contractAddresses: [contractAddress], + // make sure the txnHash is different with the transaction from chain + baseTxnHashInBigInt: lastTransactionHashInBigInt * BigInt(2), + }); + findTransactionsSpy.mockResolvedValue(transactionFromState); + + const result = await service.getTransactions( + address, + contractAddress, + 10, + ); + + const expectedResult = transactionFromState.concat(transactionsFromChain); + + expect(result).toStrictEqual(expectedResult); + }); + + it('remove the transactions that are already on chain', async () => { + const { + service, + filteredTransactions: transactionsFromChain, + removeTransactionsSpy, + address, + contractAddress, + findTransactionsSpy, + } = await prepareGetTransactions(); + + const duplicatedTransactions = [ + transactionsFromChain[transactionsFromChain.length - 1], + ]; + + findTransactionsSpy.mockResolvedValue(duplicatedTransactions); + + await service.getTransactions(address, contractAddress, 10); + + expect(removeTransactionsSpy).toHaveBeenCalledWith({ + txnHash: [duplicatedTransactions[0].txnHash], + }); + }); + }); +}); diff --git a/packages/starknet-snap/src/chain/transaction-service.ts b/packages/starknet-snap/src/chain/transaction-service.ts new file mode 100644 index 00000000..d9b57fd8 --- /dev/null +++ b/packages/starknet-snap/src/chain/transaction-service.ts @@ -0,0 +1,141 @@ +import { TransactionFinalityStatus, TransactionType } from 'starknet'; + +import { TransactionStateManager } from '../state/transaction-state-manager'; +import type { Network, Transaction, V2Transaction } from '../types/snapState'; +import { TransactionDataVersion } from '../types/snapState'; +import { dayToSec, msToSec } from '../utils'; +import type { IDataClient } from './data-client'; + +export class TransactionService { + protected dataClient: IDataClient; + + protected txnStateMgr: TransactionStateManager; + + protected network: Network; + + constructor({ + dataClient, + txnStateMgr = new TransactionStateManager(), + network, + }: { + dataClient: IDataClient; + txnStateMgr?: TransactionStateManager; + network: Network; + }) { + this.dataClient = dataClient; + this.network = network; + this.txnStateMgr = txnStateMgr; + } + + protected async *getTransactionsOnChain( + address: string, + contractAddress: string, + tillToInDays: number, + ): AsyncGenerator { + // Get the transactions till the given days in second unit. + const tillToInSec = msToSec(Date.now()) - dayToSec(tillToInDays); + const transactions = await this.dataClient.getTransactions( + address, + tillToInSec, + ); + yield* this.filterTransactionsByContractAddress( + transactions, + contractAddress, + ); + } + + protected async *getTransactionsOnState( + address: string, + contractAddress: string, + ): AsyncGenerator { + const transactions = await this.txnStateMgr.findTransactions({ + senderAddress: [address], + chainId: [this.network.chainId], + finalityStatus: [TransactionFinalityStatus.RECEIVED], + // Exculde the transaction data that are not in the latest version, + // hence we dont have to migrate the data, as it can be retrieved from the chain eventually. + dataVersion: [TransactionDataVersion.V2], + }); + // FIXME: + // filter from state manager doesnt support OR condition (contractAddress match or it is a account deploy transaction), + // hence we have to filter it manually. + yield* this.filterTransactionsByContractAddress( + transactions, + contractAddress, + ); + } + + protected async *filterTransactionsByContractAddress( + transactions: Transaction[], + contractAddress: string, + ): AsyncGenerator { + for (const tx of transactions) { + // Only return transaction that are related to the contract address or a deployed transactions. + if (this.isTransactionHasSameContractOrAnDeploy(tx, contractAddress)) { + yield tx; + } + } + } + + protected isTransactionHasSameContractOrAnDeploy( + tx: Transaction, + contractAddress: string, + ) { + const isDeployTx = tx.txnType === TransactionType.DEPLOY_ACCOUNT; + const { accountCalls } = tx as V2Transaction; + const isSameContract = + accountCalls && + Object.prototype.hasOwnProperty.call(accountCalls, contractAddress); + return isDeployTx || isSameContract; + } + + /** + * Get the transactions by the given address and contract address. + * The transactions will also include the account deploy transaction. + * + * @param address - The account address. + * @param contractAddress - The contract address. + * @param tillToInDays - The filter includes the transaction till to the given days. + * @returns A promise that resolves to an array of transactions of the given address. + */ + public async getTransactions( + address: string, + contractAddress: string, + tillToInDays: number, + ): Promise { + const transactionsOnChain: Transaction[] = []; + const transactionsOnState: Transaction[] = []; + const transactionsToRemove: string[] = []; + const transactionsOnChainSet = new Set(); + + for await (const tx of this.getTransactionsOnChain( + address, + contractAddress, + tillToInDays, + )) { + transactionsOnChain.push(tx); + transactionsOnChainSet.add(tx.txnHash); + } + + for await (const tx of this.getTransactionsOnState( + address, + contractAddress, + )) { + // eslint-disable-next-line no-negated-condition + if (!transactionsOnChainSet.has(tx.txnHash)) { + transactionsOnState.push(tx); + } else { + transactionsToRemove.push(tx.txnHash); + } + } + + if (transactionsToRemove.length > 0) { + await this.txnStateMgr.removeTransactions({ + txnHash: transactionsToRemove, + }); + } + // Merge the transactions from state and chain. + // The transactions from state will be added first, then the transactions from chain. + return transactionsOnState.concat(transactionsOnChain); + } +} diff --git a/packages/starknet-snap/src/config.ts b/packages/starknet-snap/src/config.ts index abe5caa9..a508eced 100644 --- a/packages/starknet-snap/src/config.ts +++ b/packages/starknet-snap/src/config.ts @@ -30,6 +30,11 @@ export type SnapConfig = { apiKey: string | undefined; }; }; + transaction: { + list: { + txnsInLastNumOfDays: number; + }; + }; }; export enum DataClient { @@ -49,6 +54,12 @@ export const Config: SnapConfig = { STARKNET_SEPOLIA_TESTNET_NETWORK, ], + transaction: { + list: { + txnsInLastNumOfDays: 10, + }, + }, + explorer: { [constants.StarknetChainId.SN_MAIN]: // eslint-disable-next-line no-template-curly-in-string diff --git a/packages/starknet-snap/src/rpcs/__tests__/helper.ts b/packages/starknet-snap/src/rpcs/__tests__/helper.ts index 0aee47be..fe154833 100644 --- a/packages/starknet-snap/src/rpcs/__tests__/helper.ts +++ b/packages/starknet-snap/src/rpcs/__tests__/helper.ts @@ -14,7 +14,7 @@ import * as starknetUtils from '../../utils/starknetUtils'; * * @param chainId */ -export async function mockAccount(chainId: constants.StarknetChainId) { +export async function mockAccount(chainId: constants.StarknetChainId | string) { const accounts = await generateAccounts(chainId, 1); return accounts[0]; } diff --git a/packages/starknet-snap/src/rpcs/list-transaction.test.ts b/packages/starknet-snap/src/rpcs/list-transaction.test.ts new file mode 100644 index 00000000..880bb41a --- /dev/null +++ b/packages/starknet-snap/src/rpcs/list-transaction.test.ts @@ -0,0 +1,88 @@ +import type { constants } from 'starknet'; + +import { generateTransactions } from '../__tests__/helper'; +import type { IDataClient } from '../chain/data-client'; +import { TransactionService } from '../chain/transaction-service'; +import { Config } from '../config'; +import { + ETHER_SEPOLIA_TESTNET, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from '../utils/constants'; +import { InvalidRequestParamsError } from '../utils/exceptions'; +import * as factory from '../utils/factory'; +import { mockAccount } from './__tests__/helper'; +import { ListTransactions } from './list-transactions'; +import type { ListTransactionsParams } from './list-transactions'; + +jest.mock('../utils/logger'); + +describe('listTransactions', () => { + const prepareListTransactions = async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const chainId = network.chainId as unknown as constants.StarknetChainId; + const account = await mockAccount(chainId); + const transactions = generateTransactions({ + chainId, + address: account.address, + cnt: 10, + }); + + // Mock the factory to by pass the validation of create transaction service + jest.spyOn(factory, 'createTransactionService').mockReturnValue( + new TransactionService({ + network, + dataClient: {} as unknown as IDataClient, + }), + ); + + const getTransactionsSpy = jest.spyOn( + TransactionService.prototype, + 'getTransactions', + ); + getTransactionsSpy.mockResolvedValue(transactions); + + return { transactions, getTransactionsSpy, account, chainId }; + }; + + it('returns transactions', async () => { + const { transactions, getTransactionsSpy, chainId, account } = + await prepareListTransactions(); + + const result = await ListTransactions.execute({ + chainId, + senderAddress: account.address, + contractAddress: ETHER_SEPOLIA_TESTNET.address, + txnsInLastNumOfDays: 1, + }); + + expect(getTransactionsSpy).toHaveBeenCalledWith( + account.address, + ETHER_SEPOLIA_TESTNET.address, + 1, + ); + expect(result).toStrictEqual(transactions); + }); + + it('fetchs transactions with config value if input `txnsInLastNumOfDays` has not given', async () => { + const { getTransactionsSpy, chainId, account } = + await prepareListTransactions(); + + await ListTransactions.execute({ + chainId, + senderAddress: account.address, + contractAddress: ETHER_SEPOLIA_TESTNET.address, + }); + + expect(getTransactionsSpy).toHaveBeenCalledWith( + account.address, + ETHER_SEPOLIA_TESTNET.address, + Config.transaction.list.txnsInLastNumOfDays, + ); + }); + + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { + await expect( + ListTransactions.execute({} as unknown as ListTransactionsParams), + ).rejects.toThrow(InvalidRequestParamsError); + }); +}); diff --git a/packages/starknet-snap/src/rpcs/list-transactions.ts b/packages/starknet-snap/src/rpcs/list-transactions.ts new file mode 100644 index 00000000..81dbefc7 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/list-transactions.ts @@ -0,0 +1,75 @@ +import type { Infer } from 'superstruct'; +import { assign, max, optional, min, number, type, array } from 'superstruct'; + +import { Config } from '../config'; +import { AddressStruct, BaseRequestStruct, TransactionStruct } from '../utils'; +import { createTransactionService } from '../utils/factory'; +import { ChainRpcController } from './abstract/chain-rpc-controller'; + +export const ListTransactionsRequestStruct = assign( + // FIXME: Having type struct to enable backward compatibility. But it should be replaced by object() in future. + type({ + // The txnsInLastNumOfDays is optional, but it has to be between 1 and 365. + txnsInLastNumOfDays: optional(max(min(number(), 1), 365)), + senderAddress: AddressStruct, + contractAddress: AddressStruct, + }), + BaseRequestStruct, +); + +export const ListTransactionsResponseStruct = array(TransactionStruct); + +export type ListTransactionsParams = Infer< + typeof ListTransactionsRequestStruct +>; + +export type ListTransactionsResponse = Infer< + typeof ListTransactionsResponseStruct +>; + +/** + * The RPC handler to list the transactions by the given senderAddress, contractAddress. + */ +export class ListTransactionsRpc extends ChainRpcController< + ListTransactionsParams, + ListTransactionsResponse +> { + protected requestStruct = ListTransactionsRequestStruct; + + protected responseStruct = ListTransactionsResponseStruct; + + /** + * Execute the list transactions handler. + * + * @param params - The parameters of the request. + * @param params.chainId - The chain id of the transaction. + * @param params.senderAddress - The sender address of the transaction. + * @param params.contractAddress - The contract address of the transaction. + * @param params.txnsInLastNumOfDays - The number of days to get the transactions. + * @returns A promise that resolves to a ListTransactionsResponse object. + */ + async execute( + params: ListTransactionsParams, + ): Promise { + return super.execute(params); + } + + protected async handleRequest( + params: ListTransactionsParams, + ): Promise { + const { senderAddress, contractAddress, txnsInLastNumOfDays } = params; + const tillToInDay = + txnsInLastNumOfDays ?? Config.transaction.list.txnsInLastNumOfDays; + + const service = createTransactionService(this.network); + const transactions = await service.getTransactions( + senderAddress, + contractAddress, + tillToInDay, + ); + + return transactions as unknown as ListTransactionsResponse; + } +} + +export const ListTransactions = new ListTransactionsRpc(); diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index d139df1f..c719bcf0 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -1,11 +1,11 @@ import type { RawCalldata, - TransactionType as StarkNetTransactionType, - TransactionExecutionStatus, - TransactionFinalityStatus, EstimateFee, TransactionType as StarknetTransactionType, } from 'starknet'; +import type { Infer } from 'superstruct'; + +import type { TransactionStruct } from '../utils'; /* eslint-disable */ export type SnapState = { @@ -152,24 +152,7 @@ export enum ContractFuncName { Transfer = 'transfer', } -export type V2Transaction = { - txnHash: string; // in hex - txnType: StarkNetTransactionType; - chainId: string; // in hex - senderAddress: string; // in hex - contractAddress: string; // in hex - executionStatus?: TransactionExecutionStatus | string; - finalityStatus?: TransactionFinalityStatus | string; - failureReason: string; - timestamp: number; - maxFee?: string | null; - actualFee?: string | null; - // using Record to support O(1) searching - accountCalls?: Record | null; - version: number; - // Snap data Version to support backward compatibility , migration. - dataVersion: TransactionDataVersion.V2; -}; +export type V2Transaction = Infer; // FIXME: temp solution for backward compatibility before StarkScan implemented in get transactions export type Transaction = diff --git a/packages/starknet-snap/src/utils/factory.test.ts b/packages/starknet-snap/src/utils/factory.test.ts index 12466fac..c57b07bb 100644 --- a/packages/starknet-snap/src/utils/factory.test.ts +++ b/packages/starknet-snap/src/utils/factory.test.ts @@ -1,11 +1,12 @@ import { StarkScanClient } from '../chain/data-client/starkscan'; +import { TransactionService } from '../chain/transaction-service'; import { Config, DataClient } from '../config'; import { STARKNET_SEPOLIA_TESTNET_NETWORK } from './constants'; -import { createStarkScanClient } from './factory'; +import { createStarkScanClient, createTransactionService } from './factory'; -describe('createStarkScanClient', () => { - const config = Config.dataClient[DataClient.STARKSCAN]; +const config = Config.dataClient[DataClient.STARKSCAN]; +describe('createStarkScanClient', () => { it('creates a StarkScan client', () => { config.apiKey = 'API_KEY'; expect( @@ -20,3 +21,13 @@ describe('createStarkScanClient', () => { ).toThrow('Missing StarkScan API key'); }); }); + +describe('createTransactionService', () => { + it('creates a Transaction service', () => { + config.apiKey = 'API_KEY'; + expect( + createTransactionService(STARKNET_SEPOLIA_TESTNET_NETWORK), + ).toBeInstanceOf(TransactionService); + config.apiKey = undefined; + }); +}); diff --git a/packages/starknet-snap/src/utils/factory.ts b/packages/starknet-snap/src/utils/factory.ts index 41811241..0076a98a 100644 --- a/packages/starknet-snap/src/utils/factory.ts +++ b/packages/starknet-snap/src/utils/factory.ts @@ -1,6 +1,8 @@ import type { IDataClient } from '../chain/data-client'; import { StarkScanClient } from '../chain/data-client/starkscan'; +import { TransactionService } from '../chain/transaction-service'; import { Config, DataClient } from '../config'; +import type { TransactionStateManager } from '../state/transaction-state-manager'; import type { Network } from '../types/snapState'; /** @@ -23,3 +25,22 @@ export function createStarkScanClient(network: Network): IDataClient { return dataClient; } + +/** + * Create a TransactionService object. + * + * @param network - The network. + * @param [txnStateMgr] - The transaction state manager. + * @returns A TransactionService object. + */ +export function createTransactionService( + network: Network, + txnStateMgr?: TransactionStateManager, +): TransactionService { + const dataClient = createStarkScanClient(network); + return new TransactionService({ + dataClient, + network, + txnStateMgr, + }); +} diff --git a/packages/starknet-snap/src/utils/formatter-utils.test.ts b/packages/starknet-snap/src/utils/formatter-utils.test.ts index 35301df3..4c35081c 100644 --- a/packages/starknet-snap/src/utils/formatter-utils.test.ts +++ b/packages/starknet-snap/src/utils/formatter-utils.test.ts @@ -8,6 +8,8 @@ import { ETHER_SEPOLIA_TESTNET } from './constants'; import { callToTransactionReqCall, mapDeprecatedParams, + dayToSec, + msToSec, } from './formatter-utils'; import { logger } from './logger'; @@ -207,3 +209,21 @@ describe('callToTransactionReqCall', () => { }); }); }); + +describe('dayToSec', () => { + it('converts days to seconds', () => { + const days = 10; + const expected = days * 24 * 60 * 60; + + expect(dayToSec(days)).toBe(expected); + }); +}); + +describe('msToSec', () => { + it('converts milliseconds to seconds', () => { + const ms = Date.now(); + const expected = Math.floor(ms / 1000); + + expect(msToSec(ms)).toBe(expected); + }); +}); diff --git a/packages/starknet-snap/src/utils/formatter-utils.ts b/packages/starknet-snap/src/utils/formatter-utils.ts index f031fa1f..c40da0e2 100644 --- a/packages/starknet-snap/src/utils/formatter-utils.ts +++ b/packages/starknet-snap/src/utils/formatter-utils.ts @@ -93,3 +93,23 @@ export const callToTransactionReqCall = async ( } return formattedCall; }; + +/** + * Converts days to seconds. + * + * @param days - The number of days to convert. + * @returns The number of seconds in the given number of days. + */ +export function dayToSec(days: number): number { + return days * 24 * 60 * 60; +} + +/** + * Converts milliseconds to seconds. + * + * @param ms - The number of milliseconds to convert. + * @returns The number of seconds in the given number of milliseconds. + */ +export function msToSec(ms: number): number { + return Math.floor(ms / 1000); +} diff --git a/packages/starknet-snap/src/utils/superstruct.test.ts b/packages/starknet-snap/src/utils/superstruct.test.ts index 44c8eced..d7b84dd7 100644 --- a/packages/starknet-snap/src/utils/superstruct.test.ts +++ b/packages/starknet-snap/src/utils/superstruct.test.ts @@ -4,6 +4,7 @@ import { StructError, assert } from 'superstruct'; import contractExample from '../__tests__/fixture/contract-example.json'; import transactionExample from '../__tests__/fixture/transactionExample.json'; import typedDataExample from '../__tests__/fixture/typedDataExample.json'; +import { generateTransactions } from '../__tests__/helper'; import { ACCOUNT_CLASS_HASH, CAIRO_VERSION, @@ -27,6 +28,7 @@ import { ChainIdStruct, TokenSymbolStruct, TokenNameStruct, + TransactionStruct, } from './superstruct'; describe('TokenNameStruct', () => { @@ -531,3 +533,31 @@ describe('InvocationsStruct', () => { ); }); }); + +describe('TransactionStruct', () => { + it('does not throw error if the transaction is valid', () => { + const [transaction] = generateTransactions({ + chainId: constants.StarknetChainId.SN_SEPOLIA, + address: + '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd', + }); + expect(() => assert(transaction, TransactionStruct)).not.toThrow(); + }); + + it('throws error if the transaction is invalid', () => { + const [transaction] = generateTransactions({ + chainId: constants.StarknetChainId.SN_SEPOLIA, + address: + '0x04882a372da3dfe1c53170ad75893832469bf87b62b13e84662565c4a88f25cd', + }); + expect(() => + assert( + { + ...transaction, + txnType: 'invalid txn type', + }, + TransactionStruct, + ), + ).toThrow(StructError); + }); +}); diff --git a/packages/starknet-snap/src/utils/superstruct.ts b/packages/starknet-snap/src/utils/superstruct.ts index b431e5c0..3df68715 100644 --- a/packages/starknet-snap/src/utils/superstruct.ts +++ b/packages/starknet-snap/src/utils/superstruct.ts @@ -6,7 +6,13 @@ import type { Invocations, UniversalDetails, } from 'starknet'; -import { constants, TransactionType, validateAndParseAddress } from 'starknet'; +import { + constants, + TransactionType, + validateAndParseAddress, + TransactionFinalityStatus, + TransactionExecutionStatus, +} from 'starknet'; import type { Struct } from 'superstruct'; import { boolean, @@ -25,8 +31,11 @@ import { validate, nonempty, unknown, + empty, + nullable, } from 'superstruct'; +import { TransactionDataVersion } from '../types/snapState'; import { CAIRO_VERSION_LEGACY, CAIRO_VERSION, @@ -78,6 +87,16 @@ export const AddressStruct = refine( }, ); +export const TransactionFinalityStatusStruct = enums( + Object.values(TransactionFinalityStatus), +); + +export const TransactionExecutionStatusStruct = enums( + Object.values(TransactionExecutionStatus), +); + +export const TransactionTypeStruct = enums(Object.values(TransactionType)); + export const ChainIdStruct = enums(Object.values(constants.StarknetChainId)); export const TypeDataStarknetTypeStruct = union([ @@ -287,12 +306,7 @@ export const BaseInvocationStruct = object({ // lets not accept optaional payload to reduce the complexity of the struct // as the snap control the input payload: unknown(), - type: enums([ - TransactionType.DECLARE, - TransactionType.DEPLOY, - TransactionType.DEPLOY_ACCOUNT, - TransactionType.INVOKE, - ]), + type: TransactionTypeStruct, }); export const CallsStruct = define( @@ -381,3 +395,34 @@ export const UniversalDetailsStruct = define( ); }, ); + +export const TransactionStruct = object({ + txnHash: HexStruct, + txnType: TransactionTypeStruct, + chainId: string(), + senderAddress: union([AddressStruct, empty(string())]), + contractAddress: union([AddressStruct, empty(string())]), + executionStatus: union([TransactionExecutionStatusStruct, string()]), + finalityStatus: union([TransactionFinalityStatusStruct, string()]), + failureReason: string(), + timestamp: number(), + maxFee: nullable(string()), + actualFee: nullable(string()), + accountCalls: nullable( + record( + HexStruct, + array( + object({ + contract: HexStruct, + contractFuncName: string(), + contractCallData: array(string()), + recipient: optional(string()), + amount: optional(string()), + }), + ), + ), + ), + version: number(), + // Snap data Version to support backward compatibility , migration. + dataVersion: enums(Object.values(TransactionDataVersion)), +}); From 29f7c5f21fa490b1663dd69864cd0c4f27395487 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:19:20 +0800 Subject: [PATCH 27/36] chore: update list transactions handle --- packages/starknet-snap/src/index.tsx | 7 +++++-- packages/starknet-snap/src/rpcs/index.ts | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/starknet-snap/src/index.tsx b/packages/starknet-snap/src/index.tsx index 59c40a70..999f0d93 100644 --- a/packages/starknet-snap/src/index.tsx +++ b/packages/starknet-snap/src/index.tsx @@ -19,7 +19,6 @@ import { getStarkName } from './getStarkName'; import { getStoredErc20Tokens } from './getStoredErc20Tokens'; import { getStoredNetworks } from './getStoredNetworks'; import { getStoredUserAccounts } from './getStoredUserAccounts'; -import { getTransactions } from './getTransactions'; import { getValue } from './getValue'; import { homePageController } from './on-home-page'; import { recoverAccounts } from './recoverAccounts'; @@ -37,6 +36,7 @@ import type { WatchAssetParams, GetAddrFromStarkNameParams, GetTransactionStatusParams, + ListTransactionsParams, } from './rpcs'; import { displayPrivateKey, @@ -52,6 +52,7 @@ import { watchAsset, getAddrFromStarkName, getTransactionStatus, + ListTransactions, } from './rpcs'; import { signDeployAccountTransaction } from './signDeployAccountTransaction'; import type { @@ -239,7 +240,9 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ return await getStoredNetworks(apiParams); case RpcMethod.GetTransactions: - return await getTransactions(apiParams); + return await ListTransactions.execute( + apiParams.requestParams as unknown as ListTransactionsParams, + ); case RpcMethod.RecoverAccounts: apiParams.keyDeriver = await getAddressKeyDeriver(snap); diff --git a/packages/starknet-snap/src/rpcs/index.ts b/packages/starknet-snap/src/rpcs/index.ts index 4e08d3f7..82e2e623 100644 --- a/packages/starknet-snap/src/rpcs/index.ts +++ b/packages/starknet-snap/src/rpcs/index.ts @@ -11,3 +11,4 @@ export * from './get-deployment-data'; export * from './watch-asset'; export * from './get-addr-from-starkname'; export * from './get-transaction-status'; +export * from './list-transactions'; From 8b8d4b6a6648dbbe69fd2aad7575b829d9b0ef00 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 10 Dec 2024 10:58:24 +0800 Subject: [PATCH 28/36] chore: refactor execute txn --- .../src/rpcs/execute-txn.test.ts | 897 +++++++++++++----- .../starknet-snap/src/rpcs/execute-txn.ts | 424 +++++---- .../src/state/transaction-state-manager.ts | 105 +- .../starknet-snap/src/utils/transaction.ts | 45 + 4 files changed, 1042 insertions(+), 429 deletions(-) create mode 100644 packages/starknet-snap/src/utils/transaction.ts diff --git a/packages/starknet-snap/src/rpcs/execute-txn.test.ts b/packages/starknet-snap/src/rpcs/execute-txn.test.ts index 56da650c..0a286833 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.test.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.test.ts @@ -1,329 +1,708 @@ -import type { UniversalDetails, Call, InvokeFunctionResponse } from 'starknet'; -import { constants } from 'starknet'; +import type { Call } from 'starknet'; +import { constants, TransactionType } from 'starknet'; +import { v4 as uuidv4 } from 'uuid'; -import callsExamples from '../__tests__/fixture/callsExamples.json'; // Assuming you have a similar fixture -import { generateEstimateFeesResponse } from '../__tests__/helper'; +import callsExamples from '../__tests__/fixture/callsExamples.json'; import { mockTransactionRequestStateManager } from '../state/__tests__/helper'; -import type { FeeTokenUnit } from '../types/snapApi'; -import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { AccountStateManager } from '../state/account-state-manager'; +import { TransactionStateManager } from '../state/transaction-state-manager'; +import { FeeToken } from '../types/snapApi'; +import type { + FormattedCallData, + Network, + TransactionRequest, +} from '../types/snapState'; +import * as uiUtils from '../ui/utils'; +import { + CAIRO_VERSION, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from '../utils/constants'; import { UserRejectedOpError, InvalidRequestParamsError, } from '../utils/exceptions'; +import * as formatUtils from '../utils/formatter-utils'; import * as starknetUtils from '../utils/starknetUtils'; -import { executeTxn as executeTxnUtil } from '../utils/starknetUtils'; import { - generateRandomFee, + feeTokenToTransactionVersion, + transactionVersionToFeeToken, + transactionVersionToNumber, +} from '../utils/transaction'; +import { mockAccount, + mockGetEstimatedFeesResponse, prepareConfirmDialogInteractiveUI, prepareMockAccount, } from './__tests__/helper'; -import type { ExecuteTxnParams } from './execute-txn'; -import { executeTxn } from './execute-txn'; +import type { + ConfirmTransactionParams, + DeployAccountParams, + ExecuteTxnParams, + SaveDataToStateParamas, + SendTransactionParams, +} from './execute-txn'; +import { executeTxn, ExecuteTxnRpc } from './execute-txn'; jest.mock('../utils/snap'); jest.mock('../utils/logger'); -const prepareMockExecuteTxn = async ( - transactionHash: string, - calls: Call[] | Call, - details: UniversalDetails, - accountDeployed: boolean, -) => { +/* eslint-disable @typescript-eslint/naming-convention */ +class MockExecuteTxnRpc extends ExecuteTxnRpc { + public async confirmTransaction( + arg: ConfirmTransactionParams, + ): Promise { + return super.confirmTransaction(arg); + } + + public async preExecute(arg: ExecuteTxnParams): Promise { + await super.preExecute(arg); + } + + public async deployAccount(arg: DeployAccountParams): Promise { + return super.deployAccount(arg); + } + + public async sendTransaction(arg: SendTransactionParams): Promise { + return super.sendTransaction(arg); + } + + public async saveDataToState(arg: SaveDataToStateParamas): Promise { + return super.saveDataToState(arg); + } +} + +const generateAccount = async (network) => { const state = { accContracts: [], erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + networks: [network], transactions: [], }; - const { confirmDialogSpy } = prepareConfirmDialogInteractiveUI(); - const account = await mockAccount(constants.StarknetChainId.SN_SEPOLIA); + const account = await mockAccount(network); prepareMockAccount(account, state); - const request: ExecuteTxnParams = { - chainId: state.networks[0].chainId, - address: account.address, - calls, - details, - } as ExecuteTxnParams; - - const executeTxnRespMock = { - // eslint-disable-next-line @typescript-eslint/naming-convention - transaction_hash: transactionHash, - }; - - const estimateResults = generateEstimateFeesResponse(); + return account; +}; - const getEstimatedFeesRepsMock = { - suggestedMaxFee: generateRandomFee('1000000000000000', '2000000000000000'), - overallFee: generateRandomFee('1000000000000000', '2000000000000000'), - includeDeploy: !accountDeployed, - unit: 'wei' as FeeTokenUnit, - estimateResults, - resourceBounds: estimateResults[0].resourceBounds, - }; +const createMockRpc = () => { + const rpc = new MockExecuteTxnRpc({ + showInvalidAccountAlert: true, + }); + return rpc; +}; - const getEstimatedFeesSpy = jest.spyOn(starknetUtils, 'getEstimatedFees'); - getEstimatedFeesSpy.mockResolvedValue(getEstimatedFeesRepsMock); +const setupMockRpc = async (network: Network, calls: Call[]) => { + const account = await generateAccount(network); - const executeTxnUtilSpy = jest.spyOn(starknetUtils, 'executeTxn'); - executeTxnUtilSpy.mockResolvedValue(executeTxnRespMock); + const rpc = createMockRpc(); - const createAccountSpy = jest.spyOn(starknetUtils, 'createAccount'); - createAccountSpy.mockResolvedValue({ - transactionHash: - '0x07f901c023bac6c874691244c4c2332c6825b916fb68d240c807c6156db84fd3', + // Setup the rpc, to discover the account and network + await rpc.preExecute({ + chainId: network.chainId, address: account.address, - }); - - const createInvokeTxnSpy = jest.spyOn(executeTxn as any, 'createInvokeTxn'); + calls, + } as unknown as ExecuteTxnParams); return { - network: state.networks[0], + rpc, account, - request, - confirmDialogSpy, - createAccountSpy, - createInvokeTxnSpy, - executeTxnRespMock, - executeTxnUtilSpy, - getEstimatedFeesSpy, - getEstimatedFeesRepsMock, - ...mockTransactionRequestStateManager(), }; }; -describe('ExecuteTxn', () => { - it('executes transaction correctly if the account is deployed', async () => { - const calls = callsExamples.multipleCalls; - const { - account, - createAccountSpy, - executeTxnRespMock, - getEstimatedFeesSpy, - getEstimatedFeesRepsMock, - upsertTransactionRequestSpy, - getTransactionRequestSpy, - request, - } = await prepareMockExecuteTxn( - calls.hash, - calls.calls, - calls.details, - true, +const mockCallToTransactionReqCall = (calls: Call[]) => { + const callToTransactionReqCallSpy = jest.spyOn( + formatUtils, + 'callToTransactionReqCall', + ); + const formattedCalls: FormattedCallData[] = []; + for (const call of calls) { + formattedCalls.push({ + contractAddress: call.contractAddress, + calldata: call.calldata as unknown as string[], + entrypoint: call.entrypoint, + }); + callToTransactionReqCallSpy.mockResolvedValueOnce( + formattedCalls[formattedCalls.length - 1], ); + } + return { + callToTransactionReqCallSpy, + formattedCalls, + }; +}; - const result = await executeTxn.execute(request); - - expect(result).toStrictEqual(executeTxnRespMock); - expect(executeTxnUtil).toHaveBeenCalledWith( - STARKNET_SEPOLIA_TESTNET_NETWORK, - account.address, - account.privateKey, - request.calls, - undefined, - { - ...calls.details, - maxFee: getEstimatedFeesRepsMock.suggestedMaxFee, - resourceBounds: - getEstimatedFeesRepsMock.estimateResults[0].resourceBounds, - }, - ); - expect(getEstimatedFeesSpy).toHaveBeenCalled(); - expect(createAccountSpy).not.toHaveBeenCalled(); - expect(upsertTransactionRequestSpy).toHaveBeenCalled(); - expect(getTransactionRequestSpy).toHaveBeenCalled(); - }); +const mockGenerateExecuteTxnFlow = () => { + const generateExecuteTxnFlowSpy = jest.spyOn( + uiUtils, + 'generateExecuteTxnFlow', + ); + const interfaceId = uuidv4(); + generateExecuteTxnFlowSpy.mockResolvedValue(interfaceId); + return { + interfaceId, + generateExecuteTxnFlowSpy, + }; +}; - it.each([ - { - calls: callsExamples.multipleCalls, - testCaseTitle: 'an array of call object', - }, - { - calls: callsExamples.singleCall, - testCaseTitle: 'a call object', - }, - ])( - 'stores transaction in state correctly if the params `calls` is $testCaseTitle', - async ({ calls }: { calls: any }) => { - const call = Array.isArray(calls.calls) ? calls.calls[0] : calls.calls; +describe('ExecuteTxn', () => { + describe('confirmTransaction', () => { + const prepareConfirmTransaction = async (confirm = true) => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const includeDeploy = true; + const txnVersion = constants.TRANSACTION_VERSION.V3; + const { calls } = callsExamples.multipleCalls; + + const { account, rpc } = await setupMockRpc(network, calls); const { + getEstimatedFeesResponse: { suggestedMaxFee: maxFee, resourceBounds }, + } = mockGetEstimatedFeesResponse({ + includeDeploy: false, + }); + + const request = { + calls, + address: account.address, + maxFee, + resourceBounds, + txnVersion, + includeDeploy, + }; + + return { + request, + rpc, + network, account, - createAccountSpy, - createInvokeTxnSpy, - executeTxnRespMock, - getEstimatedFeesSpy, - getEstimatedFeesRepsMock, + maxFee, + resourceBounds, + txnVersion, + includeDeploy, + ...prepareConfirmDialogInteractiveUI(confirm), + ...mockCallToTransactionReqCall(calls), + ...mockGenerateExecuteTxnFlow(), + ...mockTransactionRequestStateManager(), + }; + }; + + it('returns the `TransactionRequest` object and remove it from state', async () => { + const { request, - } = await prepareMockExecuteTxn( - calls.hash, - calls.calls, - calls.details, - true, + rpc, + interfaceId, + account: { address }, + formattedCalls, + maxFee, + resourceBounds, + txnVersion, + includeDeploy, + network: { chainId, name: networkName }, + upsertTransactionRequestSpy, + confirmDialogSpy, + getTransactionRequestSpy, + removeTransactionRequestSpy, + } = await prepareConfirmTransaction(); + + const result = await rpc.confirmTransaction(request); + + const expectedTransactionRequest = { + chainId, + networkName, + id: expect.any(String), + interfaceId, + type: TransactionType.INVOKE, + signer: address, + addressIndex: 0, + maxFee, + calls: formattedCalls, + resourceBounds, + selectedFeeToken: transactionVersionToFeeToken(txnVersion), + includeDeploy, + }; + expect(result).toStrictEqual(expectedTransactionRequest); + expect(upsertTransactionRequestSpy).toHaveBeenCalledWith( + expectedTransactionRequest, + ); + expect(confirmDialogSpy).toHaveBeenCalledWith(interfaceId); + expect(getTransactionRequestSpy).toHaveBeenCalledWith({ + requestId: expect.any(String), + }); + expect(removeTransactionRequestSpy).toHaveBeenCalledWith( + expect.any(String), ); + }); - const result = await executeTxn.execute(request); + it('does not throw an error if remove request failed', async () => { + const { request, rpc, removeTransactionRequestSpy } = + await prepareConfirmTransaction(); - expect(result).toStrictEqual(executeTxnRespMock); - expect(executeTxnUtil).toHaveBeenCalledWith( - STARKNET_SEPOLIA_TESTNET_NETWORK, - account.address, - account.privateKey, - request.calls, - undefined, - { - ...calls.details, - maxFee: getEstimatedFeesRepsMock.suggestedMaxFee, - resourceBounds: - getEstimatedFeesRepsMock.estimateResults[0].resourceBounds, - }, - ); - expect(getEstimatedFeesSpy).toHaveBeenCalled(); - expect(createAccountSpy).not.toHaveBeenCalled(); - expect(createInvokeTxnSpy).toHaveBeenCalledWith( - account.address, - calls.hash, - call, + removeTransactionRequestSpy.mockRejectedValue( + new Error('Failed to remove request'), ); - }, - ); - it.each([constants.TRANSACTION_VERSION.V1, constants.TRANSACTION_VERSION.V3])( - 'creates an account and execute the transaction with nonce 1 with transaction version %s if the account is not deployed', - async (transactionVersion) => { - const calls = callsExamples.multipleCalls; + // if any error occurs, it should not throw an error + await rpc.confirmTransaction(request); + + expect(removeTransactionRequestSpy).toHaveBeenCalled(); + }); + + it("throws `Failed to retrieve the updated transaction request` error if the transaction request can't be found after updated.", async () => { const { - account, - createAccountSpy, - executeTxnUtilSpy, - getEstimatedFeesSpy, - getEstimatedFeesRepsMock, + request, + rpc, + getTransactionRequestSpy, + removeTransactionRequestSpy, + } = await prepareConfirmTransaction(); + + getTransactionRequestSpy.mockResolvedValue(null); + + await expect(rpc.confirmTransaction(request)).rejects.toThrow( + 'Failed to retrieve the updated transaction request', + ); + + expect(removeTransactionRequestSpy).toHaveBeenCalledWith( + expect.any(String), + ); + }); + + it('throws UserRejectedOpError if user denied the operation', async () => { + const { request, rpc } = await prepareConfirmTransaction(false); + + await expect(rpc.confirmTransaction(request)).rejects.toThrow( + UserRejectedOpError, + ); + }); + }); + + describe('deployAccount', () => { + const prepareDeployAccount = async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const txnVersion = constants.TRANSACTION_VERSION.V3; + const { calls } = callsExamples.multipleCalls; + const { account, rpc } = await setupMockRpc(network, calls); + + const deployAccountSpy = jest.spyOn(starknetUtils, 'deployAccount'); + const deployAccountResponse = { + contract_address: account.address, + transaction_hash: callsExamples.multipleCalls.hash, + }; + deployAccountSpy.mockResolvedValue(deployAccountResponse); + + const request = { + address: account.address, + txnVersion, + }; + + const accountDeploymentData = starknetUtils.getDeployAccountCallData( + account.publicKey, + CAIRO_VERSION, + ); + + return { + accountDeploymentData, + request, + rpc, network, + account, + deployAccountSpy, + deployAccountResponse, + }; + }; + + it('deploys an account', async () => { + const { + rpc, request, - } = await prepareMockExecuteTxn( - calls.hash, - calls.calls, - { - ...calls.details, - version: transactionVersion, + network, + account: { address, privateKey, publicKey }, + deployAccountResponse, + deployAccountSpy, + accountDeploymentData, + } = await prepareDeployAccount(); + + const result = await rpc.deployAccount(request); + + expect(result).toStrictEqual(deployAccountResponse.transaction_hash); + expect(deployAccountSpy).toHaveBeenCalledWith( + network, + address, + accountDeploymentData, + publicKey, + privateKey, + CAIRO_VERSION, + { version: request.txnVersion }, + ); + }); + + it('throws `Failed to deploy account` error if the execution transaction hash is empty', async () => { + const { rpc, request, deployAccountSpy, deployAccountResponse } = + await prepareDeployAccount(); + deployAccountSpy.mockResolvedValue({ + ...deployAccountResponse, + transaction_hash: '', + }); + + await expect(rpc.deployAccount(request)).rejects.toThrow( + 'Failed to deploy account', + ); + }); + }); + + describe('sendTransaction', () => { + const prepareConfirmTransaction = async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const txnVersion = constants.TRANSACTION_VERSION.V3; + const { calls } = callsExamples.multipleCalls; + + const { account, rpc } = await setupMockRpc(network, calls); + + const executeTxnSpy = jest.spyOn(starknetUtils, 'executeTxn'); + const executeTxnResponse = { + transaction_hash: callsExamples.multipleCalls.hash, + }; + executeTxnSpy.mockResolvedValue(executeTxnResponse); + + const request: SendTransactionParams = { + calls, + address: account.address, + abis: undefined, + details: { + version: txnVersion, }, - false, + }; + + return { + request, + rpc, + network, + account, + executeTxnSpy, + executeTxnResponse, + }; + }; + + it('execute a transaction and return the transaction hash', async () => { + const { + rpc, + request, + network, + account: { privateKey }, + executeTxnResponse, + executeTxnSpy, + } = await prepareConfirmTransaction(); + + const result = await rpc.sendTransaction(request); + + expect(result).toStrictEqual(executeTxnResponse.transaction_hash); + expect(executeTxnSpy).toHaveBeenCalledWith( + network, + request.address, + privateKey, + request.calls, + request.abis, + request.details, + ); + }); + + it('throws `Failed to execute transaction` error if the execution transaction hash is empty', async () => { + const { rpc, request, executeTxnSpy } = await prepareConfirmTransaction(); + executeTxnSpy.mockResolvedValue({ transaction_hash: '' }); + + await expect(rpc.sendTransaction(request)).rejects.toThrow( + 'Failed to execute transaction', + ); + }); + }); + + describe('execute', () => { + const prepareExecute = async (accountDeployed = true) => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const account = await generateAccount(network); + + const { getEstimatedFeesResponse, getEstimatedFeesSpy } = + mockGetEstimatedFeesResponse({ + includeDeploy: !accountDeployed, + }); + const { suggestedMaxFee, resourceBounds } = getEstimatedFeesResponse; + + const confirmTransactionSpy = jest.spyOn( + MockExecuteTxnRpc.prototype, + 'confirmTransaction', + ); + const transactionRequest = { + selectedFeeToken: FeeToken.STRK, + maxFee: suggestedMaxFee, + resourceBounds, + } as unknown as TransactionRequest; + confirmTransactionSpy.mockResolvedValue(transactionRequest); + + const sendTansactionResponse = callsExamples.multipleCalls.hash; + const sendTransactionSpy = jest.spyOn( + MockExecuteTxnRpc.prototype, + 'sendTransaction', ); + sendTransactionSpy.mockResolvedValue(sendTansactionResponse); - await executeTxn.execute(request); + const deployAccountResponse = callsExamples.singleCall.hash; + const deployAccountSpy = jest.spyOn( + MockExecuteTxnRpc.prototype, + 'deployAccount', + ); + deployAccountSpy.mockResolvedValue(deployAccountResponse); + + const saveDataToStateSpy = jest.spyOn( + MockExecuteTxnRpc.prototype, + 'saveDataToState', + ); + saveDataToStateSpy.mockReturnThis(); - expect(getEstimatedFeesSpy).toHaveBeenCalled(); - expect(createAccountSpy).toHaveBeenCalledTimes(1); - expect(createAccountSpy).toHaveBeenCalledWith({ + const rpc = createMockRpc(); + const request: ExecuteTxnParams = { + chainId: network.chainId, address: account.address, - callback: expect.any(Function), + calls: callsExamples.multipleCalls.calls, + details: callsExamples.multipleCalls.details, + } as unknown as ExecuteTxnParams; + + return { + rpc, + account, + request, network, - privateKey: account.privateKey, - publicKey: account.publicKey, - version: transactionVersion, - waitMode: false, + getEstimatedFeesSpy, + getEstimatedFeesResponse, + confirmTransactionSpy, + sendTransactionSpy, + transactionRequest, + deployAccountResponse, + sendTansactionResponse, + deployAccountSpy, + saveDataToStateSpy, + }; + }; + + it('executes a transaction and return the transaction hash', async () => { + const { + rpc, + request, + sendTansactionResponse, + sendTransactionSpy, + account: { address }, + getEstimatedFeesResponse, + confirmTransactionSpy, + deployAccountSpy, + saveDataToStateSpy, + transactionRequest, + } = await prepareExecute(); + const updatedTxnVersion = feeTokenToTransactionVersion( + transactionRequest.selectedFeeToken, + ); + const { maxFee: updatedMaxFee, resourceBounds: updatedResourceBounds } = + transactionRequest; + const { + suggestedMaxFee: maxFee, + resourceBounds, + includeDeploy, + } = getEstimatedFeesResponse; + const { calls, abis, details } = request; + + const result = await rpc.execute(request); + + expect(result).toStrictEqual({ + transaction_hash: sendTansactionResponse, }); - expect(executeTxnUtilSpy).toHaveBeenCalledWith( - network, - account.address, - account.privateKey, - calls.calls, - undefined, - { - ...calls.details, - version: transactionVersion, - maxFee: getEstimatedFeesRepsMock.suggestedMaxFee, - nonce: 1, - resourceBounds: - getEstimatedFeesRepsMock.estimateResults[0].resourceBounds, + expect(confirmTransactionSpy).toHaveBeenCalledWith({ + txnVersion: details?.version, + address, + calls, + maxFee, + resourceBounds, + includeDeploy, + }); + expect(deployAccountSpy).not.toHaveBeenCalled(); + expect(sendTransactionSpy).toHaveBeenCalledWith({ + address, + calls, + abis, + details: { + ...details, + version: updatedTxnVersion, + maxFee: updatedMaxFee, + resourceBounds: updatedResourceBounds, }, - ); - }, - ); + }); + expect(saveDataToStateSpy).toHaveBeenCalledWith({ + txnHashForDeploy: undefined, + txnHashForExecute: sendTansactionResponse, + txnVersion: updatedTxnVersion, + maxFee: updatedMaxFee, + address, + calls, + }); + }); - it('throws `Failed to retrieve the updated transaction request` error the transaction request can not retrieve after confirmation', async () => { - const calls = callsExamples.multipleCalls; - const { getTransactionRequestSpy, request } = await prepareMockExecuteTxn( - calls.hash, - calls.calls, - calls.details, - true, - ); + it('executes a transaction and return the transaction hash with deploy account', async () => { + const { + rpc, + request, + sendTansactionResponse, + account: { address }, + deployAccountResponse, + deployAccountSpy, + saveDataToStateSpy, + transactionRequest, + sendTransactionSpy, + } = await prepareExecute(false); + const updatedTxnVersion = feeTokenToTransactionVersion( + transactionRequest.selectedFeeToken, + ); + const { maxFee: updatedMaxFee, resourceBounds: updatedResourceBounds } = + transactionRequest; + const { calls, abis, details } = request; - getTransactionRequestSpy.mockResolvedValue(null); + const result = await rpc.execute(request); - await expect(executeTxn.execute(request)).rejects.toThrow( - 'Failed to retrieve the updated transaction request', - ); + expect(result).toStrictEqual({ + transaction_hash: sendTansactionResponse, + }); + expect(deployAccountSpy).toHaveBeenCalledWith({ + address, + txnVersion: updatedTxnVersion, + }); + expect(sendTransactionSpy).toHaveBeenCalledWith({ + address, + calls, + abis, + details: { + ...details, + nonce: 1, + version: updatedTxnVersion, + maxFee: updatedMaxFee, + resourceBounds: updatedResourceBounds, + }, + }); + expect(saveDataToStateSpy).toHaveBeenCalledWith({ + txnHashForDeploy: deployAccountResponse, + txnHashForExecute: sendTansactionResponse, + txnVersion: updatedTxnVersion, + maxFee: updatedMaxFee, + address, + calls, + }); + }); }); - it.each([ - { - executeTxnResult: callsExamples.multipleCalls.hash, - testCase: 'the transaction executed successfully', - }, - { - // Simulate the case where the transaction execution failed and does not return a transaction hash - // An error `Failed to execute transaction` will be thrown in this case - executeTxnResult: '', - testCase: 'the transaction failed to execute', - }, - ])( - 'removes the transaction request from state if $testCase.', - async ({ executeTxnResult }) => { - const calls = callsExamples.multipleCalls; - const { executeTxnUtilSpy, removeTransactionRequestSpy, request } = - await prepareMockExecuteTxn( - executeTxnResult, - calls.calls, - calls.details, - true, - ); - - executeTxnUtilSpy.mockResolvedValue({ - // eslint-disable-next-line @typescript-eslint/naming-convention - transaction_hash: executeTxnResult, + describe('saveDataToState', () => { + const prepareSaveDataToState = async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const txnVersion = constants.TRANSACTION_VERSION.V3; + const { hash: txnHashForExecute, calls } = callsExamples.multipleCalls; + const { hash: txnHashForDeploy } = callsExamples.singleCall; + + const { rpc, account } = await setupMockRpc(network, calls); + const { + getEstimatedFeesResponse: { suggestedMaxFee: maxFee }, + } = mockGetEstimatedFeesResponse({ + includeDeploy: false, }); - try { - await executeTxn.execute(request); - } catch (error) { - // eslint-disable-next-line no-empty - } finally { - expect(removeTransactionRequestSpy).toHaveBeenCalled(); - } - }, - ); + const addTransactionSpy = jest.spyOn( + TransactionStateManager.prototype, + 'addTransaction', + ); + addTransactionSpy.mockReturnThis(); - it('throws UserRejectedOpError if user cancels execution', async () => { - const calls = callsExamples.multipleCalls; - const { request, confirmDialogSpy } = await prepareMockExecuteTxn( - calls.hash, - calls.calls, - calls.details, - true, - ); - confirmDialogSpy.mockResolvedValue(false); + const updateAccountAsDeploySpy = jest.spyOn( + AccountStateManager.prototype, + 'updateAccountAsDeploy', + ); + updateAccountAsDeploySpy.mockReturnThis(); - await expect(executeTxn.execute(request)).rejects.toThrow( - UserRejectedOpError, - ); - }); + const request: SaveDataToStateParamas = { + txnHashForDeploy, + txnHashForExecute, + txnVersion, + maxFee, + address: account.address, + calls, + } as unknown as SaveDataToStateParamas; + + const txnMgr = new TransactionStateManager(true); + const newInvokeTransaction = txnMgr.newInvokeTransaction({ + senderAddress: account.address, + txnHash: request.txnHashForExecute, + chainId: network.chainId, + maxFee, + txnVersion: transactionVersionToNumber(txnVersion), + calls, + }); + const newDeployTransaction = txnMgr.newDeployTransaction({ + senderAddress: account.address, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + txnHash: request.txnHashForDeploy!, + chainId: network.chainId, + txnVersion: transactionVersionToNumber(txnVersion), + }); - it('throws `Failed to execute transaction` when the transaction hash is not returned from executeTxnUtil', async () => { - const calls = callsExamples.multipleCalls; - const { request, executeTxnUtilSpy } = await prepareMockExecuteTxn( - calls.hash, - calls.calls, - calls.details, - true, - ); - executeTxnUtilSpy.mockResolvedValue( - {} as unknown as InvokeFunctionResponse, - ); + return { + rpc, + account, + request, + network, + newInvokeTransaction, + newDeployTransaction, + addTransactionSpy, + updateAccountAsDeploySpy, + }; + }; + + it('saves a invoke transaction if `txnHashForDeploy` has not given', async () => { + const { + rpc, + request, + addTransactionSpy, + updateAccountAsDeploySpy, + newInvokeTransaction, + } = await prepareSaveDataToState(); + + await rpc.saveDataToState({ + ...request, + txnHashForDeploy: undefined, + }); + + expect(addTransactionSpy).toHaveBeenCalledWith(newInvokeTransaction); + expect(updateAccountAsDeploySpy).not.toHaveBeenCalled(); + }); + + it('saves a deploy transaction and a invoke transaction', async () => { + const { + rpc, + request, + addTransactionSpy, + network: { chainId }, + account: { address }, + updateAccountAsDeploySpy, + newInvokeTransaction, + newDeployTransaction, + } = await prepareSaveDataToState(); + + await rpc.saveDataToState(request); + + expect(addTransactionSpy).toHaveBeenNthCalledWith( + 1, + newDeployTransaction, + ); + expect(addTransactionSpy).toHaveBeenNthCalledWith( + 2, + newInvokeTransaction, + ); - await expect(executeTxn.execute(request)).rejects.toThrow(Error); + expect(updateAccountAsDeploySpy).toHaveBeenCalledWith({ + address, + chainId, + transactionHash: newDeployTransaction.txnHash, + }); + }); }); it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { @@ -332,3 +711,5 @@ describe('ExecuteTxn', () => { ).rejects.toThrow(InvalidRequestParamsError); }); }); + +/* eslint-enable */ diff --git a/packages/starknet-snap/src/rpcs/execute-txn.ts b/packages/starknet-snap/src/rpcs/execute-txn.ts index 2e82eedf..3b560f75 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.ts @@ -1,6 +1,6 @@ import { type Json } from '@metamask/snaps-sdk'; -import type { Call, Calldata } from 'starknet'; -import { constants, TransactionStatus, TransactionType } from 'starknet'; +import type { Call, constants } from 'starknet'; +import { TransactionType } from 'starknet'; import type { Infer } from 'superstruct'; import { object, string, assign, optional, any } from 'superstruct'; import { v4 as uuidv4 } from 'uuid'; @@ -9,9 +9,7 @@ import { AccountStateManager } from '../state/account-state-manager'; import { TransactionRequestStateManager } from '../state/request-state-manager'; import { TokenStateManager } from '../state/token-state-manager'; import { TransactionStateManager } from '../state/transaction-state-manager'; -import { FeeToken } from '../types/snapApi'; -import type { TransactionRequest } from '../types/snapState'; -import { VoyagerTransactionType, type Transaction } from '../types/snapState'; +import type { ResourceBounds, TransactionRequest } from '../types/snapState'; import { generateExecuteTxnFlow } from '../ui/utils'; import { AddressStruct, @@ -21,13 +19,21 @@ import { mapDeprecatedParams, createInteractiveConfirmDialog, callToTransactionReqCall, + logger, } from '../utils'; +import { CAIRO_VERSION } from '../utils/constants'; import { UserRejectedOpError } from '../utils/exceptions'; import { - createAccount, + deployAccount, executeTxn as executeTxnUtil, + getDeployAccountCallData, getEstimatedFees, } from '../utils/starknetUtils'; +import { + transactionVersionToNumber, + feeTokenToTransactionVersion, + transactionVersionToFeeToken, +} from '../utils/transaction'; import type { AccountRpcControllerOptions } from './abstract/account-rpc-controller'; import { AccountRpcController } from './abstract/account-rpc-controller'; @@ -50,6 +56,36 @@ export type ExecuteTxnParams = Infer & Json; export type ExecuteTxnResponse = Infer; +export type ConfirmTransactionParams = { + calls: Call[]; + address: string; + maxFee: string; + resourceBounds: ResourceBounds; + txnVersion: constants.TRANSACTION_VERSION; + includeDeploy: boolean; +}; + +export type DeployAccountParams = { + address: string; + txnVersion: constants.TRANSACTION_VERSION; +}; + +export type SendTransactionParams = { + address: string; + calls: Call[]; + abis?: any[]; + details?: Infer; +}; + +export type SaveDataToStateParamas = { + txnHashForDeploy?: string; + txnHashForExecute: string; + txnVersion: constants.TRANSACTION_VERSION; + maxFee: string; + address: string; + calls: Call[]; +}; + /** * The RPC handler to execute a transaction. */ @@ -109,67 +145,129 @@ export class ExecuteTxnRpc extends AccountRpcController< protected async handleRequest( params: ExecuteTxnParams, ): Promise { - const requestId = uuidv4(); + const { address, calls, abis, details } = params; + const { privateKey, publicKey } = this.account; + const callsArray = Array.isArray(calls) ? calls : [calls]; + + const { + includeDeploy, + suggestedMaxFee: maxFee, + resourceBounds, + } = await getEstimatedFees( + this.network, + address, + privateKey, + publicKey, + [ + { + type: TransactionType.INVOKE, + payload: calls, + }, + ], + details, + ); - try { - const { address, calls, abis, details } = params; - const { privateKey, publicKey } = this.account; - const callsArray = Array.isArray(calls) ? calls : [calls]; + const accountDeployed = !includeDeploy; + + const { + selectedFeeToken, + maxFee: updatedMaxFee, + resourceBounds: updatedResouceBounds, + } = await this.confirmTransaction({ + txnVersion: details?.version as unknown as constants.TRANSACTION_VERSION, + address, + calls: callsArray, + maxFee, + resourceBounds, + includeDeploy, + }); + + const updatedTxnVersion = feeTokenToTransactionVersion(selectedFeeToken); + + let txnHashForDeploy: string | undefined; + + if (!accountDeployed) { + txnHashForDeploy = await this.deployAccount({ + address, + txnVersion: updatedTxnVersion, + }); + } + + const txnHashForExecute = await this.sendTransaction({ + address, + calls: callsArray, + abis, + details: { + ...details, + version: updatedTxnVersion, + // Aways repect the input, unless the account is not deployed + // TODO: we may also need to increment the nonce base on the input, if the account is not deployed + nonce: accountDeployed ? details?.nonce : 1, + maxFee, + resourceBounds: updatedResouceBounds, + }, + }); - const { includeDeploy, suggestedMaxFee, resourceBounds } = - await getEstimatedFees( - this.network, + await this.saveDataToState({ + txnHashForDeploy, + txnHashForExecute, + txnVersion: updatedTxnVersion, + maxFee: updatedMaxFee, + address, + calls: callsArray, + }); + + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + transaction_hash: txnHashForExecute, + }; + } + + protected async confirmTransaction({ + calls, + address, + maxFee, + resourceBounds, + txnVersion, + includeDeploy, + }: ConfirmTransactionParams): Promise { + const requestId = uuidv4(); + const { chainId, name: networkName } = this.network; + const { addressIndex } = this.account; + + const formattedCalls = await Promise.all( + calls.map(async (call) => + callToTransactionReqCall( + call, + chainId, address, - privateKey, - publicKey, - [ - { - type: TransactionType.INVOKE, - payload: calls, - }, - ], - details, - ); - - const accountDeployed = !includeDeploy; - const version = - details?.version as unknown as constants.TRANSACTION_VERSION; - - const formattedCalls = await Promise.all( - callsArray.map(async (call) => - callToTransactionReqCall( - call, - this.network.chainId, - address, - this.tokenStateManager, - ), + this.tokenStateManager, ), - ); + ), + ); - const request: TransactionRequest = { - chainId: this.network.chainId, - networkName: this.network.name, - id: requestId, - interfaceId: '', - type: TransactionType.INVOKE, - signer: address, - addressIndex: this.account.addressIndex, - maxFee: suggestedMaxFee, - calls: formattedCalls, - resourceBounds, - selectedFeeToken: - version === constants.TRANSACTION_VERSION.V3 - ? FeeToken.STRK - : FeeToken.ETH, - includeDeploy, - }; - - const interfaceId = await generateExecuteTxnFlow(request); - - request.interfaceId = interfaceId; - - await this.reqStateManager.upsertTransactionRequest(request); + const request: TransactionRequest = { + chainId, + networkName, + id: requestId, + interfaceId: '', + type: TransactionType.INVOKE, + signer: address, + addressIndex, + maxFee, + calls: formattedCalls, + resourceBounds, + selectedFeeToken: transactionVersionToFeeToken(txnVersion), + includeDeploy, + }; + + const interfaceId = await generateExecuteTxnFlow(request); + + request.interfaceId = interfaceId; + await this.reqStateManager.upsertTransactionRequest(request); + + try { if (!(await createInteractiveConfirmDialog(interfaceId))) { throw new UserRejectedOpError() as unknown as Error; } @@ -184,130 +282,116 @@ export class ExecuteTxnRpc extends AccountRpcController< throw new Error('Failed to retrieve the updated transaction request'); } - if (!accountDeployed) { - await createAccount({ - network: this.network, - address, - publicKey, - privateKey, - waitMode: false, - callback: async ( - contractAddress: string, - transactionHash: string, - ) => { - await this.updateAccountAsDeploy(contractAddress, transactionHash); - }, - version: - updatedRequest.selectedFeeToken === FeeToken.STRK - ? constants.TRANSACTION_VERSION.V3 - : constants.TRANSACTION_VERSION.V1, - }); - } + return updatedRequest; + } finally { + // Remove the transaction request from the state without throwing an error + await this.removeTransactionRequestSafe(requestId); + } + } - const invocationDetails = { - ...details, - // Aways repect the input, unless the account is not deployed - // TODO: we may also need to increment the nonce base on the input, if the account is not deployed - nonce: accountDeployed ? details?.nonce : 1, - maxFee: updatedRequest.maxFee, - resourceBounds: updatedRequest.resourceBounds, - version: - updatedRequest.selectedFeeToken === FeeToken.STRK - ? constants.TRANSACTION_VERSION.V3 - : constants.TRANSACTION_VERSION.V1, - }; - - const executeTxnResp = await executeTxnUtil( - this.network, - address, - privateKey, - calls, - abis, - invocationDetails, - ); + protected async removeTransactionRequestSafe(requestId: string) { + try { + await this.reqStateManager.removeTransactionRequest(requestId); + } catch (error) { + logger.error('Failed to remove transaction request', error); + } + } - if (!executeTxnResp?.transaction_hash) { - throw new Error('Failed to execute transaction'); - } + protected async deployAccount({ + address, + txnVersion, + }: DeployAccountParams): Promise { + const { privateKey, publicKey } = this.account; - // Since the RPC supports the `calls` parameter either as a single `call` object or an array of `call` objects, - // and the current state data structure does not yet support multiple `call` objects in a single transaction, - // we need to convert `calls` into a single `call` object as a temporary fix. - const call = Array.isArray(calls) ? calls[0] : calls; + const callData = getDeployAccountCallData(publicKey, CAIRO_VERSION); - await this.txnStateManager.addTransaction( - this.createInvokeTxn(address, executeTxnResp.transaction_hash, call), - ); + const { + contract_address: contractAddress, + transaction_hash: transactionHash, + } = await deployAccount( + this.network, + address, + callData, + publicKey, + privateKey, + CAIRO_VERSION, + { version: txnVersion }, + ); - return executeTxnResp; - } finally { - await this.reqStateManager.removeTransactionRequest(requestId); + if (contractAddress !== address) { + logger.warn(` + contract address is not match with the desired address\n contract address: ${contractAddress}, desired address: ${address} + `); } - } - protected async updateAccountAsDeploy( - address: string, - transactionHash: string, - ): Promise { if (!transactionHash) { - throw new Error(`Failed to deploy account for address ${address}`); + throw new Error(`Failed to deploy account`); } - await this.txnStateManager.addTransaction( - this.createDeployTxn(address, transactionHash), - ); + return transactionHash; + } + + protected async sendTransaction({ + address, + calls, + abis, + details, + }: SendTransactionParams): Promise { + const { privateKey } = this.account; - await this.accStateManager.updateAccountAsDeploy({ + const executeTxnResp = await executeTxnUtil( + this.network, address, - chainId: this.network.chainId, - transactionHash, - }); - } + privateKey, + calls, + abis, + details, + ); - protected createDeployTxn( - address: string, - transactionHash: string, - ): Transaction { - return { - txnHash: transactionHash, - txnType: VoyagerTransactionType.DEPLOY_ACCOUNT, - chainId: this.network.chainId, - senderAddress: address, - contractAddress: address, - contractFuncName: '', - contractCallData: [], - finalityStatus: TransactionStatus.RECEIVED, - executionStatus: TransactionStatus.RECEIVED, - status: '', - failureReason: '', - eventIds: [], - timestamp: Math.floor(Date.now() / 1000), - }; + if (!executeTxnResp?.transaction_hash) { + throw new Error('Failed to execute transaction'); + } + + return executeTxnResp.transaction_hash; } - protected createInvokeTxn( - address: string, - transactionHash: string, - callData: Call, - ): Transaction { - const { contractAddress, calldata, entrypoint } = callData; - return { - txnHash: transactionHash, - txnType: VoyagerTransactionType.INVOKE, - chainId: this.network.chainId, - senderAddress: address, - contractAddress, - contractFuncName: entrypoint, - contractCallData: (calldata as unknown as Calldata)?.map( - (data: string) => `0x${BigInt(data).toString(16)}`, - ), - finalityStatus: TransactionStatus.RECEIVED, - executionStatus: TransactionStatus.RECEIVED, - status: '', - failureReason: '', - eventIds: [], - timestamp: Math.floor(Date.now() / 1000), - }; + protected async saveDataToState({ + txnHashForDeploy, + txnHashForExecute, + txnVersion, + maxFee, + address, + calls, + }: SaveDataToStateParamas) { + const txnVersionInNumber = transactionVersionToNumber(txnVersion); + const { chainId } = this.network; + + if (txnHashForDeploy) { + await this.txnStateManager.addTransaction( + this.txnStateManager.newDeployTransaction({ + senderAddress: address, + txnHash: txnHashForDeploy, + chainId, + txnVersion: txnVersionInNumber, + }), + ); + await this.accStateManager.updateAccountAsDeploy({ + address, + chainId, + transactionHash: txnHashForDeploy, + }); + } + + await this.txnStateManager.addTransaction( + this.txnStateManager.newInvokeTransaction({ + senderAddress: address, + txnHash: txnHashForExecute, + chainId, + maxFee, + txnVersion: txnVersionInNumber, + calls, + }), + ); } } diff --git a/packages/starknet-snap/src/state/transaction-state-manager.ts b/packages/starknet-snap/src/state/transaction-state-manager.ts index 0973ce82..96af6612 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.ts @@ -1,5 +1,6 @@ -import type { constants, TransactionType } from 'starknet'; +import type { Call, constants } from 'starknet'; import { + TransactionType, TransactionFinalityStatus, TransactionExecutionStatus, } from 'starknet'; @@ -10,6 +11,7 @@ import { TransactionDataVersion, TransactionStatusType, } from '../types/snapState'; +import { msToSec } from '../utils'; import type { IFilter } from './filter'; import { BigIntFilter, @@ -337,4 +339,105 @@ export class TransactionStateManager extends StateManager { throw new StateManagerError(error.message); } } + + /** + * Creates a new transaction object with the given data. + * + * @param params - The parameters of the new transaction object. + * @param params.txnHash - The txn hash. + * @param params.senderAddress - The sender address. + * @param params.chainId - The chain id. + * @param params.maxFee - The max fee. + * @param params.calls - The array of `Call` object. + * @param params.txnVersion - The transaction version. + * @returns The new transaction object. + */ + newInvokeTransaction({ + txnHash, + senderAddress, + chainId, + calls, + txnVersion, + maxFee, + }: { + txnHash: string; + senderAddress: string; + chainId: string; + maxFee: string; + calls: Call[]; + txnVersion: number; + }): V2Transaction { + return { + txnHash, + txnType: TransactionType.INVOKE, + chainId, + senderAddress, + contractAddress: '', + finalityStatus: TransactionFinalityStatus.RECEIVED, + // FIXME: executionStatus will be using the same result as finality if the transaction is yet confirmed + executionStatus: TransactionFinalityStatus.RECEIVED, + failureReason: '', + timestamp: msToSec(Date.now()), + dataVersion: TransactionDataVersion.V2, + version: txnVersion, + maxFee, + // actualFee is always null if the transaction is yet confirmed + actualFee: null, + accountCalls: calls.reduce((acc, callData) => { + const { contractAddress, calldata, entrypoint } = callData; + + if (!Object.prototype.hasOwnProperty.call(acc, contractAddress)) { + acc[contractAddress] = []; + } + acc[contractAddress].push({ + contract: contractAddress, + contractFuncName: entrypoint, + contractCallData: calldata, + }); + + return acc; + }, {}), + }; + } + + /** + * Creates a new transaction object for the deploy account transaction. + * + * @param params - The parameters of the new transaction object. + * @param params.txnHash - The txn hash. + * @param params.senderAddress - The sender address. + * @param params.chainId - The chain id. + * @param params.txnVersion - The transaction version. + * @returns The new transaction object. + */ + newDeployTransaction({ + txnHash, + senderAddress, + chainId, + txnVersion, + }: { + txnHash: string; + senderAddress: string; + chainId: string; + txnVersion: number; + }): V2Transaction { + return { + txnHash, + txnType: TransactionType.DEPLOY_ACCOUNT, + chainId, + senderAddress, + contractAddress: senderAddress, + finalityStatus: TransactionFinalityStatus.RECEIVED, + // FIXME: executionStatus will be using the same result as finality if the transaction is yet confirmed + executionStatus: TransactionFinalityStatus.RECEIVED, + failureReason: '', + timestamp: msToSec(Date.now()), + dataVersion: TransactionDataVersion.V2, + version: txnVersion, + maxFee: null, + // actualFee is always null if the transaction is yet confirmed + actualFee: null, + accountCalls: null, + }; + } } diff --git a/packages/starknet-snap/src/utils/transaction.ts b/packages/starknet-snap/src/utils/transaction.ts new file mode 100644 index 00000000..4318833f --- /dev/null +++ b/packages/starknet-snap/src/utils/transaction.ts @@ -0,0 +1,45 @@ +import { constants } from 'starknet'; + +import { FeeToken } from '../types/snapApi'; + +/** + * Convert the transaction version to number. + * + * @param txnVersion - The transaction version. + * @returns The transaction version number. + */ +export function transactionVersionToNumber(txnVersion: string): number { + const v3TxnVersion = new Set([ + constants.TRANSACTION_VERSION.V3, + constants.TRANSACTION_VERSION.F3, + '3', + 3, + ]); + return v3TxnVersion.has(txnVersion) ? 3 : 1; +} + +/** + * Convert the feeToken unit to transaction version. + * + * @param feeToken - The feeToken unit. + * @returns The transaction version. + */ +export function feeTokenToTransactionVersion( + feeToken: string, +): constants.TRANSACTION_VERSION { + return feeToken === FeeToken.STRK + ? constants.TRANSACTION_VERSION.V3 + : constants.TRANSACTION_VERSION.V1; +} + +/** + * Convert the transaction version to feeToken unit. + * + * @param txnVersion - The transaction version. + * @returns The feeToken unit. + */ +export function transactionVersionToFeeToken(txnVersion: string): FeeToken { + return txnVersion === constants.TRANSACTION_VERSION.V3 + ? FeeToken.STRK + : FeeToken.ETH; +} From 0ee7431cde8eabb22fe2ca4ec0c3f7f2384d7668 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 10 Dec 2024 14:20:28 +0800 Subject: [PATCH 29/36] chore: refactor execute txn --- .../src/chain/data-client/starkscan.ts | 132 +++------ .../src/rpcs/execute-txn.test.ts | 7 +- .../starknet-snap/src/rpcs/execute-txn.ts | 6 +- .../src/state/transaction-state-manager.ts | 105 +------ .../src/utils/transaction.test.ts | 276 ++++++++++++++++++ .../starknet-snap/src/utils/transaction.ts | 175 ++++++++++- 6 files changed, 504 insertions(+), 197 deletions(-) create mode 100644 packages/starknet-snap/src/utils/transaction.test.ts diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts index 8935c80f..ba506f0f 100644 --- a/packages/starknet-snap/src/chain/data-client/starkscan.ts +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -1,24 +1,18 @@ 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 type { V2Transaction } from '../../types/snapState'; +import { type Network, type Transaction } from '../../types/snapState'; import { InvalidNetworkError } from '../../utils/exceptions'; +import { + newDeployTransaction, + newInvokeTransaction, +} from '../../utils/transaction'; import type { HttpHeaders } from '../api-client'; import { ApiClient, HttpMethod } from '../api-client'; import type { IDataClient } from '../data-client'; import type { StarkScanTransactionsResponse } from './starkscan.type'; import { - type StarkScanAccountCall, type StarkScanTransaction, type StarkScanOptions, StarkScanTransactionsResponseStruct, @@ -185,10 +179,6 @@ export class StarkScanClient extends ApiClient implements IDataClient { return tx.transaction_type === TransactionType.DEPLOY_ACCOUNT; } - protected isFundTransferTransaction(entrypoint: string): boolean { - return entrypoint === TRANSFER_SELECTOR_HEX; - } - protected getContractAddress(tx: StarkScanTransaction): string { // backfill the contract address if it is null return tx.contract_address ?? ''; @@ -207,94 +197,64 @@ export class StarkScanClient extends ApiClient implements IDataClient { } protected toTransaction(tx: StarkScanTransaction): Transaction { - /* eslint-disable @typescript-eslint/naming-convention */ + /* eslint-disable @typescript-eslint/naming-convention, camelcase */ const { transaction_hash: txnHash, transaction_type: txnType, timestamp, transaction_finality_status: finalityStatus, transaction_execution_status: executionStatus, - max_fee: maxFee, + max_fee, actual_fee: actualFee, - revert_error: failureReason, + revert_error, + // 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 account_calls: calls, - version, + version: txnVersion, } = 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); + const { chainId } = this.network; + const senderAddress = this.getSenderAddress(tx); + const failureReason = revert_error ?? ''; + const maxFee = max_fee ?? '0'; + + let transaction: V2Transaction; + + // eslint-disable-next-line no-negated-condition + if (!this.isDeployTransaction(tx)) { + transaction = newInvokeTransaction({ + txnHash, + senderAddress, + chainId, + maxFee, + calls: calls.map((call) => ({ + contractAddress: call.contract_address, + entrypoint: call.selector, + calldata: call.calldata, + })), + txnVersion, + }); + } else { + transaction = newDeployTransaction({ + txnHash, + senderAddress, + chainId, + txnVersion, + }); + } return { - txnHash, - txnType, - chainId: this.network.chainId, - senderAddress: this.getSenderAddress(tx), + ...transaction, + // Override the fields from the StarkScanTransaction timestamp, finalityStatus, executionStatus, - maxFee, actualFee, + maxFee, contractAddress: this.getContractAddress(tx), - accountCalls, - failureReason: failureReason ?? '', - version, - dataVersion: TransactionDataVersion.V2, + failureReason, + txnType, }; - /* eslint-enable */ } - - protected toAccountCall( - accountCalls: StarkScanAccountCall[], - ): Record | null { - if (!accountCalls || accountCalls.length === 0) { - return null; - } - - return accountCalls.reduce( - ( - data: Record, - accountCallArg: StarkScanAccountCall, - ) => { - const { - contract_address: contract, - selector, - calldata: contractCallData, - } = accountCallArg; - - const contractFuncName = this.selectorHexToName(selector); - if (!Object.prototype.hasOwnProperty.call(data, contract)) { - data[contract] = []; - } - - const accountCall: TranscationAccountCall = { - contract, - contractFuncName, - contractCallData, - }; - - if (this.isFundTransferTransaction(selector)) { - accountCall.recipient = accountCallArg.calldata[0]; - accountCall.amount = accountCallArg.calldata[1]; - } - - data[contract].push(accountCall); - - return data; - }, - {}, - ); - } - - protected selectorHexToName(selector: string): string { - switch (selector.toLowerCase()) { - case TRANSFER_SELECTOR_HEX.toLowerCase(): - return ContractFuncName.Transfer; - case UPGRADE_SELECTOR_HEX.toLowerCase(): - return ContractFuncName.Upgrade; - default: - return selector; - } - } } diff --git a/packages/starknet-snap/src/rpcs/execute-txn.test.ts b/packages/starknet-snap/src/rpcs/execute-txn.test.ts index 0a286833..801f2f31 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.test.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.test.ts @@ -25,6 +25,8 @@ import * as formatUtils from '../utils/formatter-utils'; import * as starknetUtils from '../utils/starknetUtils'; import { feeTokenToTransactionVersion, + newDeployTransaction as newDeployTransactionFn, + newInvokeTransaction as newInvokeTransactionFn, transactionVersionToFeeToken, transactionVersionToNumber, } from '../utils/transaction'; @@ -627,8 +629,7 @@ describe('ExecuteTxn', () => { calls, } as unknown as SaveDataToStateParamas; - const txnMgr = new TransactionStateManager(true); - const newInvokeTransaction = txnMgr.newInvokeTransaction({ + const newInvokeTransaction = newInvokeTransactionFn({ senderAddress: account.address, txnHash: request.txnHashForExecute, chainId: network.chainId, @@ -636,7 +637,7 @@ describe('ExecuteTxn', () => { txnVersion: transactionVersionToNumber(txnVersion), calls, }); - const newDeployTransaction = txnMgr.newDeployTransaction({ + const newDeployTransaction = newDeployTransactionFn({ senderAddress: account.address, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion txnHash: request.txnHashForDeploy!, diff --git a/packages/starknet-snap/src/rpcs/execute-txn.ts b/packages/starknet-snap/src/rpcs/execute-txn.ts index 3b560f75..3c7714d9 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.ts @@ -33,6 +33,8 @@ import { transactionVersionToNumber, feeTokenToTransactionVersion, transactionVersionToFeeToken, + newDeployTransaction, + newInvokeTransaction, } from '../utils/transaction'; import type { AccountRpcControllerOptions } from './abstract/account-rpc-controller'; import { AccountRpcController } from './abstract/account-rpc-controller'; @@ -368,7 +370,7 @@ export class ExecuteTxnRpc extends AccountRpcController< if (txnHashForDeploy) { await this.txnStateManager.addTransaction( - this.txnStateManager.newDeployTransaction({ + newDeployTransaction({ senderAddress: address, txnHash: txnHashForDeploy, chainId, @@ -383,7 +385,7 @@ export class ExecuteTxnRpc extends AccountRpcController< } await this.txnStateManager.addTransaction( - this.txnStateManager.newInvokeTransaction({ + newInvokeTransaction({ senderAddress: address, txnHash: txnHashForExecute, chainId, diff --git a/packages/starknet-snap/src/state/transaction-state-manager.ts b/packages/starknet-snap/src/state/transaction-state-manager.ts index 96af6612..0973ce82 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.ts @@ -1,6 +1,5 @@ -import type { Call, constants } from 'starknet'; +import type { constants, TransactionType } from 'starknet'; import { - TransactionType, TransactionFinalityStatus, TransactionExecutionStatus, } from 'starknet'; @@ -11,7 +10,6 @@ import { TransactionDataVersion, TransactionStatusType, } from '../types/snapState'; -import { msToSec } from '../utils'; import type { IFilter } from './filter'; import { BigIntFilter, @@ -339,105 +337,4 @@ export class TransactionStateManager extends StateManager { throw new StateManagerError(error.message); } } - - /** - * Creates a new transaction object with the given data. - * - * @param params - The parameters of the new transaction object. - * @param params.txnHash - The txn hash. - * @param params.senderAddress - The sender address. - * @param params.chainId - The chain id. - * @param params.maxFee - The max fee. - * @param params.calls - The array of `Call` object. - * @param params.txnVersion - The transaction version. - * @returns The new transaction object. - */ - newInvokeTransaction({ - txnHash, - senderAddress, - chainId, - calls, - txnVersion, - maxFee, - }: { - txnHash: string; - senderAddress: string; - chainId: string; - maxFee: string; - calls: Call[]; - txnVersion: number; - }): V2Transaction { - return { - txnHash, - txnType: TransactionType.INVOKE, - chainId, - senderAddress, - contractAddress: '', - finalityStatus: TransactionFinalityStatus.RECEIVED, - // FIXME: executionStatus will be using the same result as finality if the transaction is yet confirmed - executionStatus: TransactionFinalityStatus.RECEIVED, - failureReason: '', - timestamp: msToSec(Date.now()), - dataVersion: TransactionDataVersion.V2, - version: txnVersion, - maxFee, - // actualFee is always null if the transaction is yet confirmed - actualFee: null, - accountCalls: calls.reduce((acc, callData) => { - const { contractAddress, calldata, entrypoint } = callData; - - if (!Object.prototype.hasOwnProperty.call(acc, contractAddress)) { - acc[contractAddress] = []; - } - acc[contractAddress].push({ - contract: contractAddress, - contractFuncName: entrypoint, - contractCallData: calldata, - }); - - return acc; - }, {}), - }; - } - - /** - * Creates a new transaction object for the deploy account transaction. - * - * @param params - The parameters of the new transaction object. - * @param params.txnHash - The txn hash. - * @param params.senderAddress - The sender address. - * @param params.chainId - The chain id. - * @param params.txnVersion - The transaction version. - * @returns The new transaction object. - */ - newDeployTransaction({ - txnHash, - senderAddress, - chainId, - txnVersion, - }: { - txnHash: string; - senderAddress: string; - chainId: string; - txnVersion: number; - }): V2Transaction { - return { - txnHash, - txnType: TransactionType.DEPLOY_ACCOUNT, - chainId, - senderAddress, - contractAddress: senderAddress, - finalityStatus: TransactionFinalityStatus.RECEIVED, - // FIXME: executionStatus will be using the same result as finality if the transaction is yet confirmed - executionStatus: TransactionFinalityStatus.RECEIVED, - failureReason: '', - timestamp: msToSec(Date.now()), - dataVersion: TransactionDataVersion.V2, - version: txnVersion, - maxFee: null, - // actualFee is always null if the transaction is yet confirmed - actualFee: null, - accountCalls: null, - }; - } } diff --git a/packages/starknet-snap/src/utils/transaction.test.ts b/packages/starknet-snap/src/utils/transaction.test.ts new file mode 100644 index 00000000..daee887e --- /dev/null +++ b/packages/starknet-snap/src/utils/transaction.test.ts @@ -0,0 +1,276 @@ +import { + constants, + TransactionFinalityStatus, + TransactionType, +} from 'starknet'; + +import callsExamples from '../__tests__/fixture/callsExamples.json'; +import { generateAccounts } from '../__tests__/helper'; +import { FeeToken } from '../types/snapApi'; +import { ContractFuncName, TransactionDataVersion } from '../types/snapState'; +import { + ETHER_SEPOLIA_TESTNET, + STRK_SEPOLIA_TESTNET, + TRANSFER_SELECTOR_HEX, + UPGRADE_SELECTOR_HEX, +} from './constants'; +import { + callsToTranscationAccountCalls, + newDeployTransaction, + newInvokeTransaction, + transactionVersionToNumber, + feeTokenToTransactionVersion, + transactionVersionToFeeToken, + transactionSelectorHexToName, + isFundTransferEntrypoint, +} from './transaction'; + +describe('transactionVersionToNumber', () => { + it.each([ + constants.TRANSACTION_VERSION.V3, + constants.TRANSACTION_VERSION.F3, + 3, + '3', + ])( + 'converts the transaction version to 3 if the given txnVersion is %s', + (txnVersion: string) => { + expect(transactionVersionToNumber(txnVersion)).toBe(3); + }, + ); + + it.each([ + ...Object.values(constants.TRANSACTION_VERSION).filter( + (ver) => + ver !== constants.TRANSACTION_VERSION.V3 && + ver !== constants.TRANSACTION_VERSION.F3, + ), + '1', + 1, + 'invalid_version', + ])( + 'converts the transaction version to 1 if the given txnVersion is %s', + (txnVersion: string) => { + expect(transactionVersionToNumber(txnVersion)).toBe(1); + }, + ); +}); + +describe('feeTokenToTransactionVersion', () => { + it('converts feeToken string to transaction version v3 if it is STRK', () => { + expect(feeTokenToTransactionVersion(FeeToken.STRK)).toStrictEqual( + constants.TRANSACTION_VERSION.V3, + ); + }); + + it.each([FeeToken.ETH, 'invalid_unit'])( + 'converts feeToken string to transaction version v1 if it not STRK - %s', + (txnVersion: string) => { + expect(feeTokenToTransactionVersion(txnVersion)).toStrictEqual( + constants.TRANSACTION_VERSION.V1, + ); + }, + ); +}); + +describe('transactionVersionToFeeToken', () => { + it('converts transaction version to STRK unit if it is transaction v3', () => { + expect( + transactionVersionToFeeToken(constants.TRANSACTION_VERSION.V3), + ).toStrictEqual(FeeToken.STRK); + }); + + it.each([ + Object.values(constants.TRANSACTION_VERSION).filter( + (ver) => ver !== constants.TRANSACTION_VERSION.V3, + ), + 'invalid_unit', + ])( + 'converts transaction version to ETH unit if it is not STRK - %s', + (txnVersion: string) => { + expect(transactionVersionToFeeToken(txnVersion)).toStrictEqual( + FeeToken.ETH, + ); + }, + ); +}); + +describe('transactionSelectorHexToName', () => { + it.each([TRANSFER_SELECTOR_HEX, 'transfer'])( + 'converts selector name to `transfer` if it matchs the transfer selector - %s', + (selector: string) => { + expect(transactionSelectorHexToName(selector)).toStrictEqual( + ContractFuncName.Transfer, + ); + }, + ); + + it.each([UPGRADE_SELECTOR_HEX, 'upgrade'])( + 'converts selector name to `upgrade` if it matchs the upgrade selector - %s', + (selector: string) => { + expect(transactionSelectorHexToName(selector)).toStrictEqual( + ContractFuncName.Upgrade, + ); + }, + ); + + it.each(['transfers', 'upgraded', '0x11234'])( + 'returns the original selector string if it doesnt match the hex string for upgrade or transfer', + (selector: string) => { + expect(transactionSelectorHexToName(selector)).toStrictEqual(selector); + }, + ); +}); + +describe('callsToTranscationAccountCalls', () => { + it('converts calls to transaction account calls', () => { + const { calls } = callsExamples.singleCall; + const result = callsToTranscationAccountCalls([calls]); + + const { + contractAddress: contract, + calldata: contractCallData, + entrypoint, + } = calls; + + expect(result).toStrictEqual({ + [contract]: [ + { + contract, + contractCallData, + contractFuncName: transactionSelectorHexToName(entrypoint), + }, + ], + }); + }); + + it('converts calls to transaction account calls with recipient and amount if it is an fund transfer call', async () => { + const [{ address }] = await generateAccounts( + constants.StarknetChainId.SN_SEPOLIA, + 1, + ); + const amount = '100000000000'; + const calls = [ + { + contractAddress: ETHER_SEPOLIA_TESTNET.address, + calldata: [address, amount], + entrypoint: TRANSFER_SELECTOR_HEX, + }, + { + contractAddress: ETHER_SEPOLIA_TESTNET.address, + calldata: [address, amount], + entrypoint: TRANSFER_SELECTOR_HEX, + }, + { + contractAddress: STRK_SEPOLIA_TESTNET.address, + calldata: [address, amount], + entrypoint: TRANSFER_SELECTOR_HEX, + }, + ]; + + const result = callsToTranscationAccountCalls(calls); + + expect(result).toStrictEqual( + calls.reduce((acc, call) => { + const { + contractAddress: contract, + calldata: contractCallData, + entrypoint, + } = call; + + if (!Object.prototype.hasOwnProperty.call(acc, contract)) { + acc[contract] = []; + } + acc[contract].push({ + contract, + contractCallData, + contractFuncName: transactionSelectorHexToName(entrypoint), + recipient: contractCallData[0], + amount: contractCallData[1], + }); + return acc; + }, {}), + ); + }); +}); + +describe('isFundTransferEntrypoint', () => { + it.each([TRANSFER_SELECTOR_HEX, 'transfer'])( + 'returns true if the entrypoint is a fund transfer entrypoint - %s', + (entrypoint: string) => { + expect(isFundTransferEntrypoint(entrypoint)).toBe(true); + }, + ); + + it('returns false if the entrypoint is not a fund transfer entrypoint', () => { + expect(isFundTransferEntrypoint(UPGRADE_SELECTOR_HEX)).toBe(false); + }); +}); + +describe('newInvokeTransaction', () => { + it('creates a new invoke transaction', async () => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + const [{ address: senderAddress }] = await generateAccounts(chainId, 1); + const { hash: txnHash, calls } = callsExamples.multipleCalls; + const txnVersion = 1; + const maxFee = '10'; + + const result = newInvokeTransaction({ + txnHash, + senderAddress, + chainId, + maxFee, + calls, + txnVersion, + }); + + expect(result).toStrictEqual({ + txnHash, + txnType: TransactionType.INVOKE, + chainId, + senderAddress, + contractAddress: '', + finalityStatus: TransactionFinalityStatus.RECEIVED, + executionStatus: TransactionFinalityStatus.RECEIVED, + failureReason: '', + timestamp: expect.any(Number), + dataVersion: TransactionDataVersion.V2, + version: txnVersion, + maxFee, + actualFee: null, + accountCalls: callsToTranscationAccountCalls(calls), + }); + }); +}); + +describe('newDeployTransaction', () => { + it('creates a new deploy transaction', async () => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + const [{ address: senderAddress }] = await generateAccounts(chainId, 1); + const { hash: txnHash } = callsExamples.multipleCalls; + const txnVersion = 1; + + const result = newDeployTransaction({ + txnHash, + senderAddress, + chainId, + txnVersion, + }); + + expect(result).toStrictEqual({ + txnHash, + txnType: TransactionType.DEPLOY_ACCOUNT, + chainId, + senderAddress, + contractAddress: senderAddress, + finalityStatus: TransactionFinalityStatus.RECEIVED, + executionStatus: TransactionFinalityStatus.RECEIVED, + failureReason: '', + timestamp: expect.any(Number), + dataVersion: TransactionDataVersion.V2, + version: txnVersion, + maxFee: null, + actualFee: null, + accountCalls: null, + }); + }); +}); diff --git a/packages/starknet-snap/src/utils/transaction.ts b/packages/starknet-snap/src/utils/transaction.ts index 4318833f..7ec2c42f 100644 --- a/packages/starknet-snap/src/utils/transaction.ts +++ b/packages/starknet-snap/src/utils/transaction.ts @@ -1,6 +1,15 @@ -import { constants } from 'starknet'; +import type { Call } from 'starknet'; +import { + constants, + TransactionFinalityStatus, + TransactionType, +} from 'starknet'; import { FeeToken } from '../types/snapApi'; +import type { TranscationAccountCall, V2Transaction } from '../types/snapState'; +import { ContractFuncName, TransactionDataVersion } from '../types/snapState'; +import { TRANSFER_SELECTOR_HEX, UPGRADE_SELECTOR_HEX } from './constants'; +import { msToSec } from './formatter-utils'; /** * Convert the transaction version to number. @@ -8,7 +17,9 @@ import { FeeToken } from '../types/snapApi'; * @param txnVersion - The transaction version. * @returns The transaction version number. */ -export function transactionVersionToNumber(txnVersion: string): number { +export function transactionVersionToNumber( + txnVersion: string | number, +): number { const v3TxnVersion = new Set([ constants.TRANSACTION_VERSION.V3, constants.TRANSACTION_VERSION.F3, @@ -43,3 +54,163 @@ export function transactionVersionToFeeToken(txnVersion: string): FeeToken { ? FeeToken.STRK : FeeToken.ETH; } + +/** + * Convert the transaction selector to string name. + * If the selector is not known, return the selector. + * + * @param selector - The transaction selector. + * @returns The meaninful name of the selector if it is known, otherwise return the selector. + */ +export function transactionSelectorHexToName(selector: string): string { + switch (selector.toLowerCase()) { + case ContractFuncName.Transfer.toLowerCase(): + case TRANSFER_SELECTOR_HEX.toLowerCase(): + return ContractFuncName.Transfer; + case ContractFuncName.Upgrade.toLowerCase(): + case UPGRADE_SELECTOR_HEX.toLowerCase(): + return ContractFuncName.Upgrade; + default: + return selector; + } +} + +/** + * Convert an array of `Call` objects to a record of `TranscationAccountCall` objects. + * + * @param calls - The array of `Call` object. + * @returns The record of `TranscationAccountCall` objects. + */ +export function callsToTranscationAccountCalls( + calls: Call[], +): Record { + return calls.reduce((acc, call) => { + const { + contractAddress: contract, + calldata: contractCallData, + entrypoint, + } = call; + + const contractFuncName = transactionSelectorHexToName(entrypoint); + + if (!Object.prototype.hasOwnProperty.call(acc, contract)) { + acc[contract] = []; + } + + const accountCall: TranscationAccountCall = { + contract, + contractFuncName, + contractCallData: contractCallData as unknown as string[], + }; + + if (isFundTransferEntrypoint(entrypoint)) { + accountCall.recipient = accountCall.contractCallData[0]; + accountCall.amount = accountCall.contractCallData[1]; + } + + acc[contract].push(accountCall); + + return acc; + }, {}); +} + +/** + * Check if the entrypoint is a fund transfer entrypoint. + * + * @param entrypoint - The entrypoint. + * @returns `true` if the entrypoint is a fund transfer entrypoint, otherwise `false`. + */ +export function isFundTransferEntrypoint(entrypoint: string): boolean { + return ( + entrypoint.toLowerCase() === TRANSFER_SELECTOR_HEX || + entrypoint.toLowerCase() === ContractFuncName.Transfer + ); +} + +/** + * Creates a new transaction object with the given data. + * + * @param params - The parameters of the new transaction object. + * @param params.txnHash - The txn hash. + * @param params.senderAddress - The sender address. + * @param params.chainId - The chain id. + * @param params.maxFee - The max fee. + * @param params.calls - The array of `Call` object. + * @param params.txnVersion - The transaction version. + * @returns The new transaction object. + */ +export function newInvokeTransaction({ + txnHash, + senderAddress, + chainId, + calls, + txnVersion, + maxFee, +}: { + txnHash: string; + senderAddress: string; + chainId: string; + maxFee: string; + calls: Call[]; + txnVersion: number; +}): V2Transaction { + return { + txnHash, + txnType: TransactionType.INVOKE, + chainId, + senderAddress, + contractAddress: '', + finalityStatus: TransactionFinalityStatus.RECEIVED, + // executionStatus will be using the same result as finality if the transaction is yet confirmed + executionStatus: TransactionFinalityStatus.RECEIVED, + failureReason: '', + timestamp: msToSec(Date.now()), + dataVersion: TransactionDataVersion.V2, + version: txnVersion, + maxFee, + // actualFee is always null if the transaction is yet confirmed + actualFee: null, + accountCalls: callsToTranscationAccountCalls(calls), + }; +} + +/** + * Creates a new transaction object for the deploy account transaction. + * + * @param params - The parameters of the new transaction object. + * @param params.txnHash - The txn hash. + * @param params.senderAddress - The sender address. + * @param params.chainId - The chain id. + * @param params.txnVersion - The transaction version. + * @returns The new transaction object. + */ +export function newDeployTransaction({ + txnHash, + senderAddress, + chainId, + txnVersion, +}: { + txnHash: string; + senderAddress: string; + chainId: string; + txnVersion: number; +}): V2Transaction { + return { + txnHash, + txnType: TransactionType.DEPLOY_ACCOUNT, + chainId, + senderAddress, + contractAddress: senderAddress, + finalityStatus: TransactionFinalityStatus.RECEIVED, + // executionStatus will be using the same result as finality if the transaction is yet confirmed + executionStatus: TransactionFinalityStatus.RECEIVED, + failureReason: '', + timestamp: msToSec(Date.now()), + dataVersion: TransactionDataVersion.V2, + version: txnVersion, + maxFee: null, + // actualFee is always null if the transaction is yet confirmed + actualFee: null, + accountCalls: null, + }; +} From cf64931fe269d2c0ed14c9e3237056fe7079b351 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:59:05 +0800 Subject: [PATCH 30/36] chore: update create account and upgrade account --- packages/starknet-snap/src/createAccount.ts | 21 +++++-------- .../starknet-snap/src/upgradeAccContract.ts | 21 +++++-------- .../test/src/upgradeAccContract.test.ts | 31 +------------------ 3 files changed, 15 insertions(+), 58 deletions(-) diff --git a/packages/starknet-snap/src/createAccount.ts b/packages/starknet-snap/src/createAccount.ts index bdf7e4c2..f92417e4 100644 --- a/packages/starknet-snap/src/createAccount.ts +++ b/packages/starknet-snap/src/createAccount.ts @@ -6,8 +6,7 @@ import type { ApiParamsWithKeyDeriver, CreateAccountRequestParams, } from './types/snapApi'; -import type { AccContract, Transaction } from './types/snapState'; -import { VoyagerTransactionType, TransactionStatus } from './types/snapState'; +import type { AccContract } from './types/snapState'; import { CAIRO_VERSION_LEGACY, CAIRO_VERSION } from './utils/constants'; import { logger } from './utils/logger'; import { toJson } from './utils/serializer'; @@ -26,6 +25,7 @@ import { waitForTransaction, estimateAccountDeployFee, } from './utils/starknetUtils'; +import { newDeployTransaction } from './utils/transaction'; /** * Create an starknet account. @@ -142,21 +142,14 @@ export async function createAccount( await upsertAccount(userAccount, wallet, saveMutex); - const txn: Transaction = { + const txn = newDeployTransaction({ txnHash: deployResp.transaction_hash, - txnType: VoyagerTransactionType.DEPLOY_ACCOUNT, chainId: network.chainId, senderAddress: deployResp.contract_address, - contractAddress: deployResp.contract_address, - contractFuncName: '', - contractCallData: [], - finalityStatus: TransactionStatus.RECEIVED, - executionStatus: TransactionStatus.RECEIVED, - status: '', - failureReason: '', - eventIds: [], - timestamp: Math.floor(Date.now() / 1000), - }; + // whenever create account is happen, we pay the fee in ETH, so txnVersion is 1 + // FIXME: it should allow to pay the fee in STRK + txnVersion: 1, + }) await upsertTransaction(txn, wallet, saveMutex); } diff --git a/packages/starknet-snap/src/upgradeAccContract.ts b/packages/starknet-snap/src/upgradeAccContract.ts index a6c16204..a352d7db 100644 --- a/packages/starknet-snap/src/upgradeAccContract.ts +++ b/packages/starknet-snap/src/upgradeAccContract.ts @@ -5,8 +5,6 @@ import type { ApiParamsWithKeyDeriver, UpgradeTransactionRequestParams, } from './types/snapApi'; -import type { Transaction } from './types/snapState'; -import { TransactionStatus, VoyagerTransactionType } from './types/snapState'; import { ACCOUNT_CLASS_HASH, CAIRO_VERSION_LEGACY } from './utils/constants'; import { logger } from './utils/logger'; import { toJson } from './utils/serializer'; @@ -23,6 +21,7 @@ import { isAccountDeployed, estimateFee, } from './utils/starknetUtils'; +import { newInvokeTransaction } from './utils/transaction'; /** * @@ -145,21 +144,15 @@ export async function upgradeAccContract(params: ApiParamsWithKeyDeriver) { throw new Error(`Transaction hash is not found`); } - const txn: Transaction = { + const txn = newInvokeTransaction({ txnHash: txnResp.transaction_hash, - txnType: VoyagerTransactionType.INVOKE, chainId: network.chainId, senderAddress: contractAddress, - contractAddress, - contractFuncName: 'upgrade', - contractCallData: CallData.compile(calldata), - finalityStatus: TransactionStatus.RECEIVED, - executionStatus: TransactionStatus.RECEIVED, - status: '', // DEPRECATED LATER - failureReason: '', - eventIds: [], - timestamp: Math.floor(Date.now() / 1000), - }; + calls: [txnInvocation], + maxFee: maxFee.toString(10), + // whenever upgrade is happen, we pay the fee in ETH, so txnVersion is 1 + txnVersion: 1, + }) await upsertTransaction(txn, wallet, saveMutex); diff --git a/packages/starknet-snap/test/src/upgradeAccContract.test.ts b/packages/starknet-snap/test/src/upgradeAccContract.test.ts index 899f7ede..79f26c93 100644 --- a/packages/starknet-snap/test/src/upgradeAccContract.test.ts +++ b/packages/starknet-snap/test/src/upgradeAccContract.test.ts @@ -281,35 +281,6 @@ describe('Test function: upgradeAccContract', function () { } }); - it('should save transaction when execute transaction success', async function () { - executeTxnStub.resolves(sendTransactionResp); - estimateFeeStub.resolves(estimateFeeResp); - walletStub.rpcStubs.snap_dialog.resolves(true); - const address = ( - apiParams.requestParams as UpgradeTransactionRequestParams - ).contractAddress; - const calldata = CallData.compile({ - implementation: ACCOUNT_CLASS_HASH, - calldata: [0], - }); - const txn = { - txnHash: sendTransactionResp.transaction_hash, - txnType: VoyagerTransactionType.INVOKE, - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - senderAddress: address, - contractAddress: address, - contractFuncName: 'upgrade', - contractCallData: CallData.compile(calldata), - finalityStatus: TransactionStatus.RECEIVED, - executionStatus: TransactionStatus.RECEIVED, - status: '', - failureReason: '', - eventIds: [], - }; - - const result = await upgradeAccContract(apiParams); - expect(result).to.be.equal(sendTransactionResp); - expect(upsertTransactionStub).to.calledOnceWith(sinon.match(txn)); - }); + }); }); From 12be249bf41178628a7b4738a310512fecf0a686 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:59:32 +0800 Subject: [PATCH 31/36] chore: lint --- packages/starknet-snap/src/createAccount.ts | 2 +- packages/starknet-snap/src/upgradeAccContract.ts | 2 +- packages/starknet-snap/test/src/upgradeAccContract.test.ts | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/starknet-snap/src/createAccount.ts b/packages/starknet-snap/src/createAccount.ts index f92417e4..d8704183 100644 --- a/packages/starknet-snap/src/createAccount.ts +++ b/packages/starknet-snap/src/createAccount.ts @@ -149,7 +149,7 @@ export async function createAccount( // whenever create account is happen, we pay the fee in ETH, so txnVersion is 1 // FIXME: it should allow to pay the fee in STRK txnVersion: 1, - }) + }); await upsertTransaction(txn, wallet, saveMutex); } diff --git a/packages/starknet-snap/src/upgradeAccContract.ts b/packages/starknet-snap/src/upgradeAccContract.ts index a352d7db..1e657118 100644 --- a/packages/starknet-snap/src/upgradeAccContract.ts +++ b/packages/starknet-snap/src/upgradeAccContract.ts @@ -152,7 +152,7 @@ export async function upgradeAccContract(params: ApiParamsWithKeyDeriver) { maxFee: maxFee.toString(10), // whenever upgrade is happen, we pay the fee in ETH, so txnVersion is 1 txnVersion: 1, - }) + }); await upsertTransaction(txn, wallet, saveMutex); diff --git a/packages/starknet-snap/test/src/upgradeAccContract.test.ts b/packages/starknet-snap/test/src/upgradeAccContract.test.ts index 79f26c93..026b5fd4 100644 --- a/packages/starknet-snap/test/src/upgradeAccContract.test.ts +++ b/packages/starknet-snap/test/src/upgradeAccContract.test.ts @@ -280,7 +280,5 @@ describe('Test function: upgradeAccContract', function () { expect(result.message).to.be.include('Transaction hash is not found'); } }); - - }); }); From c5e7741dbba277b818a0e98d733ae2971b798011 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:23:34 +0800 Subject: [PATCH 32/36] Revert "chore: lint" This reverts commit 12be249bf41178628a7b4738a310512fecf0a686. --- packages/starknet-snap/src/createAccount.ts | 2 +- packages/starknet-snap/src/upgradeAccContract.ts | 2 +- packages/starknet-snap/test/src/upgradeAccContract.test.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/starknet-snap/src/createAccount.ts b/packages/starknet-snap/src/createAccount.ts index d8704183..f92417e4 100644 --- a/packages/starknet-snap/src/createAccount.ts +++ b/packages/starknet-snap/src/createAccount.ts @@ -149,7 +149,7 @@ export async function createAccount( // whenever create account is happen, we pay the fee in ETH, so txnVersion is 1 // FIXME: it should allow to pay the fee in STRK txnVersion: 1, - }); + }) await upsertTransaction(txn, wallet, saveMutex); } diff --git a/packages/starknet-snap/src/upgradeAccContract.ts b/packages/starknet-snap/src/upgradeAccContract.ts index 1e657118..a352d7db 100644 --- a/packages/starknet-snap/src/upgradeAccContract.ts +++ b/packages/starknet-snap/src/upgradeAccContract.ts @@ -152,7 +152,7 @@ export async function upgradeAccContract(params: ApiParamsWithKeyDeriver) { maxFee: maxFee.toString(10), // whenever upgrade is happen, we pay the fee in ETH, so txnVersion is 1 txnVersion: 1, - }); + }) await upsertTransaction(txn, wallet, saveMutex); diff --git a/packages/starknet-snap/test/src/upgradeAccContract.test.ts b/packages/starknet-snap/test/src/upgradeAccContract.test.ts index 026b5fd4..79f26c93 100644 --- a/packages/starknet-snap/test/src/upgradeAccContract.test.ts +++ b/packages/starknet-snap/test/src/upgradeAccContract.test.ts @@ -280,5 +280,7 @@ describe('Test function: upgradeAccContract', function () { expect(result.message).to.be.include('Transaction hash is not found'); } }); + + }); }); From bb69ff5b999db67c6d629bb49aa87bb0dbccfdd3 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:23:39 +0800 Subject: [PATCH 33/36] Revert "chore: update create account and upgrade account" This reverts commit cf64931fe269d2c0ed14c9e3237056fe7079b351. --- packages/starknet-snap/src/createAccount.ts | 21 ++++++++----- .../starknet-snap/src/upgradeAccContract.ts | 21 ++++++++----- .../test/src/upgradeAccContract.test.ts | 31 ++++++++++++++++++- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/packages/starknet-snap/src/createAccount.ts b/packages/starknet-snap/src/createAccount.ts index f92417e4..bdf7e4c2 100644 --- a/packages/starknet-snap/src/createAccount.ts +++ b/packages/starknet-snap/src/createAccount.ts @@ -6,7 +6,8 @@ import type { ApiParamsWithKeyDeriver, CreateAccountRequestParams, } from './types/snapApi'; -import type { AccContract } from './types/snapState'; +import type { AccContract, Transaction } from './types/snapState'; +import { VoyagerTransactionType, TransactionStatus } from './types/snapState'; import { CAIRO_VERSION_LEGACY, CAIRO_VERSION } from './utils/constants'; import { logger } from './utils/logger'; import { toJson } from './utils/serializer'; @@ -25,7 +26,6 @@ import { waitForTransaction, estimateAccountDeployFee, } from './utils/starknetUtils'; -import { newDeployTransaction } from './utils/transaction'; /** * Create an starknet account. @@ -142,14 +142,21 @@ export async function createAccount( await upsertAccount(userAccount, wallet, saveMutex); - const txn = newDeployTransaction({ + const txn: Transaction = { txnHash: deployResp.transaction_hash, + txnType: VoyagerTransactionType.DEPLOY_ACCOUNT, chainId: network.chainId, senderAddress: deployResp.contract_address, - // whenever create account is happen, we pay the fee in ETH, so txnVersion is 1 - // FIXME: it should allow to pay the fee in STRK - txnVersion: 1, - }) + contractAddress: deployResp.contract_address, + contractFuncName: '', + contractCallData: [], + finalityStatus: TransactionStatus.RECEIVED, + executionStatus: TransactionStatus.RECEIVED, + status: '', + failureReason: '', + eventIds: [], + timestamp: Math.floor(Date.now() / 1000), + }; await upsertTransaction(txn, wallet, saveMutex); } diff --git a/packages/starknet-snap/src/upgradeAccContract.ts b/packages/starknet-snap/src/upgradeAccContract.ts index a352d7db..a6c16204 100644 --- a/packages/starknet-snap/src/upgradeAccContract.ts +++ b/packages/starknet-snap/src/upgradeAccContract.ts @@ -5,6 +5,8 @@ import type { ApiParamsWithKeyDeriver, UpgradeTransactionRequestParams, } from './types/snapApi'; +import type { Transaction } from './types/snapState'; +import { TransactionStatus, VoyagerTransactionType } from './types/snapState'; import { ACCOUNT_CLASS_HASH, CAIRO_VERSION_LEGACY } from './utils/constants'; import { logger } from './utils/logger'; import { toJson } from './utils/serializer'; @@ -21,7 +23,6 @@ import { isAccountDeployed, estimateFee, } from './utils/starknetUtils'; -import { newInvokeTransaction } from './utils/transaction'; /** * @@ -144,15 +145,21 @@ export async function upgradeAccContract(params: ApiParamsWithKeyDeriver) { throw new Error(`Transaction hash is not found`); } - const txn = newInvokeTransaction({ + const txn: Transaction = { txnHash: txnResp.transaction_hash, + txnType: VoyagerTransactionType.INVOKE, chainId: network.chainId, senderAddress: contractAddress, - calls: [txnInvocation], - maxFee: maxFee.toString(10), - // whenever upgrade is happen, we pay the fee in ETH, so txnVersion is 1 - txnVersion: 1, - }) + contractAddress, + contractFuncName: 'upgrade', + contractCallData: CallData.compile(calldata), + finalityStatus: TransactionStatus.RECEIVED, + executionStatus: TransactionStatus.RECEIVED, + status: '', // DEPRECATED LATER + failureReason: '', + eventIds: [], + timestamp: Math.floor(Date.now() / 1000), + }; await upsertTransaction(txn, wallet, saveMutex); diff --git a/packages/starknet-snap/test/src/upgradeAccContract.test.ts b/packages/starknet-snap/test/src/upgradeAccContract.test.ts index 79f26c93..899f7ede 100644 --- a/packages/starknet-snap/test/src/upgradeAccContract.test.ts +++ b/packages/starknet-snap/test/src/upgradeAccContract.test.ts @@ -281,6 +281,35 @@ describe('Test function: upgradeAccContract', function () { } }); - + it('should save transaction when execute transaction success', async function () { + executeTxnStub.resolves(sendTransactionResp); + estimateFeeStub.resolves(estimateFeeResp); + walletStub.rpcStubs.snap_dialog.resolves(true); + const address = ( + apiParams.requestParams as UpgradeTransactionRequestParams + ).contractAddress; + const calldata = CallData.compile({ + implementation: ACCOUNT_CLASS_HASH, + calldata: [0], + }); + const txn = { + txnHash: sendTransactionResp.transaction_hash, + txnType: VoyagerTransactionType.INVOKE, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + senderAddress: address, + contractAddress: address, + contractFuncName: 'upgrade', + contractCallData: CallData.compile(calldata), + finalityStatus: TransactionStatus.RECEIVED, + executionStatus: TransactionStatus.RECEIVED, + status: '', + failureReason: '', + eventIds: [], + }; + + const result = await upgradeAccContract(apiParams); + expect(result).to.be.equal(sendTransactionResp); + expect(upsertTransactionStub).to.calledOnceWith(sinon.match(txn)); + }); }); }); From 69aa0ec629bcc7ac7a2a78bcd172bc971b3e7dbb Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:36:28 +0800 Subject: [PATCH 34/36] chore: update rpcs to handle v2 data --- packages/starknet-snap/src/createAccount.ts | 18 ++++------- .../starknet-snap/src/upgradeAccContract.ts | 21 +++++-------- .../test/src/upgradeAccContract.test.ts | 31 ------------------- 3 files changed, 14 insertions(+), 56 deletions(-) diff --git a/packages/starknet-snap/src/createAccount.ts b/packages/starknet-snap/src/createAccount.ts index bdf7e4c2..8f8304ed 100644 --- a/packages/starknet-snap/src/createAccount.ts +++ b/packages/starknet-snap/src/createAccount.ts @@ -26,6 +26,7 @@ import { waitForTransaction, estimateAccountDeployFee, } from './utils/starknetUtils'; +import { newDeployTransaction } from './utils/transaction'; /** * Create an starknet account. @@ -142,21 +143,14 @@ export async function createAccount( await upsertAccount(userAccount, wallet, saveMutex); - const txn: Transaction = { + const txn = newDeployTransaction({ txnHash: deployResp.transaction_hash, - txnType: VoyagerTransactionType.DEPLOY_ACCOUNT, chainId: network.chainId, senderAddress: deployResp.contract_address, - contractAddress: deployResp.contract_address, - contractFuncName: '', - contractCallData: [], - finalityStatus: TransactionStatus.RECEIVED, - executionStatus: TransactionStatus.RECEIVED, - status: '', - failureReason: '', - eventIds: [], - timestamp: Math.floor(Date.now() / 1000), - }; + // whenever create account is happen, we pay the fee in ETH, so txnVersion is 1 + // FIXME: it should allow to pay the fee in STRK + txnVersion: 1, + }); await upsertTransaction(txn, wallet, saveMutex); } diff --git a/packages/starknet-snap/src/upgradeAccContract.ts b/packages/starknet-snap/src/upgradeAccContract.ts index a6c16204..93b4e331 100644 --- a/packages/starknet-snap/src/upgradeAccContract.ts +++ b/packages/starknet-snap/src/upgradeAccContract.ts @@ -23,6 +23,7 @@ import { isAccountDeployed, estimateFee, } from './utils/starknetUtils'; +import { newInvokeTransaction } from './utils/transaction'; /** * @@ -145,21 +146,15 @@ export async function upgradeAccContract(params: ApiParamsWithKeyDeriver) { throw new Error(`Transaction hash is not found`); } - const txn: Transaction = { + const txn = newInvokeTransaction({ txnHash: txnResp.transaction_hash, - txnType: VoyagerTransactionType.INVOKE, - chainId: network.chainId, senderAddress: contractAddress, - contractAddress, - contractFuncName: 'upgrade', - contractCallData: CallData.compile(calldata), - finalityStatus: TransactionStatus.RECEIVED, - executionStatus: TransactionStatus.RECEIVED, - status: '', // DEPRECATED LATER - failureReason: '', - eventIds: [], - timestamp: Math.floor(Date.now() / 1000), - }; + chainId: network.chainId, + maxFee: maxFee.toString(10), + calls: [txnInvocation], + // whenever upgrade is happen, we pay the fee in ETH, so txnVersion is 1 + txnVersion: 1, + }); await upsertTransaction(txn, wallet, saveMutex); diff --git a/packages/starknet-snap/test/src/upgradeAccContract.test.ts b/packages/starknet-snap/test/src/upgradeAccContract.test.ts index 899f7ede..026b5fd4 100644 --- a/packages/starknet-snap/test/src/upgradeAccContract.test.ts +++ b/packages/starknet-snap/test/src/upgradeAccContract.test.ts @@ -280,36 +280,5 @@ describe('Test function: upgradeAccContract', function () { expect(result.message).to.be.include('Transaction hash is not found'); } }); - - it('should save transaction when execute transaction success', async function () { - executeTxnStub.resolves(sendTransactionResp); - estimateFeeStub.resolves(estimateFeeResp); - walletStub.rpcStubs.snap_dialog.resolves(true); - const address = ( - apiParams.requestParams as UpgradeTransactionRequestParams - ).contractAddress; - const calldata = CallData.compile({ - implementation: ACCOUNT_CLASS_HASH, - calldata: [0], - }); - const txn = { - txnHash: sendTransactionResp.transaction_hash, - txnType: VoyagerTransactionType.INVOKE, - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - senderAddress: address, - contractAddress: address, - contractFuncName: 'upgrade', - contractCallData: CallData.compile(calldata), - finalityStatus: TransactionStatus.RECEIVED, - executionStatus: TransactionStatus.RECEIVED, - status: '', - failureReason: '', - eventIds: [], - }; - - const result = await upgradeAccContract(apiParams); - expect(result).to.be.equal(sendTransactionResp); - expect(upsertTransactionStub).to.calledOnceWith(sinon.match(txn)); - }); }); }); From c2ab083df47ebfc6e13131c37008670a0b554ad7 Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 10 Dec 2024 16:41:12 +0800 Subject: [PATCH 35/36] chore: update rpcs to handle new txn state data --- packages/starknet-snap/src/createAccount.ts | 3 +-- packages/starknet-snap/src/upgradeAccContract.ts | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/starknet-snap/src/createAccount.ts b/packages/starknet-snap/src/createAccount.ts index 8f8304ed..d8704183 100644 --- a/packages/starknet-snap/src/createAccount.ts +++ b/packages/starknet-snap/src/createAccount.ts @@ -6,8 +6,7 @@ import type { ApiParamsWithKeyDeriver, CreateAccountRequestParams, } from './types/snapApi'; -import type { AccContract, Transaction } from './types/snapState'; -import { VoyagerTransactionType, TransactionStatus } from './types/snapState'; +import type { AccContract } from './types/snapState'; import { CAIRO_VERSION_LEGACY, CAIRO_VERSION } from './utils/constants'; import { logger } from './utils/logger'; import { toJson } from './utils/serializer'; diff --git a/packages/starknet-snap/src/upgradeAccContract.ts b/packages/starknet-snap/src/upgradeAccContract.ts index 93b4e331..056b8003 100644 --- a/packages/starknet-snap/src/upgradeAccContract.ts +++ b/packages/starknet-snap/src/upgradeAccContract.ts @@ -5,8 +5,6 @@ import type { ApiParamsWithKeyDeriver, UpgradeTransactionRequestParams, } from './types/snapApi'; -import type { Transaction } from './types/snapState'; -import { TransactionStatus, VoyagerTransactionType } from './types/snapState'; import { ACCOUNT_CLASS_HASH, CAIRO_VERSION_LEGACY } from './utils/constants'; import { logger } from './utils/logger'; import { toJson } from './utils/serializer'; From 3d0c52972b23be4448f83d6cd065ddd96729707a Mon Sep 17 00:00:00 2001 From: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 11 Dec 2024 08:56:20 +0800 Subject: [PATCH 36/36] chore: fix naming --- packages/starknet-snap/src/utils/transaction.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/starknet-snap/src/utils/transaction.test.ts b/packages/starknet-snap/src/utils/transaction.test.ts index 17f89eae..768bfa24 100644 --- a/packages/starknet-snap/src/utils/transaction.test.ts +++ b/packages/starknet-snap/src/utils/transaction.test.ts @@ -63,7 +63,7 @@ describe('feeTokenToTransactionVersion', () => { }); it.each([FeeToken.ETH, 'invalid_unit'])( - 'converts feeToken string to transaction version v1 if it not STRK - %s', + 'converts feeToken string to transaction version v1 if it is not STRK - %s', (txnVersion: string) => { expect(feeTokenToTransactionVersion(txnVersion)).toStrictEqual( constants.TRANSACTION_VERSION.V1,