From 5b72ef9f8ab55ad57dcaca25e306d3feecffcf0d Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 13 Dec 2024 07:58:01 +0800 Subject: [PATCH] feat: refactor RPC `starkNet_getTransactions` with StarkScan (#463) * feat: refactor RPC `starkNet_getTransactions` with `StarkScan` DataClient (#453) * feat: add stark scan client * chore: add starkscan config * chore: lint * chore: add interface * chore: support multiple txn * chore: update starkscan * chore: update stark scan client * chore: update contract func name * chore: fix test * chore: update data client * chore: re-structure starkscan type * chore: add test coverage * chore: factory and config * chore: add backward compatibility for transactions type * chore: add comment * chore: lint * chore: resolve review comment * chore: change dataVersion to enum * chore: lint * chore: update test helper and refactor ContractAddressFilter * chore: lint * chore: add test for dataVersion filter * chore: update txn state mgr test * chore: update search condition * chore: update starkscan to handle missing selector_name * chore: apply starkscan for list transaction * chore: update list transactions handle * chore: resolve comment * chore: update function name --------- Co-authored-by: khanti42 * chore: handle multi calls on RPC `starkNet_executeTxn` (#457) * feat: add stark scan client * chore: add starkscan config * chore: lint * chore: add interface * chore: support multiple txn * chore: update starkscan * chore: update stark scan client * chore: update contract func name * chore: fix test * chore: update data client * chore: re-structure starkscan type * chore: add test coverage * chore: factory and config * chore: add backward compatibility for transactions type * chore: add comment * chore: lint * chore: resolve review comment * chore: change dataVersion to enum * chore: lint * chore: update test helper and refactor ContractAddressFilter * chore: lint * chore: add test for dataVersion filter * chore: update txn state mgr test * chore: update search condition * chore: update starkscan to handle missing selector_name * chore: apply starkscan for list transaction * chore: update list transactions handle * chore: refactor execute txn * chore: refactor execute txn * chore: update create account and upgrade account * chore: lint * Revert "chore: lint" This reverts commit 12be249bf41178628a7b4738a310512fecf0a686. * Revert "chore: update create account and upgrade account" This reverts commit cf64931fe269d2c0ed14c9e3237056fe7079b351. * chore: resolve comment * chore: resolve comment * chore: remove space --------- Co-authored-by: khanti42 * chore: update RPCs - `starkNet_createAccount` , `starkNet_createAccountLegacy`, `starkNet_upgradeAccContract` to handle new txn state (#458) * feat: add stark scan client * chore: add starkscan config * chore: lint * chore: add interface * chore: support multiple txn * chore: update starkscan * chore: update stark scan client * chore: update contract func name * chore: fix test * chore: update data client * chore: re-structure starkscan type * chore: add test coverage * chore: factory and config * chore: add backward compatibility for transactions type * chore: add comment * chore: lint * chore: resolve review comment * chore: change dataVersion to enum * chore: lint * chore: update test helper and refactor ContractAddressFilter * chore: lint * chore: add test for dataVersion filter * chore: update txn state mgr test * chore: update search condition * chore: update starkscan to handle missing selector_name * chore: apply starkscan for list transaction * chore: update list transactions handle * chore: refactor execute txn * chore: refactor execute txn * chore: update create account and upgrade account * chore: lint * Revert "chore: lint" This reverts commit 12be249bf41178628a7b4738a310512fecf0a686. * Revert "chore: update create account and upgrade account" This reverts commit cf64931fe269d2c0ed14c9e3237056fe7079b351. * chore: update rpcs to handle v2 data * chore: update rpcs to handle new txn state data * chore: fix naming --------- Co-authored-by: khanti42 * chore: update wallet-ui to match new getTransactions rpc format (#459) * feat: add stark scan client * chore: add starkscan config * chore: lint * chore: add interface * chore: support multiple txn * chore: update starkscan * chore: update stark scan client * chore: update contract func name * chore: fix test * chore: update data client * chore: re-structure starkscan type * chore: add test coverage * chore: factory and config * chore: add backward compatibility for transactions type * chore: add comment * chore: lint * chore: resolve review comment * chore: change dataVersion to enum * chore: lint * chore: update test helper and refactor ContractAddressFilter * chore: lint * chore: add test for dataVersion filter * chore: update txn state mgr test * chore: update search condition * chore: update starkscan to handle missing selector_name * chore: apply starkscan for list transaction * chore: update list transactions handle * chore: resolve comment * chore: update wallet-ui to handle new starkNet_getTransactions * chore: rename type * chore: remove unecessary input param * chore: remove unecessary input param * chore: change explorer link to starkscan * chore: fix comments --------- Co-authored-by: stanleyyuen <102275989+stanleyyconsensys@users.noreply.github.com> * chore: refine snap for starkscan migration (#462) * chore: refine wallet ui for starkscan migration (#461) --------- Co-authored-by: khanti42 --- .../starknet-snap/src/__tests__/helper.ts | 7 +- .../src/chain/data-client/starkscan.ts | 132 +-- .../src/chain/transaction-service.test.ts | 267 ++++++ .../src/chain/transaction-service.ts | 141 +++ packages/starknet-snap/src/config.ts | 11 + packages/starknet-snap/src/createAccount.ts | 21 +- packages/starknet-snap/src/index.tsx | 7 +- .../src/rpcs/__tests__/helper.ts | 2 +- .../src/rpcs/execute-txn.test.ts | 898 +++++++++++++----- .../starknet-snap/src/rpcs/execute-txn.ts | 426 +++++---- packages/starknet-snap/src/rpcs/index.ts | 1 + .../src/rpcs/list-transaction.test.ts | 88 ++ .../src/rpcs/list-transactions.ts | 75 ++ .../state/transaction-state-manager.test.ts | 4 +- packages/starknet-snap/src/types/snapState.ts | 25 +- packages/starknet-snap/src/ui/utils.test.tsx | 8 +- .../starknet-snap/src/upgradeAccContract.ts | 26 +- .../starknet-snap/src/utils/factory.test.ts | 17 +- packages/starknet-snap/src/utils/factory.ts | 21 + .../src/utils/formatter-utils.test.ts | 26 +- .../src/utils/formatter-utils.ts | 24 +- packages/starknet-snap/src/utils/snapUtils.ts | 4 +- .../starknet-snap/src/utils/starknetUtils.ts | 6 +- .../src/utils/superstruct.test.ts | 37 +- .../starknet-snap/src/utils/superstruct.ts | 59 +- .../src/utils/transaction.test.ts | 276 ++++++ .../starknet-snap/src/utils/transaction.ts | 216 +++++ .../test/src/upgradeAccContract.test.ts | 31 - .../TransactionListItem.stories.tsx | 32 +- .../TransactionListItem.style.tsx | 25 +- .../TransactionListItem.view.tsx | 10 +- .../TransactionListItem/types.ts | 109 +-- .../TransactionsList.stories.tsx | 232 +++-- .../TransactionsList.view.tsx | 2 - .../SendSummaryModal.view.tsx | 7 +- .../wallet-ui/src/services/useStarkNetSnap.ts | 10 +- packages/wallet-ui/src/types/index.ts | 46 +- packages/wallet-ui/src/utils/constants.ts | 4 +- 38 files changed, 2522 insertions(+), 811 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 create mode 100644 packages/starknet-snap/src/utils/transaction.test.ts create mode 100644 packages/starknet-snap/src/utils/transaction.ts diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 9ec61e2d..802e534c 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -26,6 +26,7 @@ import type { } from '../chain/data-client/starkscan.type'; import { FeeToken } from '../types/snapApi'; import { + ContractFuncName, TransactionDataVersion, type AccContract, type Transaction, @@ -268,7 +269,7 @@ export function generateTransactions({ const txnType = getRandomData(_txnTypes); const contractFuncName = txnType == TransactionType.INVOKE - ? getRandomData(['transfer', 'upgrade']) + ? getRandomData(Object.values(ContractFuncName)) : ''; transactions.push( @@ -303,6 +304,8 @@ function getTransactionTemplate() { finalityStatus: '', accountCalls: null, version: 1, + maxFee: null, + actualFee: null, dataVersion: TransactionDataVersion.V2, }; } @@ -464,7 +467,7 @@ export function generateTransactionRequests({ to: address, amount: '1', }), - entrypoint: 'transfer', + entrypoint: ContractFuncName.Transfer, }, ], includeDeploy: false, 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/chain/transaction-service.test.ts b/packages/starknet-snap/src/chain/transaction-service.test.ts new file mode 100644 index 00000000..58575c13 --- /dev/null +++ b/packages/starknet-snap/src/chain/transaction-service.test.ts @@ -0,0 +1,267 @@ +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, + ); + } + + hasMatchingContractOrIsDeploy(tx: Transaction, contractAddress: string) { + return super.hasMatchingContractOrIsDeploy(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.hasMatchingContractOrIsDeploy(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..a6bc8828 --- /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.hasMatchingContractOrIsDeploy(tx, contractAddress)) { + yield tx; + } + } + } + + protected hasMatchingContractOrIsDeploy( + 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/createAccount.ts b/packages/starknet-snap/src/createAccount.ts index bdf7e4c2..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'; @@ -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/index.tsx b/packages/starknet-snap/src/index.tsx index 59c40a70..b799bcb5 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/__tests__/helper.ts b/packages/starknet-snap/src/rpcs/__tests__/helper.ts index ea50cd8c..2cba2739 100644 --- a/packages/starknet-snap/src/rpcs/__tests__/helper.ts +++ b/packages/starknet-snap/src/rpcs/__tests__/helper.ts @@ -16,7 +16,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/execute-txn.test.ts b/packages/starknet-snap/src/rpcs/execute-txn.test.ts index 56da650c..801f2f31 100644 --- a/packages/starknet-snap/src/rpcs/execute-txn.test.ts +++ b/packages/starknet-snap/src/rpcs/execute-txn.test.ts @@ -1,329 +1,709 @@ -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, + newDeployTransaction as newDeployTransactionFn, + newInvokeTransaction as newInvokeTransactionFn, + 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 newInvokeTransaction = newInvokeTransactionFn({ + senderAddress: account.address, + txnHash: request.txnHashForExecute, + chainId: network.chainId, + maxFee, + txnVersion: transactionVersionToNumber(txnVersion), + calls, + }); + const newDeployTransaction = newDeployTransactionFn({ + 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 +712,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..3c7714d9 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,23 @@ 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, + newDeployTransaction, + newInvokeTransaction, +} from '../utils/transaction'; import type { AccountRpcControllerOptions } from './abstract/account-rpc-controller'; import { AccountRpcController } from './abstract/account-rpc-controller'; @@ -50,6 +58,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 +147,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 +284,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( + newDeployTransaction({ + senderAddress: address, + txnHash: txnHashForDeploy, + chainId, + txnVersion: txnVersionInNumber, + }), + ); + await this.accStateManager.updateAccountAsDeploy({ + address, + chainId, + transactionHash: txnHashForDeploy, + }); + } + + await this.txnStateManager.addTransaction( + newInvokeTransaction({ + senderAddress: address, + txnHash: txnHashForExecute, + chainId, + maxFee, + txnVersion: txnVersionInNumber, + calls, + }), + ); } } 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'; 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..26e55ef6 --- /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..efea5b75 --- /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/state/transaction-state-manager.test.ts b/packages/starknet-snap/src/state/transaction-state-manager.test.ts index bc0c90e9..8d84d965 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.test.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.test.ts @@ -7,7 +7,7 @@ import { import { generateTransactions } from '../__tests__/helper'; import type { V2Transaction } from '../types/snapState'; -import { TransactionDataVersion } from '../types/snapState'; +import { ContractFuncName, TransactionDataVersion } from '../types/snapState'; import { PRELOADED_TOKENS } from '../utils/constants'; import { mockAcccounts, mockState } from './__tests__/helper'; import { StateManagerError } from './state-manager'; @@ -147,7 +147,7 @@ describe('TransactionStateManager', () => { chainId: legacyData.chainId, senderAddress: legacyData.senderAddress, contractAddress: legacyData.contractAddress, - contractFuncName: 'transfer', + contractFuncName: ContractFuncName.Transfer, contractCallData: ['0x123', '0x456'], executionStatus: legacyData.executionStatus, finalityStatus: legacyData.finalityStatus, diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index 85132d19..ac9316e5 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 = { @@ -160,24 +160,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/ui/utils.test.tsx b/packages/starknet-snap/src/ui/utils.test.tsx index f0b98500..5483e271 100644 --- a/packages/starknet-snap/src/ui/utils.test.tsx +++ b/packages/starknet-snap/src/ui/utils.test.tsx @@ -1,7 +1,11 @@ import type { constants } from 'starknet'; import { generateAccounts } from '../__tests__/helper'; -import type { Erc20Token, FormattedCallData } from '../types/snapState'; +import { + ContractFuncName, + type Erc20Token, + type FormattedCallData, +} from '../types/snapState'; import { DEFAULT_DECIMAL_PLACES, BlockIdentifierEnum, @@ -138,7 +142,7 @@ describe('hasSufficientFundsForFee', () => { const calls: FormattedCallData[] = []; for (let i = 0; i < cnt; i++) { calls.push({ - entrypoint: 'transfer', + entrypoint: ContractFuncName.Transfer, contractAddress: token.address, tokenTransferData: { amount, diff --git a/packages/starknet-snap/src/upgradeAccContract.ts b/packages/starknet-snap/src/upgradeAccContract.ts index a6c16204..708739ec 100644 --- a/packages/starknet-snap/src/upgradeAccContract.ts +++ b/packages/starknet-snap/src/upgradeAccContract.ts @@ -5,8 +5,7 @@ import type { ApiParamsWithKeyDeriver, UpgradeTransactionRequestParams, } from './types/snapApi'; -import type { Transaction } from './types/snapState'; -import { TransactionStatus, VoyagerTransactionType } from './types/snapState'; +import { ContractFuncName } 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 +22,7 @@ import { isAccountDeployed, estimateFee, } from './utils/starknetUtils'; +import { newInvokeTransaction } from './utils/transaction'; /** * @@ -67,7 +67,7 @@ export async function upgradeAccContract(params: ApiParamsWithKeyDeriver) { contractAddress, ); - const method = 'upgrade'; + const method = ContractFuncName.Upgrade; const calldata = CallData.compile({ implementation: ACCOUNT_CLASS_HASH, @@ -145,21 +145,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/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..cd86dea5 100644 --- a/packages/starknet-snap/src/utils/formatter-utils.test.ts +++ b/packages/starknet-snap/src/utils/formatter-utils.test.ts @@ -3,11 +3,13 @@ import { constants } from 'starknet'; import { singleCall } from '../__tests__/fixture/callsExamples.json'; import { generateAccounts } from '../__tests__/helper'; import { TokenStateManager } from '../state/token-state-manager'; -import type { Erc20Token } from '../types/snapState'; +import { ContractFuncName, type Erc20Token } from '../types/snapState'; import { ETHER_SEPOLIA_TESTNET } from './constants'; import { callToTransactionReqCall, mapDeprecatedParams, + dayToSec, + msToSec, } from './formatter-utils'; import { logger } from './logger'; @@ -127,7 +129,7 @@ describe('callToTransactionReqCall', () => { const { senderAddress, recipientAddress } = await getSenderAndRecipient(); const call = { ...singleCall.calls, - entrypoint: 'transfer', + entrypoint: ContractFuncName.Transfer, calldata: [recipientAddress, '1000'], }; @@ -175,7 +177,7 @@ describe('callToTransactionReqCall', () => { const transferAmt = '1000'; const call = { ...singleCall.calls, - entrypoint: 'transfer', + entrypoint: ContractFuncName.Transfer, calldata: [recipientAddress, transferAmt], }; const token = ETHER_SEPOLIA_TESTNET; @@ -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..78e88e64 100644 --- a/packages/starknet-snap/src/utils/formatter-utils.ts +++ b/packages/starknet-snap/src/utils/formatter-utils.ts @@ -2,7 +2,7 @@ import type { Call } from 'starknet'; import { assert } from 'superstruct'; import type { TokenStateManager } from '../state/token-state-manager'; -import type { FormattedCallData } from '../types/snapState'; +import { ContractFuncName, type FormattedCallData } from '../types/snapState'; import { logger } from './logger'; import { AddressStruct, NumberStringStruct } from './superstruct'; @@ -61,7 +61,7 @@ export const callToTransactionReqCall = async ( }; // Check if the entrypoint is 'transfer' and the populate transfer fields - if (entrypoint === 'transfer' && calldata) { + if (entrypoint === ContractFuncName.Transfer && calldata) { try { const token = await tokenStateManager.getToken({ address: contractAddress, @@ -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/snapUtils.ts b/packages/starknet-snap/src/utils/snapUtils.ts index f1107acc..276e11f0 100644 --- a/packages/starknet-snap/src/utils/snapUtils.ts +++ b/packages/starknet-snap/src/utils/snapUtils.ts @@ -16,7 +16,7 @@ import type { import { Config } from '../config'; import { FeeToken, type AddNetworkRequestParams } from '../types/snapApi'; -import { TransactionStatus } from '../types/snapState'; +import { ContractFuncName, TransactionStatus } from '../types/snapState'; import type { Network, Erc20Token, @@ -312,7 +312,7 @@ export function getSendTxnText( ); addDialogTxt(components, 'Network', network.name); - if (token && contractFuncName === 'transfer') { + if (token && contractFuncName === ContractFuncName.Transfer) { try { let amount = ''; if ([3, 6, 9, 12, 15, 18].includes(token.decimals)) { diff --git a/packages/starknet-snap/src/utils/starknetUtils.ts b/packages/starknet-snap/src/utils/starknetUtils.ts index 7ac9211d..4002d25c 100644 --- a/packages/starknet-snap/src/utils/starknetUtils.ts +++ b/packages/starknet-snap/src/utils/starknetUtils.ts @@ -51,7 +51,7 @@ import type { SnapState, Transaction, } from '../types/snapState'; -import { TransactionType } from '../types/snapState'; +import { ContractFuncName, TransactionType } from '../types/snapState'; import type { DeployAccountPayload, TransactionResponse, @@ -596,10 +596,10 @@ export const getMassagedTransactions = async ( let txContractFuncName = ''; switch (txFuncSelector) { case bigIntTransferSelectorHex: - txContractFuncName = 'transfer'; + txContractFuncName = ContractFuncName.Transfer; break; case bigIntUpgradeSelectorHex: - txContractFuncName = 'upgrade'; + txContractFuncName = ContractFuncName.Upgrade; break; default: txContractFuncName = ''; diff --git a/packages/starknet-snap/src/utils/superstruct.test.ts b/packages/starknet-snap/src/utils/superstruct.test.ts index 44c8eced..50a4b656 100644 --- a/packages/starknet-snap/src/utils/superstruct.test.ts +++ b/packages/starknet-snap/src/utils/superstruct.test.ts @@ -4,6 +4,8 @@ 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 { ContractFuncName } from '../types/snapState'; import { ACCOUNT_CLASS_HASH, CAIRO_VERSION, @@ -27,6 +29,7 @@ import { ChainIdStruct, TokenSymbolStruct, TokenNameStruct, + TransactionStruct, } from './superstruct'; describe('TokenNameStruct', () => { @@ -392,7 +395,7 @@ describe('InvocationsStruct', () => { type: TransactionType.INVOKE, payload: { contractAddress: ETHER_SEPOLIA_TESTNET.address, - entrypoint: 'transfer', + entrypoint: ContractFuncName.Transfer, }, }, { @@ -424,7 +427,7 @@ describe('InvocationsStruct', () => { { type: TransactionType.INVOKE, payload: { - entrypoint: 'transfer', + entrypoint: ContractFuncName.Transfer, }, }, { @@ -500,7 +503,7 @@ describe('InvocationsStruct', () => { payload: [ { contractAddress: ETHER_SEPOLIA_TESTNET.address, - entrypoint: 'transfer', + entrypoint: ContractFuncName.Transfer, }, ], }, @@ -531,3 +534,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)), +}); 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..768bfa24 --- /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, + transactionSelectorToName, + 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 is 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('transactionSelectorToName', () => { + it.each([TRANSFER_SELECTOR_HEX, 'transfer'])( + 'converts selector name to `transfer` if it matchs the transfer selector - %s', + (selector: string) => { + expect(transactionSelectorToName(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(transactionSelectorToName(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(transactionSelectorToName(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: transactionSelectorToName(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: transactionSelectorToName(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 new file mode 100644 index 00000000..4430fc8f --- /dev/null +++ b/packages/starknet-snap/src/utils/transaction.ts @@ -0,0 +1,216 @@ +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. + * + * @param txnVersion - The transaction version. + * @returns The transaction version number. + */ +export function transactionVersionToNumber( + txnVersion: string | number, +): 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; +} + +/** + * Convert the transaction selector to string name. + * If the selector is not known, return the selector. + * + * @param selector - The transaction selector. + * @returns The meaningful name of the selector if it is known, otherwise return the selector. + */ +export function transactionSelectorToName(selector: string): string { + switch (selector.toLowerCase()) { + case ContractFuncName.Transfer: + case TRANSFER_SELECTOR_HEX.toLowerCase(): + return ContractFuncName.Transfer; + case ContractFuncName.Upgrade: + 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 = transactionSelectorToName(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, + }; +} 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)); - }); }); }); diff --git a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.stories.tsx b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.stories.tsx index c499c415..ab132ab4 100644 --- a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.stories.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.stories.tsx @@ -1,5 +1,5 @@ import { Meta } from '@storybook/react'; -import { Transaction } from 'types'; +import { ContractFuncName, Transaction, TransactionStatus } from 'types'; import { TransactionListItemView } from './TransactionListItem.view'; export default { @@ -12,18 +12,30 @@ const transaction: Transaction = { txnType: 'invoke', chainId: '0x534e5f5345504f4c4941', senderAddress: - '0x05ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', + '0x5ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', contractAddress: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - contractFuncName: 'transfer', - contractCallData: [ - '0x6b686ebe2cbd70b37b54df1b9889cc3095b55f386110843912efcaed416ff3f', - '0x0de0b6b3a7640000', - ], - timestamp: 1655705597, - status: 'Accepted on L1', - eventIds: ['245417_20_0'], + executionStatus: TransactionStatus.SUCCEEDED, + finalityStatus: TransactionStatus.RECEIVED, failureReason: '', + timestamp: 1655869759, + accountCalls: { + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7': [ + { + contract: + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + contractFuncName: ContractFuncName.Transfer, + contractCallData: [ + '0x6b686ebe2cbd70b37b54df1b9889cc3095b55f386110843912efcaed416ff3f', + '0x0de0b6b3a7640000', + ], + }, + ], + }, + maxFee: null, + actualFee: null, + version: 1, + dataVersion: '2', }; export const FullWidth = () => ( diff --git a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.style.tsx b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.style.tsx index e667e495..9291512a 100644 --- a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.style.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.style.tsx @@ -1,31 +1,32 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import styled from 'styled-components'; -import { TransactionStatusOptions } from 'types'; import { theme } from 'theme/default'; import { RoundedIcon } from 'components/ui/atom/RoundedIcon'; +import { + TransactionExecutionStatus, + TransactionFinalityStatus, +} from 'starknet'; interface ISpan { - status?: TransactionStatusOptions | string; + status?: string; } interface IIconeStyled { transactionname?: string; } -const getStatusColor = (status?: TransactionStatusOptions | string) => { +const getStatusColor = (status?: string) => { switch (status) { - case 'ACCEPTED ON L1': - case 'ACCEPTED ON L2': - case 'SUCCEEDED': + case TransactionFinalityStatus.ACCEPTED_ON_L1: + case TransactionFinalityStatus.ACCEPTED_ON_L2: + case TransactionExecutionStatus.SUCCEEDED: return theme.palette.success.dark; - case 'RECEIVED': + case TransactionFinalityStatus.RECEIVED: return theme.palette.info.main; - case 'REJECTED': - case 'NOT RECEIVED': - case 'REVERTED': + case TransactionFinalityStatus.NOT_RECEIVED: + case TransactionExecutionStatus.REJECTED: + case TransactionExecutionStatus.REVERTED: return theme.palette.error.main; - case 'PENDING': - return theme.palette.warning.main; default: return theme.palette.grey.grey1; } diff --git a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.view.tsx b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.view.tsx index 7cafbdb1..ccaac53b 100644 --- a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.view.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionListItem/TransactionListItem.view.tsx @@ -21,7 +21,6 @@ import { getTxnFailureReason, getTxnName, getTxnStatus, - getTxnToFromLabel, getTxnValues, } from './types'; import { getHumanReadableAmount, openExplorerTab } from 'utils/utils'; @@ -32,15 +31,17 @@ interface Props { export const TransactionListItemView = ({ transaction }: Props) => { const wallet = useAppSelector((state) => state.wallet); + const tokenAddress = wallet.erc20TokenBalanceSelected.address; const [currencySymbol, setCurrencySymbol] = useState('N/A'); const [txnValue, setTxnValue] = useState('0'); const [txnUsdValue, setTxnUsdValue] = useState('0.00'); useEffect(() => { const fetchData = async () => { + // Find the matching token const foundToken = wallet.erc20TokenBalances.find((token) => ethers.BigNumber.from(token.address).eq( - ethers.BigNumber.from(transaction.contractAddress), + ethers.BigNumber.from(tokenAddress), ), ); if (foundToken) { @@ -48,6 +49,7 @@ export const TransactionListItemView = ({ transaction }: Props) => { transaction, foundToken.decimals, foundToken.usdPrice, + tokenAddress, ); setTxnValue(getHumanReadableAmount(foundToken, txnValues.txnValue)); setTxnUsdValue(txnValues.txnUsdValue); @@ -58,10 +60,10 @@ export const TransactionListItemView = ({ transaction }: Props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const txnName = getTxnName(transaction); + const txnName = getTxnName(transaction, tokenAddress); const txnDate = getTxnDate(transaction); const txnStatus = getTxnStatus(transaction); - const txnToFromLabel = getTxnToFromLabel(transaction); + const txnToFromLabel = ''; const txnFailureReason = getTxnFailureReason(transaction); return ( { @@ -17,23 +17,33 @@ export const getIcon = (transactionName: string): IconProp => { } }; -export const getTxnName = (transaction: Transaction): string => { - if (transaction.txnType.toLowerCase() === VoyagerTransactionType.INVOKE) { - if (transaction.contractFuncName.toLowerCase() === 'transfer') { - return 'Send'; - } else if (transaction.contractFuncName.toLowerCase() === 'upgrade') { - return 'Upgrade Account'; - } - } else if ( - transaction.txnType.toLowerCase() === VoyagerTransactionType.DEPLOY - ) { - return 'Deploy'; - } else if ( - transaction.txnType.toLowerCase() === VoyagerTransactionType.DEPLOY_ACCOUNT - ) { - return 'Deploy Account'; +export const getTxnName = ( + transaction: Transaction, + contractAddress: string, +): string => { + switch (transaction.txnType) { + case TransactionType.INVOKE: + if ( + transaction.accountCalls && + transaction.accountCalls[contractAddress] !== undefined + ) { + for (const call of transaction.accountCalls[contractAddress]) { + if (call.contractFuncName === ContractFuncName.Transfer) { + return 'Receive'; + } + if (call.contractFuncName === ContractFuncName.Upgrade) { + return 'Upgrade Account'; + } + } + } + return 'Contract Interaction'; + case TransactionType.DEPLOY: + return 'Depoly'; + case TransactionType.DEPLOY_ACCOUNT: + return 'Deploy Account'; + default: + return 'Unknown'; } - return 'Unknown'; }; export const getTxnDate = (transaction: Transaction): string => { @@ -50,6 +60,9 @@ export const getTxnDate = (transaction: Transaction): string => { export const getTxnStatus = (transaction: Transaction): string => { let statusStr = []; + if (transaction.executionStatus) { + statusStr.push(formatStatus(transaction.executionStatus)); + } if (transaction.finalityStatus === transaction.executionStatus) { return transaction.finalityStatus ? formatStatus(transaction.finalityStatus) @@ -58,9 +71,6 @@ export const getTxnStatus = (transaction: Transaction): string => { if (transaction.finalityStatus) { statusStr.push(formatStatus(transaction.finalityStatus)); } - if (transaction.executionStatus) { - statusStr.push(formatStatus(transaction.executionStatus)); - } return statusStr.join(' / '); }; @@ -78,29 +88,6 @@ export const formatStatus = (status: string): string => { .join(' '); }; -export const getTxnToFromLabel = (transaction: Transaction): string => { - const txnName = getTxnName(transaction); - switch (txnName) { - case 'Send': - // TODO : This will not be needed after getTransactions revamp. - if (transaction.contractCallData.length === 3) { - return ( - 'To ' + shortenAddress(transaction.contractCallData[0].toString()) - ); - } else { - return ( - 'To ' + shortenAddress(transaction.contractCallData[4].toString()) - ); - } - case 'Receive': - return 'From ' + shortenAddress(transaction.senderAddress); - case 'Deploy': - return 'To ' + shortenAddress(transaction.contractAddress); - default: - return ''; - } -}; - export const getTxnFailureReason = (transaction: Transaction): string => { return transaction.executionStatus && transaction.executionStatus.toLowerCase() === @@ -114,24 +101,30 @@ export const getTxnValues = ( transaction: Transaction, decimals: number = 18, toUsdRate: number = 0, + tokenAddress: string, ) => { let txnValue = '0'; let txnUsdValue = '0'; + if ( + transaction.accountCalls && + transaction.accountCalls[tokenAddress] !== undefined + ) { + txnValue = ethers.utils.formatUnits( + // A transaction can have multiple contract calls with the same tokenAddress. + // Hence, it is necessary to sum the amount of all contract calls with the same tokenAddress. + transaction.accountCalls[tokenAddress].reduce((acc, call) => { + // When the contract function is `transfer`, + // there is a amount representing the transfer value of that contract call. + if (call.contractFuncName === ContractFuncName.Transfer) { + const value = BigInt(call.amount || '0'); + acc += value; + } + return acc; + }, BigInt(0)), + decimals, + ); - const txnName = getTxnName(transaction); - switch (txnName) { - case 'Send': - case 'Receive': - txnValue = ethers.utils.formatUnits( - transaction.contractCallData[ - transaction.contractCallData.length - 2 - ].toString(), - decimals, - ); - txnUsdValue = (parseFloat(txnValue) * toUsdRate).toFixed(2); - break; - default: - break; + txnUsdValue = (parseFloat(txnValue) * toUsdRate).toFixed(2); } return { txnValue, txnUsdValue }; diff --git a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.stories.tsx b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.stories.tsx index e0efc285..cefb872c 100644 --- a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.stories.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.stories.tsx @@ -1,5 +1,5 @@ import { Meta } from '@storybook/react'; -import { Transaction } from 'types'; +import { ContractFuncName, Transaction, TransactionStatus } from 'types'; import { TransactionsListView } from './TransactionsList.view'; export default { @@ -17,16 +17,27 @@ const transactions: Transaction[] = [ '0x5ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', contractAddress: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - contractFuncName: 'transfer', - contractCallData: [ - '0x6b686ebe2cbd70b37b54df1b9889cc3095b55f386110843912efcaed416ff3f', - '0x38d7ea4c68000', - '0x0', - ], - status: 'RECEIVED', + executionStatus: TransactionStatus.SUCCEEDED, + finalityStatus: TransactionStatus.RECEIVED, failureReason: '', - eventIds: [], timestamp: 1655869759, + accountCalls: { + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7': [ + { + contract: + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + contractFuncName: ContractFuncName.Transfer, + contractCallData: [ + '0x6b686ebe2cbd70b37b54df1b9889cc3095b55f386110843912efcaed416ff3f', + '0x0de0b6b3a7640000', + ], + }, + ], + }, + maxFee: null, + actualFee: null, + version: 1, + dataVersion: '2', }, { txnHash: @@ -37,15 +48,27 @@ const transactions: Transaction[] = [ '0x05ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', contractAddress: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - contractFuncName: 'transfer', - contractCallData: [ - '0x6b686ebe2cbd70b37b54df1b9889cc3095b55f386110843912efcaed416ff3f', - '0x0de0b6b3a7640000', - ], timestamp: 1655705597, - status: 'Accepted on L2', - eventIds: ['245417_20_0'], + executionStatus: TransactionStatus.SUCCEEDED, + finalityStatus: TransactionStatus.ACCEPTED_ON_L2, failureReason: '', + accountCalls: { + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7': [ + { + contract: + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + contractFuncName: ContractFuncName.Transfer, + contractCallData: [ + '0x6b686ebe2cbd70b37b54df1b9889cc3095b55f386110843912efcaed416ff3f', + '0x0de0b6b3a7640000', + ], + }, + ], + }, + maxFee: null, + actualFee: null, + version: 1, + dataVersion: '2', }, { txnHash: @@ -56,17 +79,28 @@ const transactions: Transaction[] = [ '0x5ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', contractAddress: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - contractFuncName: 'transfer', - contractCallData: [ - '0x6b686ebe2cbd70b37b54df1b9889cc3095b55f386110843912efcaed416ff3f', - '0xde0b6b3a7640000', - '0x0', - ], - status: 'REJECTED', + executionStatus: TransactionStatus.REJECTED, + finalityStatus: TransactionStatus.NOT_RECEIVED, failureReason: 'Actual fee exceeded max fee.\n13056675060932 > 9585012591398', - eventIds: [], timestamp: 1655695493, + accountCalls: { + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7': [ + { + contract: + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + contractFuncName: ContractFuncName.Transfer, + contractCallData: [ + '0x6b686ebe2cbd70b37b54df1b9889cc3095b55f386110843912efcaed416ff3f', + '0x0de0b6b3a7640000', + ], + }, + ], + }, + maxFee: null, + actualFee: null, + version: 1, + dataVersion: '2', }, { txnHash: @@ -77,15 +111,27 @@ const transactions: Transaction[] = [ '0x05ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', contractAddress: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - contractFuncName: 'transfer', - contractCallData: [ - '0x14361d05e560796ad3152e083b609f5205f3bd76039327326746ba7f769a666', - '0x0de0b6b3a7640000', - ], timestamp: 1654745214, - status: 'Accepted on L1', - eventIds: ['233927_16_0'], + executionStatus: TransactionStatus.SUCCEEDED, + finalityStatus: TransactionStatus.ACCEPTED_ON_L1, failureReason: '', + accountCalls: { + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7': [ + { + contract: + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + contractFuncName: ContractFuncName.Transfer, + contractCallData: [ + '0x14361d05e560796ad3152e083b609f5205f3bd76039327326746ba7f769a666', + '0x0de0b6b3a7640000', + ], + }, + ], + }, + maxFee: null, + actualFee: null, + version: 1, + dataVersion: '2', }, { txnHash: @@ -96,15 +142,27 @@ const transactions: Transaction[] = [ '0x05ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', contractAddress: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - contractFuncName: 'transfer', - contractCallData: [ - '0x14361d05e560796ad3152e083b609f5205f3bd76039327326746ba7f769a666', - '0x0de0b6b3a7640000', - ], timestamp: 1654708040, - status: 'Accepted on L1', - eventIds: ['233510_7_0'], + executionStatus: TransactionStatus.SUCCEEDED, + finalityStatus: TransactionStatus.ACCEPTED_ON_L1, failureReason: '', + accountCalls: { + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7': [ + { + contract: + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + contractFuncName: ContractFuncName.Transfer, + contractCallData: [ + '0x14361d05e560796ad3152e083b609f5205f3bd76039327326746ba7f769a666', + '0x0de0b6b3a7640000', + ], + }, + ], + }, + maxFee: null, + actualFee: null, + version: 1, + dataVersion: '2', }, { txnHash: @@ -115,15 +173,27 @@ const transactions: Transaction[] = [ '0x05ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', contractAddress: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - contractFuncName: 'transfer', - contractCallData: [ - '0x14361d05e560796ad3152e083b609f5205f3bd76039327326746ba7f769a666', - '0x1bc16d674ec80000', - ], timestamp: 1654701586, - status: 'Accepted on L1', - eventIds: ['233442_14_0'], + executionStatus: TransactionStatus.SUCCEEDED, + finalityStatus: TransactionStatus.ACCEPTED_ON_L1, failureReason: '', + accountCalls: { + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7': [ + { + contract: + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + contractFuncName: ContractFuncName.Transfer, + contractCallData: [ + '0x14361d05e560796ad3152e083b609f5205f3bd76039327326746ba7f769a666', + '0x1bc16d674ec80000', + ], + }, + ], + }, + maxFee: null, + actualFee: null, + version: 1, + dataVersion: '2', }, { txnHash: '0x81fab4268648483028b9be3353d6551ca35041fb2a03c2f372b19f3ab109b1', @@ -133,15 +203,27 @@ const transactions: Transaction[] = [ '0x05ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', contractAddress: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - contractFuncName: 'transfer', - contractCallData: [ - '0x256d8f49882cc9366037415f48fa9fd2b5b7344ded7573ebfcef7c90e3e6b75', - '0x0de0b6b3a7640000', - ], timestamp: 1654678571, - status: 'Accepted on L1', - eventIds: ['233196_29_0'], + executionStatus: TransactionStatus.SUCCEEDED, + finalityStatus: TransactionStatus.ACCEPTED_ON_L1, failureReason: '', + accountCalls: { + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7': [ + { + contract: + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + contractFuncName: ContractFuncName.Transfer, + contractCallData: [ + '0x256d8f49882cc9366037415f48fa9fd2b5b7344ded7573ebfcef7c90e3e6b75', + '0x0de0b6b3a7640000', + ], + }, + ], + }, + maxFee: null, + actualFee: null, + version: 1, + dataVersion: '2', }, { txnHash: @@ -152,15 +234,27 @@ const transactions: Transaction[] = [ '0x05ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', contractAddress: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - contractFuncName: 'transfer', - contractCallData: [ - '0x256d8f49882cc9366037415f48fa9fd2b5b7344ded7573ebfcef7c90e3e6b75', - '0x3782dace9d900000', - ], timestamp: 1654670913, - status: 'Accepted on L1', - eventIds: ['233116_23_0'], + executionStatus: TransactionStatus.SUCCEEDED, + finalityStatus: TransactionStatus.ACCEPTED_ON_L1, failureReason: '', + accountCalls: { + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7': [ + { + contract: + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + contractFuncName: ContractFuncName.Transfer, + contractCallData: [ + '0x256d8f49882cc9366037415f48fa9fd2b5b7344ded7573ebfcef7c90e3e6b75', + '0x3782dace9d900000', + ], + }, + ], + }, + maxFee: null, + actualFee: null, + version: 1, + dataVersion: '2', }, { txnHash: @@ -171,12 +265,24 @@ const transactions: Transaction[] = [ '0x05ccc9fc2d7ce9e2b0f2cee1a4b898570bb4d03ba23ad6f72f0db971bd04552c', contractAddress: '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', - contractFuncName: '', - contractCallData: [], timestamp: 1654662459, - status: 'Accepted on L1', - eventIds: ['233027_3_0'], + executionStatus: TransactionStatus.SUCCEEDED, + finalityStatus: TransactionStatus.ACCEPTED_ON_L1, failureReason: '', + accountCalls: { + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7': [ + { + contract: + '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7', + contractFuncName: '', + contractCallData: [], + }, + ], + }, + maxFee: null, + actualFee: null, + version: 1, + dataVersion: '2', }, ]; diff --git a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.view.tsx b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.view.tsx index bbb68d0d..efa01a26 100644 --- a/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.view.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/TransactionsList/TransactionsList.view.tsx @@ -28,7 +28,6 @@ export const TransactionsListView = ({ transactions }: Props) => { address, wallet.erc20TokenBalanceSelected.address, 10, - 10, chain, false, true, @@ -50,7 +49,6 @@ export const TransactionsListView = ({ transactions }: Props) => { address, wallet.erc20TokenBalanceSelected.address, 10, - 10, chain, ); } diff --git a/packages/wallet-ui/src/components/ui/organism/Header/SendSummaryModal/SendSummaryModal.view.tsx b/packages/wallet-ui/src/components/ui/organism/Header/SendSummaryModal/SendSummaryModal.view.tsx index 8428a122..95807fbf 100644 --- a/packages/wallet-ui/src/components/ui/organism/Header/SendSummaryModal/SendSummaryModal.view.tsx +++ b/packages/wallet-ui/src/components/ui/organism/Header/SendSummaryModal/SendSummaryModal.view.tsx @@ -32,7 +32,7 @@ import { useStarkNetSnap } from 'services'; import { ethers } from 'ethers'; import Toastr from 'toastr2'; import { constants } from 'starknet'; -import { FeeToken, FeeTokenUnit } from 'types'; +import { ContractFuncName, FeeToken, FeeTokenUnit } from 'types'; interface Props { address: string; @@ -90,7 +90,7 @@ export const SendSummaryModalView = ({ const callData = address + ',' + amountBN.toString() + ',0'; estimateFees( wallet.erc20TokenBalanceSelected.address, - 'transfer', + ContractFuncName.Transfer, callData, wallet.accounts[0] as unknown as string, chainId, @@ -183,7 +183,7 @@ export const SendSummaryModalView = ({ const callData = address + ',' + amountBN.toString() + ',0'; sendTransaction( wallet.erc20TokenBalanceSelected.address, - 'transfer', + ContractFuncName.Transfer, callData, wallet.accounts[0] as unknown as string, gasFees.suggestedMaxFee, @@ -197,7 +197,6 @@ export const SendSummaryModalView = ({ wallet.accounts[0] as unknown as string, wallet.erc20TokenBalanceSelected.address, 10, - 10, chainId, false, true, diff --git a/packages/wallet-ui/src/services/useStarkNetSnap.ts b/packages/wallet-ui/src/services/useStarkNetSnap.ts index 3ce4222e..17b95dd7 100644 --- a/packages/wallet-ui/src/services/useStarkNetSnap.ts +++ b/packages/wallet-ui/src/services/useStarkNetSnap.ts @@ -24,7 +24,7 @@ import { isUserDenyError, } from '../utils/utils'; import { setWalletConnection } from '../slices/walletSlice'; -import { Network, VoyagerTransactionType } from '../types'; +import { Network } from '../types'; import { Account } from '../types'; import { Erc20TokenBalance, Erc20Token } from '../types'; import { disableLoading, enableLoadingWithMessage } from '../slices/UISlice'; @@ -545,7 +545,6 @@ export const useStarkNetSnap = () => { const getTransactions = async ( senderAddress: string, contractAddress: string, - pageSize: number, txnsInLastNumOfDays: number, chainId: string, showLoading: boolean = true, @@ -566,10 +565,7 @@ export const useStarkNetSnap = () => { ...defaultParam, senderAddress, contractAddress, - pageSize, txnsInLastNumOfDays, - onlyFromState, - withDeployTxn: true, chainId, }, }, @@ -596,8 +592,8 @@ export const useStarkNetSnap = () => { //Set the deploy transaction const deployTransaction = storedTxns.find( (txn: Transaction) => - txn.txnType.toLowerCase() === VoyagerTransactionType.DEPLOY || - txn.txnType.toLowerCase() === VoyagerTransactionType.DEPLOY_ACCOUNT, + txn.txnType === TransactionType.DEPLOY || + txn.txnType === TransactionType.DEPLOY_ACCOUNT, ); dispatch(setTransactionDeploy(deployTransaction)); diff --git a/packages/wallet-ui/src/types/index.ts b/packages/wallet-ui/src/types/index.ts index d6c064a8..657538af 100644 --- a/packages/wallet-ui/src/types/index.ts +++ b/packages/wallet-ui/src/types/index.ts @@ -1,19 +1,30 @@ import { BigNumber } from 'ethers'; +type AccountCall = { + contract: string; // HexStruct resolves to a string in this context + contractFuncName: string; + contractCallData: string[]; + recipient?: string; + amount?: string; +}; + +type AccountCalls = Record | null; + export type Transaction = { txnHash: string; // in hex txnType: string; chainId: string; // in hex senderAddress: string; // in hex contractAddress: string; // in hex - contractFuncName: string; - contractCallData: string[] | number[]; - status?: TransactionStatus | string; - executionStatus?: TransactionStatus | string; - finalityStatus?: TransactionStatus | string; + executionStatus: TransactionStatus | string; + finalityStatus: TransactionStatus | string; failureReason: string; - eventIds: string[]; + actualFee: string | null; + maxFee: string | null; timestamp: number; + accountCalls: AccountCalls; + version: number; + dataVersion: string; }; export type Account = { @@ -40,27 +51,20 @@ export interface Erc20TokenBalance extends Erc20Token { amount: BigNumber; usdPrice?: number; } -export type TransactionStatusOptions = - | 'Received' - | 'Pending' - | 'Accepted on L2' - | 'Accepted on L1' - | 'Rejected' - | 'Not Received'; - -export enum VoyagerTransactionType { // for retrieving txns from Voyager - DEPLOY = 'deploy', - DEPLOY_ACCOUNT = 'deploy_account', - INVOKE = 'invoke', -} export enum TransactionStatus { // for retrieving txn from Starknet feeder gateway + NOT_RECEIVED = 'NOT_RECEIVED', RECEIVED = 'RECEIVED', - PENDING = 'PENDING', ACCEPTED_ON_L2 = 'ACCEPTED_ON_L2', ACCEPTED_ON_L1 = 'ACCEPTED_ON_L1', - NOT_RECEIVED = 'NOT_RECEIVED', REJECTED = 'REJECTED', + REVERTED = 'REVERTED', + SUCCEEDED = 'SUCCEEDED', +} + +export enum ContractFuncName { + Upgrade = 'upgrade', + Transfer = 'transfer', } export enum BalanceType { diff --git a/packages/wallet-ui/src/utils/constants.ts b/packages/wallet-ui/src/utils/constants.ts index 4c930ead..1c79c6c1 100644 --- a/packages/wallet-ui/src/utils/constants.ts +++ b/packages/wallet-ui/src/utils/constants.ts @@ -39,10 +39,10 @@ export const DECIMALS_DISPLAYED_MAX_LENGTH = 11; export const COINGECKO_API = 'https://api.coingecko.com/api/v3/'; -export const STARKNET_MAINNET_EXPLORER = 'https://voyager.online/'; +export const STARKNET_MAINNET_EXPLORER = 'https://starkscan.co/'; export const STARKNET_SEPOLIA_TESTNET_EXPLORER = - 'https://sepolia.voyager.online/'; + 'https://sepolia.starkscan.co/'; export const SNAPS_DOC_URL = 'https://docs.metamask.io/guide/snaps.html';