From 5d569a88ed0605e4f022de5b567acfd5c8fc6f15 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:57:19 +0800 Subject: [PATCH 1/6] chore: revamp RPC `starkNet_getTransactionStatus` (#447) * chore: add chain rpc controller * chore: relocate base rpc controller * chore: revamp getTransactionStatus API * chore: update get transaction status code * chore: remove mocha test * chore: remove get transactions test --- .../starknet-snap/src/getTransactionStatus.ts | 35 --- packages/starknet-snap/src/index.tsx | 7 +- .../src/rpcs/get-transaction-status.test.ts | 74 +++++ .../src/rpcs/get-transaction-status.ts | 69 +++++ packages/starknet-snap/src/rpcs/index.ts | 1 + packages/starknet-snap/src/types/snapApi.ts | 6 +- .../test/src/getTransactionStatus.test.ts | 69 ----- .../test/src/getTransactions.test.ts | 276 ------------------ 8 files changed, 153 insertions(+), 384 deletions(-) delete mode 100644 packages/starknet-snap/src/getTransactionStatus.ts create mode 100644 packages/starknet-snap/src/rpcs/get-transaction-status.test.ts create mode 100644 packages/starknet-snap/src/rpcs/get-transaction-status.ts delete mode 100644 packages/starknet-snap/test/src/getTransactionStatus.test.ts delete mode 100644 packages/starknet-snap/test/src/getTransactions.test.ts diff --git a/packages/starknet-snap/src/getTransactionStatus.ts b/packages/starknet-snap/src/getTransactionStatus.ts deleted file mode 100644 index 998a1165..00000000 --- a/packages/starknet-snap/src/getTransactionStatus.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { - ApiParams, - GetTransactionStatusRequestParams, -} from './types/snapApi'; -import { logger } from './utils/logger'; -import { toJson } from './utils/serializer'; -import { getNetworkFromChainId } from './utils/snapUtils'; -import * as utils from './utils/starknetUtils'; - -/** - * - * @param params - */ -export async function getTransactionStatus(params: ApiParams) { - try { - const { state, requestParams } = params; - const requestParamsObj = requestParams as GetTransactionStatusRequestParams; - - const { transactionHash } = requestParamsObj; - const network = getNetworkFromChainId(state, requestParamsObj.chainId); - - const getTxnStatusResp = await utils.getTransactionStatus( - transactionHash, - network, - ); - logger.log( - `getTransactionStatus:\ngetTxnStatusResp: ${toJson(getTxnStatusResp)}`, - ); - - return getTxnStatusResp; - } catch (error) { - logger.error(`Problem found:`, error); - throw error; - } -} diff --git a/packages/starknet-snap/src/index.tsx b/packages/starknet-snap/src/index.tsx index af3b7b3f..d43e696a 100644 --- a/packages/starknet-snap/src/index.tsx +++ b/packages/starknet-snap/src/index.tsx @@ -23,7 +23,6 @@ import { getStoredNetworks } from './getStoredNetworks'; import { getStoredTransactions } from './getStoredTransactions'; import { getStoredUserAccounts } from './getStoredUserAccounts'; import { getTransactions } from './getTransactions'; -import { getTransactionStatus } from './getTransactionStatus'; import { getValue } from './getValue'; import { homePageController } from './on-home-page'; import { recoverAccounts } from './recoverAccounts'; @@ -39,6 +38,7 @@ import type { GetDeploymentDataParams, DeclareContractParams, WatchAssetParams, + GetTransactionStatusParams, } from './rpcs'; import { displayPrivateKey, @@ -52,6 +52,7 @@ import { switchNetwork, getDeploymentData, watchAsset, + getTransactionStatus, } from './rpcs'; import { signDeployAccountTransaction } from './signDeployAccountTransaction'; import type { @@ -201,7 +202,9 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { return await getErc20TokenBalance(apiParams); case 'starkNet_getTransactionStatus': - return await getTransactionStatus(apiParams); + return await getTransactionStatus.execute( + apiParams.requestParams as unknown as GetTransactionStatusParams, + ); case 'starkNet_getValue': return await getValue(apiParams); diff --git a/packages/starknet-snap/src/rpcs/get-transaction-status.test.ts b/packages/starknet-snap/src/rpcs/get-transaction-status.test.ts new file mode 100644 index 00000000..07e76d46 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/get-transaction-status.test.ts @@ -0,0 +1,74 @@ +import type { constants } from 'starknet'; +import { + TransactionExecutionStatus, + TransactionFinalityStatus, +} from 'starknet'; + +import { mockNetworkStateManager } from '../state/__tests__/helper'; +import type { Network } from '../types/snapState'; +import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../utils/constants'; +import { InvalidRequestParamsError } from '../utils/exceptions'; +import * as starknetUtils from '../utils/starknetUtils'; +import type { GetTransactionStatusParams } from './get-transaction-status'; +import { getTransactionStatus } from './get-transaction-status'; + +jest.mock('../utils/snap'); +jest.mock('../utils/logger'); + +describe('GetTransactionStatusRpc', () => { + const prepareGetTransactionStatusTest = ({ + network, + status, + }: { + network: Network; + status: { + finalityStatus: TransactionFinalityStatus; + executionStatus: TransactionExecutionStatus; + }; + }) => { + const { getNetworkSpy } = mockNetworkStateManager(network); + + const getTransactionStatusSpy = jest.spyOn( + starknetUtils, + 'getTransactionStatus', + ); + + getTransactionStatusSpy.mockResolvedValue(status); + + return { + getTransactionStatusSpy, + getNetworkSpy, + }; + }; + + it('returns transaction status', async () => { + const network = STARKNET_SEPOLIA_TESTNET_NETWORK; + const transactionHash = + '0x06385d46da9fbed4a5798298b17df069ac5f786e4c9f8f6b81c665540aea245a'; + const expectedResult = { + finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, + executionStatus: TransactionExecutionStatus.SUCCEEDED, + }; + const { getTransactionStatusSpy } = prepareGetTransactionStatusTest({ + network, + status: expectedResult, + }); + + const result = await getTransactionStatus.execute({ + chainId: network.chainId as unknown as constants.StarknetChainId, + transactionHash, + }); + + expect(result).toStrictEqual(expectedResult); + expect(getTransactionStatusSpy).toHaveBeenCalledWith( + transactionHash, + network, + ); + }); + + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { + await expect( + getTransactionStatus.execute({} as unknown as GetTransactionStatusParams), + ).rejects.toThrow(InvalidRequestParamsError); + }); +}); diff --git a/packages/starknet-snap/src/rpcs/get-transaction-status.ts b/packages/starknet-snap/src/rpcs/get-transaction-status.ts new file mode 100644 index 00000000..7f8ed16f --- /dev/null +++ b/packages/starknet-snap/src/rpcs/get-transaction-status.ts @@ -0,0 +1,69 @@ +import { HexStruct } from '@metamask/utils'; +import { + TransactionExecutionStatus, + TransactionFinalityStatus, +} from 'starknet'; +import type { Infer } from 'superstruct'; +import { assign, nonempty, object, enums, optional } from 'superstruct'; + +import { BaseRequestStruct } from '../utils'; +import { getTransactionStatus as getTransactionStatusFn } from '../utils/starknetUtils'; +import { ChainRpcController } from './abstract/chain-rpc-controller'; + +export const GetTransactionStatusRequestStruct = assign( + object({ + transactionHash: nonempty(HexStruct), + }), + BaseRequestStruct, +); + +export const GetTransactionStatusResponseStruct = object({ + executionStatus: optional(enums(Object.values(TransactionExecutionStatus))), + finalityStatus: optional(enums(Object.values(TransactionFinalityStatus))), +}); + +export type GetTransactionStatusParams = Infer< + typeof GetTransactionStatusRequestStruct +>; + +export type GetTransactionStatusResponse = Infer< + typeof GetTransactionStatusResponseStruct +>; + +/** + * The RPC handler to get a transaction status by the given transaction hash. + */ +export class GetTransactionStatusRpc extends ChainRpcController< + GetTransactionStatusParams, + GetTransactionStatusResponse +> { + protected requestStruct = GetTransactionStatusRequestStruct; + + protected responseStruct = GetTransactionStatusResponseStruct; + + /** + * Execute the get transaction request handler. + * + * @param params - The parameters of the request. + * @param params.transactionHash - The transaction hash to enquire the transaction status. + * @param params.chainId - The chain id of the transaction. + * @returns A promise that resolves to a GetTransactionStatusResponse object that contains executionStatus and finalityStatus. + */ + async execute( + params: GetTransactionStatusParams, + ): Promise { + return super.execute(params); + } + + protected async handleRequest( + params: GetTransactionStatusParams, + ): Promise { + const { transactionHash } = params; + + const resp = await getTransactionStatusFn(transactionHash, this.network); + + return resp; + } +} + +export const getTransactionStatus = new GetTransactionStatusRpc(); diff --git a/packages/starknet-snap/src/rpcs/index.ts b/packages/starknet-snap/src/rpcs/index.ts index 09e65cf3..609edb5d 100644 --- a/packages/starknet-snap/src/rpcs/index.ts +++ b/packages/starknet-snap/src/rpcs/index.ts @@ -9,3 +9,4 @@ export * from './verify-signature'; export * from './switch-network'; export * from './get-deployment-data'; export * from './watch-asset'; +export * from './get-transaction-status'; diff --git a/packages/starknet-snap/src/types/snapApi.ts b/packages/starknet-snap/src/types/snapApi.ts index 639952de..45cb569d 100644 --- a/packages/starknet-snap/src/types/snapApi.ts +++ b/packages/starknet-snap/src/types/snapApi.ts @@ -7,6 +7,8 @@ import type { EstimateFeeDetails, DeployAccountSignerDetails, constants, + TransactionExecutionStatus, + TransactionFinalityStatus, } from 'starknet'; import type { SnapState, VoyagerTransactionType } from './snapState'; @@ -152,8 +154,8 @@ export type DeclareContractRequestParams = { } & BaseRequestParams; export type RpcV4GetTransactionReceiptResponse = { - execution_status?: string; - finality_status?: string; + execution_status?: TransactionExecutionStatus; + finality_status?: TransactionFinalityStatus; }; export type Authorizable = { diff --git a/packages/starknet-snap/test/src/getTransactionStatus.test.ts b/packages/starknet-snap/test/src/getTransactionStatus.test.ts deleted file mode 100644 index cc3b67fc..00000000 --- a/packages/starknet-snap/test/src/getTransactionStatus.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import chai, { expect } from 'chai'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { WalletMock } from '../wallet.mock.test'; -import * as utils from '../../src/utils/starknetUtils'; -import { getTransactionStatus } from '../../src/getTransactionStatus'; -import { SnapState } from '../../src/types/snapState'; -import { STARKNET_MAINNET_NETWORK } from '../../src/utils/constants'; -import { getTxnStatusResp } from '../constants.test'; -import { Mutex } from 'async-mutex'; -import { - ApiParams, - GetTransactionStatusRequestParams, -} from '../../src/types/snapApi'; - -chai.use(sinonChai); -const sandbox = sinon.createSandbox(); - -describe('Test function: getTransactionStatus', function () { - const walletStub = new WalletMock(); - const state: SnapState = { - accContracts: [], - erc20Tokens: [], - networks: [STARKNET_MAINNET_NETWORK], - transactions: [], - }; - const apiParams: ApiParams = { - state, - requestParams: {}, - wallet: walletStub, - saveMutex: new Mutex(), - }; - - afterEach(function () { - walletStub.reset(); - sandbox.restore(); - }); - - it('should get the transaction status correctly', async function () { - sandbox.stub(utils, 'getTransactionStatus').callsFake(async () => { - return getTxnStatusResp; - }); - const requestObject: GetTransactionStatusRequestParams = { - transactionHash: - '0x27f204588cadd08a7914f6a9808b34de0cbfc4cb53aa053663e7fd3a34dbc26', - }; - apiParams.requestParams = requestObject; - const result = await getTransactionStatus(apiParams); - expect(result).to.be.eq(getTxnStatusResp); - }); - - it('should throw error if getTransactionStatus failed', async function () { - sandbox.stub(utils, 'getTransactionStatus').throws(new Error()); - const requestObject: GetTransactionStatusRequestParams = { - transactionHash: - '0x27f204588cadd08a7914f6a9808b34de0cbfc4cb53aa053663e7fd3a34dbc26', - }; - apiParams.requestParams = requestObject; - - let result; - try { - await getTransactionStatus(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); -}); diff --git a/packages/starknet-snap/test/src/getTransactions.test.ts b/packages/starknet-snap/test/src/getTransactions.test.ts deleted file mode 100644 index 5688c26c..00000000 --- a/packages/starknet-snap/test/src/getTransactions.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import chai, { expect } from 'chai'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { WalletMock } from '../wallet.mock.test'; -import * as utils from '../../src/utils/starknetUtils'; -import * as snapUtils from '../../src/utils/snapUtils'; -import { SnapState, Transaction } from '../../src/types/snapState'; -import { - STARKNET_SEPOLIA_TESTNET_NETWORK, - STARKNET_MAINNET_NETWORK, -} from '../../src/utils/constants'; -import { - createAccountProxyTxn, - expectedMassagedTxn4, - expectedMassagedTxn5, - expectedMassagedTxns, - getTxnFromSequencerResp1, - getTxnFromSequencerResp2, - getTxnStatusAcceptL2Resp, - getTxnStatusResp, - getTxnsFromVoyagerResp, - unsettedTransactionInMassagedTxn, - initAccountTxn, - txn1, - txn2, - txn3, - txn4, - txn5, - mainnetTxn1, -} from '../constants.test'; -import { getTransactions, updateStatus } from '../../src/getTransactions'; -import { Mutex } from 'async-mutex'; -import { - ApiParams, - GetTransactionsRequestParams, -} from '../../src/types/snapApi'; -import { GetTransactionResponse, num } from 'starknet'; -import { VoyagerTransactions } from '../../src/types/voyager'; -import { TransactionStatuses } from '../../src/types/starknet'; - -chai.use(sinonChai); -const sandbox = sinon.createSandbox(); -describe('Test function: getTransactions', function () { - const walletStub = new WalletMock(); - let getTransactionStatusStub: sinon.SinonStub; - const state: SnapState = { - accContracts: [], - erc20Tokens: [], - networks: [STARKNET_SEPOLIA_TESTNET_NETWORK, STARKNET_MAINNET_NETWORK], - transactions: [ - { ...unsettedTransactionInMassagedTxn }, - { ...txn1 }, - { ...txn2 }, - { ...txn3 }, - { ...txn4 }, - { ...txn5 }, - { ...mainnetTxn1 }, - { ...createAccountProxyTxn }, - { ...initAccountTxn }, - ], - }; - const apiParams: ApiParams = { - state, - requestParams: {}, - wallet: walletStub, - saveMutex: new Mutex(), - }; - - beforeEach(function () { - sandbox.useFakeTimers(1653553083147); - sandbox.stub(utils, 'getTransactionsFromVoyager').callsFake(async () => { - return getTxnsFromVoyagerResp as unknown as VoyagerTransactions; - }); - sandbox.stub(utils, 'getTransaction').callsFake(async (...args) => { - if (args?.[0] === getTxnsFromVoyagerResp.items[0].hash) { - return getTxnFromSequencerResp1 as unknown as GetTransactionResponse; - } else if (args?.[0] === getTxnsFromVoyagerResp.items[1].hash) { - return getTxnFromSequencerResp2 as unknown as GetTransactionResponse; - } else { - return null as unknown as GetTransactionResponse; - } - }); - getTransactionStatusStub = sandbox - .stub(utils, 'getTransactionStatus') - .callsFake(async (...args) => { - if (args?.[0] === getTxnsFromVoyagerResp.items[0].hash) { - return getTxnStatusResp as unknown as TransactionStatuses; - } else if (args?.[0] === getTxnsFromVoyagerResp.items[1].hash) { - return getTxnStatusResp as unknown as TransactionStatuses; - } else if (args?.[0] === expectedMassagedTxn5.txnHash) { - return undefined as unknown as TransactionStatuses; - } - return getTxnStatusAcceptL2Resp as unknown as TransactionStatuses; - }); - walletStub.rpcStubs.snap_manageState.resolves(state); - }); - - afterEach(function () { - walletStub.reset(); - sandbox.restore(); - }); - - it('should get the transactions from Voyager of testnet correctly', async function () { - const requestObject: GetTransactionsRequestParams = { - senderAddress: txn4.senderAddress, - pageSize: '10', - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - }; - apiParams.requestParams = requestObject; - - const result = await getTransactions(apiParams); - - expect(walletStub.rpcStubs.snap_manageState).to.have.been.called; - expect(result.length).to.be.eq(4); - expect(result).to.be.eql(expectedMassagedTxns); - }); - - it('should merge the transactions stored in snap state correctly', async function () { - const requestObject: GetTransactionsRequestParams = { - senderAddress: txn4.senderAddress, - pageSize: '10', - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - }; - apiParams.requestParams = requestObject; - - const result = await getTransactions(apiParams); - const mergeTxn = result.find( - (e) => - num.toBigInt(e.txnHash) === - num.toBigInt(unsettedTransactionInMassagedTxn.txnHash), - ); - expect(getTransactionStatusStub.callCount).to.be.eq(4); - expect(walletStub.rpcStubs.snap_manageState).to.have.been.called; - expect(mergeTxn).not.to.be.undefined; - if (mergeTxn !== undefined) { - expect(mergeTxn.status).to.be.eq(''); - expect(mergeTxn.finalityStatus).to.be.eq(getTxnStatusResp.finalityStatus); - expect(mergeTxn.executionStatus).to.be.eq( - getTxnStatusResp.executionStatus, - ); - } - expect(result.length).to.be.eq(4); - expect(result).to.be.eql(expectedMassagedTxns); - }); - - it('should get the transactions of testnet stored in snap state correctly', async function () { - const requestObject: GetTransactionsRequestParams = { - senderAddress: txn4.senderAddress, - pageSize: '10', - onlyFromState: true, - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - }; - apiParams.requestParams = requestObject; - const result = await getTransactions(apiParams); - expect(walletStub.rpcStubs.snap_manageState).to.have.been.called; - expect(result.length).to.be.eq(2); - expect(result).to.be.eql([expectedMassagedTxn5, expectedMassagedTxn4]); - }); - - it('should get the transactions with deploy txn from Voyager of testnet correctly', async function () { - const requestObject: GetTransactionsRequestParams = { - senderAddress: txn4.senderAddress, - pageSize: '10', - withDeployTxn: true, - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - }; - apiParams.requestParams = requestObject; - const result = await getTransactions(apiParams); - expect(walletStub.rpcStubs.snap_manageState).to.have.been.called; - expect(result.length).to.be.eq(4); - expect(result).to.be.eql(expectedMassagedTxns); - }); - - it('should throw error if upsertTransactions failed', async function () { - sandbox.stub(snapUtils, 'upsertTransactions').throws(new Error()); - const requestObject: GetTransactionsRequestParams = { - senderAddress: txn4.senderAddress, - pageSize: '10', - withDeployTxn: true, - chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, - }; - apiParams.requestParams = requestObject; - - let result; - try { - await getTransactions(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw an error if the sender address is an invalid address', async function () { - const requestObject: GetTransactionsRequestParams = { - senderAddress: 'wrongAddress', - pageSize: '10', - withDeployTxn: true, - }; - apiParams.requestParams = requestObject; - let result; - try { - result = await getTransactions(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); - - it('should throw an error if the contract address is an invalid address', async function () { - const requestObject: GetTransactionsRequestParams = { - senderAddress: txn4.senderAddress, - pageSize: '10', - withDeployTxn: true, - contractAddress: 'wrongAddress', - }; - apiParams.requestParams = requestObject; - let result; - try { - result = await getTransactions(apiParams); - } catch (err) { - result = err; - } finally { - expect(result).to.be.an('Error'); - } - }); -}); - -describe('Test function: getTransactions.updateStatus', function () { - let getTransactionStatusStub: sinon.SinonStub; - let txns: Transaction[] = []; - beforeEach(function () { - txns = [{ ...unsettedTransactionInMassagedTxn }]; - getTransactionStatusStub = sandbox - .stub(utils, 'getTransactionStatus') - .callsFake(async () => { - return getTxnStatusAcceptL2Resp; - }); - }); - - afterEach(function () { - sandbox.restore(); - }); - - it('should update status correctly', async function () { - await updateStatus(txns[0], STARKNET_SEPOLIA_TESTNET_NETWORK); - expect(getTransactionStatusStub.callCount).to.be.eq(1); - expect(txns[0].finalityStatus).to.be.eq( - getTxnStatusAcceptL2Resp.finalityStatus, - ); - expect(txns[0].executionStatus).to.be.eq( - getTxnStatusAcceptL2Resp.executionStatus, - ); - expect(txns[0].status).to.be.eq(''); - }); - - describe('when getTransactionStatus throw error', function () { - beforeEach(function () { - sandbox.restore(); - getTransactionStatusStub = sandbox - .stub(utils, 'getTransactionStatus') - .throws(new Error()); - }); - it('should not throw error', async function () { - await updateStatus(txns[0], STARKNET_SEPOLIA_TESTNET_NETWORK); - expect(txns[0].finalityStatus).to.be.eq( - unsettedTransactionInMassagedTxn.finalityStatus, - ); - expect(txns[0].executionStatus).to.be.eq( - unsettedTransactionInMassagedTxn.executionStatus, - ); - expect(txns[0].status).to.be.eq(unsettedTransactionInMassagedTxn.status); - }); - }); -}); From 1e800700e1713a94da6ab0e3d074e0c5f16a0d6c Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Tue, 3 Dec 2024 20:20:50 +0800 Subject: [PATCH 2/6] feat: add permission boundary (#448) * feat: add permission boundary * chore: add mock for index test --- packages/starknet-snap/src/index.test.tsx | 5 + packages/starknet-snap/src/index.tsx | 75 ++++++------- .../src/utils/permission.test.ts | 59 ++++++++++ .../starknet-snap/src/utils/permission.ts | 104 ++++++++++++++++++ 4 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 packages/starknet-snap/src/utils/permission.test.ts create mode 100644 packages/starknet-snap/src/utils/permission.ts diff --git a/packages/starknet-snap/src/index.test.tsx b/packages/starknet-snap/src/index.test.tsx index 2426ddf5..da3181cb 100644 --- a/packages/starknet-snap/src/index.test.tsx +++ b/packages/starknet-snap/src/index.test.tsx @@ -4,6 +4,7 @@ import { onHomePage, onRpcRequest } from '.'; import * as createAccountApi from './createAccount'; import { HomePageController } from './on-home-page'; import * as keyPairUtils from './utils/keyPair'; +import * as permissionUtil from './utils/permission'; jest.mock('./utils/logger'); @@ -41,7 +42,11 @@ describe('onRpcRequest', () => { expect(createAccountSpy).toHaveBeenCalledTimes(1); }); + // It is a never case, as the permission of each method is checked in the `validateOrigin` function. + // But to increase the coverage, we keep this test case. it('throws `MethodNotFoundError` if the request method not found', async () => { + jest.spyOn(permissionUtil, 'validateOrigin').mockReturnThis(); + await expect( onRpcRequest({ ...createMockRequest(), diff --git a/packages/starknet-snap/src/index.tsx b/packages/starknet-snap/src/index.tsx index d43e696a..0b46b166 100644 --- a/packages/starknet-snap/src/index.tsx +++ b/packages/starknet-snap/src/index.tsx @@ -10,17 +10,14 @@ import type { import { MethodNotFoundError } from '@metamask/snaps-sdk'; import { Box, Link, Text } from '@metamask/snaps-sdk/jsx'; -import { addNetwork } from './addNetwork'; import { Config } from './config'; import { createAccount } from './createAccount'; -import { estimateAccDeployFee } from './estimateAccountDeployFee'; import { extractPublicKey } from './extractPublicKey'; import { getCurrentNetwork } from './getCurrentNetwork'; import { getErc20TokenBalance } from './getErc20TokenBalance'; import { getStarkName } from './getStarkName'; import { getStoredErc20Tokens } from './getStoredErc20Tokens'; import { getStoredNetworks } from './getStoredNetworks'; -import { getStoredTransactions } from './getStoredTransactions'; import { getStoredUserAccounts } from './getStoredUserAccounts'; import { getTransactions } from './getTransactions'; import { getValue } from './getValue'; @@ -81,6 +78,7 @@ import { UnknownError } from './utils/exceptions'; import { getAddressKeyDeriver } from './utils/keyPair'; import { acquireLock } from './utils/lock'; import { logger } from './utils/logger'; +import { RpcMethod, validateOrigin } from './utils/permission'; import { toJson } from './utils/serializer'; import { upsertErc20Token, @@ -91,12 +89,17 @@ import { declare const snap; logger.logLevel = parseInt(Config.logLevel, 10); -export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { +export const onRpcRequest: OnRpcRequestHandler = async ({ + origin, + request, +}) => { const requestParams = request?.params as unknown as ApiRequestParams; logger.log(`${request.method}:\nrequestParams: ${toJson(requestParams)}`); try { + validateOrigin(origin, request.method); + if (request.method === 'ping') { logger.log('pong'); return 'pong'; @@ -142,13 +145,13 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { }; switch (request.method) { - case 'starkNet_createAccount': + case RpcMethod.CreateAccount: apiParams.keyDeriver = await getAddressKeyDeriver(snap); return await createAccount( apiParams as unknown as ApiParamsWithKeyDeriver, ); - case 'starkNet_createAccountLegacy': + case RpcMethod.DeployCario0Account: apiParams.keyDeriver = await getAddressKeyDeriver(snap); return await createAccount( apiParams as unknown as ApiParamsWithKeyDeriver, @@ -157,123 +160,111 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { CAIRO_VERSION_LEGACY, ); - case 'starkNet_getStoredUserAccounts': + case RpcMethod.ListAccounts: return await getStoredUserAccounts(apiParams); - case 'starkNet_displayPrivateKey': + case RpcMethod.DisplayPrivateKey: return await displayPrivateKey.execute( apiParams.requestParams as unknown as DisplayPrivateKeyParams, ); - case 'starkNet_extractPublicKey': + case RpcMethod.ExtractPublicKey: apiParams.keyDeriver = await getAddressKeyDeriver(snap); return await extractPublicKey( apiParams as unknown as ApiParamsWithKeyDeriver, ); - case 'starkNet_signMessage': + case RpcMethod.SignMessage: return await signMessage.execute( apiParams.requestParams as unknown as SignMessageParams, ); - case 'starkNet_signTransaction': + case RpcMethod.SignTransaction: apiParams.keyDeriver = await getAddressKeyDeriver(snap); return await signTransaction.execute( apiParams.requestParams as unknown as SignTransactionParams, ); - case 'starkNet_signDeclareTransaction': + case RpcMethod.SignDeclareTransaction: return await signDeclareTransaction.execute( apiParams.requestParams as unknown as SignDeclareTransactionParams, ); - case 'starkNet_signDeployAccountTransaction': + case RpcMethod.SignDeployAccountTransaction: apiParams.keyDeriver = await getAddressKeyDeriver(snap); return await signDeployAccountTransaction( apiParams as unknown as ApiParamsWithKeyDeriver, ); - case 'starkNet_verifySignedMessage': + case RpcMethod.VerifySignedMessage: return await verifySignature.execute( apiParams.requestParams as unknown as VerifySignatureParams, ); - case 'starkNet_getErc20TokenBalance': + case RpcMethod.GetErc20TokenBalance: return await getErc20TokenBalance(apiParams); - case 'starkNet_getTransactionStatus': + case RpcMethod.GetTransactionStatus: return await getTransactionStatus.execute( apiParams.requestParams as unknown as GetTransactionStatusParams, ); - case 'starkNet_getValue': + case RpcMethod.ReadContract: return await getValue(apiParams); - case 'starkNet_estimateFee': + case RpcMethod.EstimateFee: return await estimateFee.execute( apiParams.requestParams as unknown as EstimateFeeParams, ); - case 'starkNet_estimateAccountDeployFee': - apiParams.keyDeriver = await getAddressKeyDeriver(snap); - return await estimateAccDeployFee( - apiParams as unknown as ApiParamsWithKeyDeriver, - ); - - case 'starkNet_addErc20Token': + case RpcMethod.AddErc20Token: return await watchAsset.execute( apiParams.requestParams as unknown as WatchAssetParams, ); - case 'starkNet_getStoredErc20Tokens': + case RpcMethod.GetStoredErc20Tokens: return await getStoredErc20Tokens(apiParams); - case 'starkNet_addNetwork': - return await addNetwork(apiParams); - - case 'starkNet_switchNetwork': + case RpcMethod.SwitchNetwork: return await switchNetwork.execute( apiParams.requestParams as unknown as SwitchNetworkParams, ); - case 'starkNet_getCurrentNetwork': + case RpcMethod.GetCurrentNetwork: return await getCurrentNetwork(apiParams); - case 'starkNet_getStoredNetworks': + case RpcMethod.GetStoredNetworks: return await getStoredNetworks(apiParams); - case 'starkNet_getStoredTransactions': - return await getStoredTransactions(apiParams); - - case 'starkNet_getTransactions': + case RpcMethod.GetTransactions: return await getTransactions(apiParams); - case 'starkNet_recoverAccounts': + case RpcMethod.RecoverAccounts: apiParams.keyDeriver = await getAddressKeyDeriver(snap); return await recoverAccounts( apiParams as unknown as ApiParamsWithKeyDeriver, ); - case 'starkNet_executeTxn': + case RpcMethod.ExecuteTxn: return await executeTxn.execute( apiParams.requestParams as unknown as ExecuteTxnParams, ); - case 'starkNet_upgradeAccContract': + case RpcMethod.UpgradeAccContract: apiParams.keyDeriver = await getAddressKeyDeriver(snap); return upgradeAccContract( apiParams as unknown as ApiParamsWithKeyDeriver, ); - case 'starkNet_declareContract': + case RpcMethod.DeclareContract: return await declareContract.execute( apiParams.requestParams as unknown as DeclareContractParams, ); - case 'starkNet_getStarkName': + case RpcMethod.GetStarkName: return await getStarkName(apiParams); - case 'starkNet_getDeploymentData': + case RpcMethod.GetDeploymentData: return await getDeploymentData.execute( apiParams.requestParams as unknown as GetDeploymentDataParams, ); diff --git a/packages/starknet-snap/src/utils/permission.test.ts b/packages/starknet-snap/src/utils/permission.test.ts new file mode 100644 index 00000000..9d6a0f93 --- /dev/null +++ b/packages/starknet-snap/src/utils/permission.test.ts @@ -0,0 +1,59 @@ +import { originPermissions, validateOrigin, RpcMethod } from './permission'; + +describe('validateOrigin', () => { + const walletUIDappPermissions = Array.from( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + originPermissions.get('https://snaps.consensys.io')!, + ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const publicPermissions = Array.from(originPermissions.get('*')!); + const restrictedPermissions = [ + RpcMethod.DeployCario0Account, + RpcMethod.ListAccounts, + RpcMethod.GetTransactions, + RpcMethod.UpgradeAccContract, + RpcMethod.GetStarkName, + RpcMethod.ReadContract, + RpcMethod.GetStoredErc20Tokens, + ]; + + it.each(walletUIDappPermissions)( + "pass the validation with a valid Wallet UI Dapp's origin and a whitelisted method. method - %s", + (method: string) => { + expect(() => + validateOrigin('https://snaps.consensys.io', method), + ).not.toThrow(); + }, + ); + + it.each(publicPermissions)( + 'pass the validation with any origin and a whitelisted method. method - %s', + (method: string) => { + expect(() => validateOrigin('https://any.io', method)).not.toThrow(); + }, + ); + + it.each(restrictedPermissions)( + 'throw a `Permission denied` if the method is restricted for public. method - %s', + (method: string) => { + expect(() => validateOrigin('https://any.io', method)).toThrow( + 'Permission denied', + ); + }, + ); + + it('throw a `Permission denied` if the method is not exist.', () => { + expect(() => validateOrigin('https://any.io', 'method_not_exist')).toThrow( + 'Permission denied', + ); + expect(() => + validateOrigin('https://snaps.consensys.io', 'method_not_exist'), + ).toThrow('Permission denied'); + }); + + it('throw a `Origin not found` if the orgin is not given or empty.', () => { + expect(() => validateOrigin('', 'method_not_exist')).toThrow( + 'Origin not found', + ); + }); +}); diff --git a/packages/starknet-snap/src/utils/permission.ts b/packages/starknet-snap/src/utils/permission.ts new file mode 100644 index 00000000..216452ef --- /dev/null +++ b/packages/starknet-snap/src/utils/permission.ts @@ -0,0 +1,104 @@ +import { UnauthorizedError } from '@metamask/snaps-sdk'; + +export enum RpcMethod { + ExtractPublicKey = 'starkNet_extractPublicKey', + GetCurrentNetwork = 'starkNet_getCurrentNetwork', + GetStoredNetworks = 'starkNet_getStoredNetworks', + SwitchNetwork = 'starkNet_switchNetwork', + AddErc20Token = 'starkNet_addErc20Token', + RecoverAccounts = 'starkNet_recoverAccounts', + ExecuteTxn = 'starkNet_executeTxn', + DeclareContract = 'starkNet_declareContract', + GetDeploymentData = 'starkNet_getDeploymentData', + SignMessage = 'starkNet_signMessage', + SignTransaction = 'starkNet_signTransaction', + SignDeclareTransaction = 'starkNet_signDeclareTransaction', + SignDeployAccountTransaction = 'starkNet_signDeployAccountTransaction', + + CreateAccount = 'starkNet_createAccount', + DisplayPrivateKey = 'starkNet_displayPrivateKey', + GetErc20TokenBalance = 'starkNet_getErc20TokenBalance', + GetTransactionStatus = 'starkNet_getTransactionStatus', + EstimateFee = 'starkNet_estimateFee', + VerifySignedMessage = 'starkNet_verifySignedMessage', + DeployCario0Account = 'starkNet_createAccountLegacy', + ListAccounts = 'starkNet_getStoredUserAccounts', + GetTransactions = 'starkNet_getTransactions', + UpgradeAccContract = 'starkNet_upgradeAccContract', + GetStarkName = 'starkNet_getStarkName', + ReadContract = 'starkNet_getValue', + GetStoredErc20Tokens = 'starkNet_getStoredErc20Tokens', +} +// RpcMethod that are allowed to be called by any origin +const publicPermissions = [ + RpcMethod.ExtractPublicKey, + RpcMethod.GetCurrentNetwork, + RpcMethod.GetStoredNetworks, + RpcMethod.SwitchNetwork, + RpcMethod.AddErc20Token, + RpcMethod.RecoverAccounts, + RpcMethod.ExecuteTxn, + RpcMethod.DeclareContract, + RpcMethod.GetDeploymentData, + RpcMethod.SignMessage, + RpcMethod.SignTransaction, + RpcMethod.SignDeclareTransaction, + RpcMethod.SignDeployAccountTransaction, + RpcMethod.CreateAccount, + RpcMethod.DisplayPrivateKey, + RpcMethod.GetErc20TokenBalance, + RpcMethod.GetTransactionStatus, + RpcMethod.EstimateFee, + RpcMethod.VerifySignedMessage, +]; +// RpcMethod that are restricted to be called by wallet UI origins +const walletUIDappPermissions = publicPermissions.concat([ + RpcMethod.DeployCario0Account, + RpcMethod.ListAccounts, + RpcMethod.GetTransactions, + RpcMethod.UpgradeAccContract, + RpcMethod.GetStarkName, + RpcMethod.ReadContract, + RpcMethod.GetStoredErc20Tokens, +]); + +const publicPermissionsSet = new Set(publicPermissions); +const walletUIDappPermissionsSet = new Set(walletUIDappPermissions); + +const walletUIDappOrigins = [ + 'http://localhost:3000', + 'https://snaps.consensys.io', + 'https://dev.snaps.consensys.io', + 'https://staging.snaps.consensys.io', +]; + +export const originPermissions = new Map>([]); +for (const origin of walletUIDappOrigins) { + originPermissions.set(origin, walletUIDappPermissionsSet); +} +originPermissions.set('*', publicPermissionsSet); + +/** + * Validate the origin and method pair. + * If the origin is not found or the method is not allowed, throw an error. + * + * @param origin - The origin of the request. + * @param method - The method of the request. + * @throws {UnauthorizedError} If the origin is not found or the method is not allowed. + */ +export function validateOrigin(origin: string, method: string): void { + if (!origin) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw new UnauthorizedError('Origin not found'); + } + // As public permissions are a subset of wallet UI Dapp permissions, + // If the origin and method pair are not in the wallet UI Dapp permissions, + // then fallback and validate whether it hits the common permission. + if ( + !originPermissions.get(origin)?.has(method) && + !originPermissions.get('*')?.has(method) + ) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw new UnauthorizedError(`Permission denied`); + } +} From c9e2c64572ce22bb111e6075fa6191dc025bc863 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Wed, 4 Dec 2024 21:37:30 +0800 Subject: [PATCH 3/6] feat: allow users to transfer money to a .stark name (#437) (#449) * feat: transfer to a stark name * fix: lint errors * test: update getAddrFromStarkName network * fix: update to new RPC structure * chore: lint * chore: lint * fix: racing condition in SendModal * fix: move validate logic out of addressInput & update requests made to snap * fix: simplify name check in frontend * fix: resolved addr is validated by superstruct * chore: add snap permission boundary and update UI text * chore: update RpcController import location --------- Co-authored-by: Iris --- .../openrpc/starknet_snap_api_openrpc.json | 34 ++++++++ packages/starknet-snap/src/index.tsx | 7 ++ .../src/rpcs/get-addr-from-starkname.test.ts | 58 +++++++++++++ .../src/rpcs/get-addr-from-starkname.ts | 87 +++++++++++++++++++ packages/starknet-snap/src/rpcs/index.ts | 1 + .../src/utils/permission.test.ts | 1 + .../starknet-snap/src/utils/permission.ts | 1 + .../src/utils/starknetUtils.test.ts | 34 ++++++++ .../starknet-snap/src/utils/starknetUtils.ts | 14 +++ .../starknet-snap/src/utils/superstruct.ts | 12 +++ .../AddressInput/AddressInput.style.ts | 8 ++ .../AddressInput/AddressInput.view.tsx | 19 +++- .../Header/SendModal/SendModal.view.tsx | 51 +++++++++-- .../wallet-ui/src/services/useStarkNetSnap.ts | 22 +++++ packages/wallet-ui/src/utils/utils.ts | 6 ++ 15 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 packages/starknet-snap/src/rpcs/get-addr-from-starkname.test.ts create mode 100644 packages/starknet-snap/src/rpcs/get-addr-from-starkname.ts diff --git a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json index 72c853b6..a6e77596 100644 --- a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json +++ b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json @@ -1244,6 +1244,40 @@ } }, "errors": [] + }, + { + "name": "starkNet_getAddrFromStarkName", + "summary": "Get address from a stark name", + "paramStructure": "by-name", + "params": [ + { + "name": "starkName", + "summary": "stark name of the user", + "description": "stark name of the user", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "chainId", + "summary": "Id of the target Starknet network", + "description": "Id of the target Starknet network (default to Starknet Goerli Testnet)", + "required": false, + "schema": { + "$ref": "#/components/schemas/CHAIN_ID" + } + } + ], + "result": { + "name": "result", + "summary": "Address of the given stark name", + "description": "Address of the given stark name", + "schema": { + "$ref": "#/components/schemas/ADDRESS" + } + }, + "errors": [] } ], "components": { diff --git a/packages/starknet-snap/src/index.tsx b/packages/starknet-snap/src/index.tsx index 0b46b166..59c40a70 100644 --- a/packages/starknet-snap/src/index.tsx +++ b/packages/starknet-snap/src/index.tsx @@ -35,6 +35,7 @@ import type { GetDeploymentDataParams, DeclareContractParams, WatchAssetParams, + GetAddrFromStarkNameParams, GetTransactionStatusParams, } from './rpcs'; import { @@ -49,6 +50,7 @@ import { switchNetwork, getDeploymentData, watchAsset, + getAddrFromStarkName, getTransactionStatus, } from './rpcs'; import { signDeployAccountTransaction } from './signDeployAccountTransaction'; @@ -269,6 +271,11 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ apiParams.requestParams as unknown as GetDeploymentDataParams, ); + case RpcMethod.GetAddressByStarkName: + return await getAddrFromStarkName.execute( + apiParams.requestParams as unknown as GetAddrFromStarkNameParams, + ); + default: throw new MethodNotFoundError() as unknown as Error; } diff --git a/packages/starknet-snap/src/rpcs/get-addr-from-starkname.test.ts b/packages/starknet-snap/src/rpcs/get-addr-from-starkname.test.ts new file mode 100644 index 00000000..01973640 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/get-addr-from-starkname.test.ts @@ -0,0 +1,58 @@ +import { constants } from 'starknet'; + +import { InvalidRequestParamsError } from '../utils/exceptions'; +import * as starknetUtils from '../utils/starknetUtils'; +import { + getAddrFromStarkName, + type GetAddrFromStarkNameParams, +} from './get-addr-from-starkname'; + +jest.mock('../utils/snap'); +jest.mock('../utils/logger'); + +const prepareMockGetAddrFromStarkName = ({ + chainId, + starkName, +}: { + chainId: constants.StarknetChainId; + starkName: string; +}) => { + const request = { + chainId, + starkName, + } as unknown as GetAddrFromStarkNameParams; + + const getAddrFromStarkNameSpy = jest.spyOn( + starknetUtils, + 'getAddrFromStarkNameUtil', + ); + getAddrFromStarkNameSpy.mockResolvedValue( + '0x01c744953f1d671673f46a9179a58a7e58d9299499b1e076cdb908e7abffe69f', + ); + + return { + request, + }; +}; + +describe('getAddrFromStarkName', () => { + it('get address from stark name correctly', async () => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + const { request } = prepareMockGetAddrFromStarkName({ + chainId, + starkName: 'testname.stark', + }); + + const result = await getAddrFromStarkName.execute(request); + + expect(result).toBe( + '0x01c744953f1d671673f46a9179a58a7e58d9299499b1e076cdb908e7abffe69f', + ); + }); + + it('throws `InvalidRequestParamsError` when request parameter is not correct', async () => { + await expect( + getAddrFromStarkName.execute({} as unknown as GetAddrFromStarkNameParams), + ).rejects.toThrow(InvalidRequestParamsError); + }); +}); diff --git a/packages/starknet-snap/src/rpcs/get-addr-from-starkname.ts b/packages/starknet-snap/src/rpcs/get-addr-from-starkname.ts new file mode 100644 index 00000000..cab1d5a1 --- /dev/null +++ b/packages/starknet-snap/src/rpcs/get-addr-from-starkname.ts @@ -0,0 +1,87 @@ +import type { Infer } from 'superstruct'; +import { assign, object } from 'superstruct'; + +import { NetworkStateManager } from '../state/network-state-manager'; +import type { Network } from '../types/snapState'; +import { AddressStruct, BaseRequestStruct, StarkNameStruct } from '../utils'; +import { InvalidNetworkError } from '../utils/exceptions'; +import { getAddrFromStarkNameUtil } from '../utils/starknetUtils'; +import { RpcController } from './abstract/base-rpc-controller'; + +export const GetAddrFromStarkNameRequestStruct = assign( + object({ + starkName: StarkNameStruct, + }), + BaseRequestStruct, +); + +export const GetAddrFromStarkNameResponseStruct = AddressStruct; + +export type GetAddrFromStarkNameParams = Infer< + typeof GetAddrFromStarkNameRequestStruct +>; + +export type GetAddrFromStarkNameResponse = Infer< + typeof GetAddrFromStarkNameResponseStruct +>; + +/** + * The RPC handler to get a StarkName by a Starknet address. + */ +export class GetAddrFromStarkNameRpc extends RpcController< + GetAddrFromStarkNameParams, + GetAddrFromStarkNameResponse +> { + protected requestStruct = GetAddrFromStarkNameRequestStruct; + + protected responseStruct = GetAddrFromStarkNameResponseStruct; + + protected readonly networkStateMgr: NetworkStateManager; + + constructor() { + super(); + this.networkStateMgr = new NetworkStateManager(); + } + + /** + * Execute the get address from stark name request handler. + * + * @param params - The parameters of the request. + * @param params.starkName - The stark name of the user. + * @param params.chainId - The chain id of the network. + * @returns A promise that resolves to an address. + * @throws {Error} If the network with the chain id is not supported. + */ + async execute( + params: GetAddrFromStarkNameParams, + ): Promise { + return super.execute(params); + } + + protected async getNetworkFromChainId(chainId: string): Promise { + const network = await this.networkStateMgr.getNetwork({ + chainId, + }); + + // It should be never happen, as the chainId should be validated by the superstruct + if (!network) { + throw new InvalidNetworkError() as unknown as Error; + } + + return network; + } + + protected async handleRequest( + params: GetAddrFromStarkNameParams, + ): Promise { + const { chainId, starkName } = params; + + const network = await this.getNetworkFromChainId(chainId); + + const address = await getAddrFromStarkNameUtil(network, starkName); + + return address; + } +} + +export const getAddrFromStarkName = new GetAddrFromStarkNameRpc(); diff --git a/packages/starknet-snap/src/rpcs/index.ts b/packages/starknet-snap/src/rpcs/index.ts index 609edb5d..4e08d3f7 100644 --- a/packages/starknet-snap/src/rpcs/index.ts +++ b/packages/starknet-snap/src/rpcs/index.ts @@ -9,4 +9,5 @@ export * from './verify-signature'; export * from './switch-network'; export * from './get-deployment-data'; export * from './watch-asset'; +export * from './get-addr-from-starkname'; export * from './get-transaction-status'; diff --git a/packages/starknet-snap/src/utils/permission.test.ts b/packages/starknet-snap/src/utils/permission.test.ts index 9d6a0f93..69931b28 100644 --- a/packages/starknet-snap/src/utils/permission.test.ts +++ b/packages/starknet-snap/src/utils/permission.test.ts @@ -13,6 +13,7 @@ describe('validateOrigin', () => { RpcMethod.GetTransactions, RpcMethod.UpgradeAccContract, RpcMethod.GetStarkName, + RpcMethod.GetAddressByStarkName, RpcMethod.ReadContract, RpcMethod.GetStoredErc20Tokens, ]; diff --git a/packages/starknet-snap/src/utils/permission.ts b/packages/starknet-snap/src/utils/permission.ts index 216452ef..0a264b1c 100644 --- a/packages/starknet-snap/src/utils/permission.ts +++ b/packages/starknet-snap/src/utils/permission.ts @@ -26,6 +26,7 @@ export enum RpcMethod { GetTransactions = 'starkNet_getTransactions', UpgradeAccContract = 'starkNet_upgradeAccContract', GetStarkName = 'starkNet_getStarkName', + GetAddressByStarkName = 'starkNet_getAddrFromStarkName', ReadContract = 'starkNet_getValue', GetStoredErc20Tokens = 'starkNet_getStoredErc20Tokens', } diff --git a/packages/starknet-snap/src/utils/starknetUtils.test.ts b/packages/starknet-snap/src/utils/starknetUtils.test.ts index 307729d3..2bfc56e7 100644 --- a/packages/starknet-snap/src/utils/starknetUtils.test.ts +++ b/packages/starknet-snap/src/utils/starknetUtils.test.ts @@ -170,3 +170,37 @@ describe('getEstimatedFees', () => { }); }); }); + +describe('isValidStarkName', () => { + it.each([ + { starkName: 'valid.stark', expected: true }, + { starkName: 'valid-name.stark', expected: true }, + { starkName: 'valid123.stark', expected: true }, + { starkName: 'valid-name123.stark', expected: true }, + { starkName: 'valid.subdomain.stark', expected: true }, + { starkName: '1-valid.stark', expected: true }, + { + starkName: 'valid-name-with-many-subdomains.valid.subdomain.stark', + expected: true, + }, + { + starkName: 'too-long-stark-domain-name-more-than-48-characters.stark', + expected: false, + }, + { starkName: 'invalid..stark', expected: false }, + { starkName: 'invalid@stark', expected: false }, + { starkName: 'invalid_name.stark', expected: false }, + { starkName: 'invalid space.stark', expected: false }, + { starkName: 'invalid.starknet', expected: false }, + { starkName: '.invalid.stark', expected: false }, + { starkName: 'invalid.', expected: false }, + { starkName: 'invalid.stark.', expected: false }, + { starkName: '', expected: false }, + ])( + 'validates `$starkName` correctly and returns $expected', + ({ starkName, expected }) => { + const result = starknetUtils.isValidStarkName(starkName); + expect(result).toBe(expected); + }, + ); +}); diff --git a/packages/starknet-snap/src/utils/starknetUtils.ts b/packages/starknet-snap/src/utils/starknetUtils.ts index 7c610528..835623ee 100644 --- a/packages/starknet-snap/src/utils/starknetUtils.ts +++ b/packages/starknet-snap/src/utils/starknetUtils.ts @@ -1367,3 +1367,17 @@ export const validateAccountRequireUpgradeOrDeploy = async ( throw new DeployRequiredError(); } }; + +export const getAddrFromStarkNameUtil = async ( + network: Network, + starkName: string, +) => { + const provider = getProvider(network); + return Account.getAddressFromStarkName(provider, starkName); +}; + +export const isValidStarkName = (starkName: string): boolean => { + return /^(?:[a-z0-9-]{1,48}(?:[a-z0-9-]{1,48}[a-z0-9-])?\.)*[a-z0-9-]{1,48}\.stark$/.test( + starkName, + ); +}; diff --git a/packages/starknet-snap/src/utils/superstruct.ts b/packages/starknet-snap/src/utils/superstruct.ts index 505d7f94..b431e5c0 100644 --- a/packages/starknet-snap/src/utils/superstruct.ts +++ b/packages/starknet-snap/src/utils/superstruct.ts @@ -33,6 +33,7 @@ import { MAXIMUM_TOKEN_NAME_LENGTH, MAXIMUM_TOKEN_SYMBOL_LENGTH, } from './constants'; +import { isValidStarkName } from './starknetUtils'; import { isValidAsciiStrField } from './string'; export const TokenNameStruct = refine( @@ -188,6 +189,17 @@ export const DeclareSignDetailsStruct = assign( }), ); +export const StarkNameStruct = refine( + string(), + 'StarkNameStruct', + (value: string) => { + if (isValidStarkName(value)) { + return true; + } + return `The given stark name is invalid`; + }, +); + /* ------------------------------ Contract Struct ------------------------------ */ /* eslint-disable */ export const SierraContractEntryPointFieldsStruct = object({ diff --git a/packages/wallet-ui/src/components/ui/molecule/AddressInput/AddressInput.style.ts b/packages/wallet-ui/src/components/ui/molecule/AddressInput/AddressInput.style.ts index 83214d40..b7c40d34 100644 --- a/packages/wallet-ui/src/components/ui/molecule/AddressInput/AddressInput.style.ts +++ b/packages/wallet-ui/src/components/ui/molecule/AddressInput/AddressInput.style.ts @@ -107,3 +107,11 @@ export const Icon = styled(FontAwesomeIcon).attrs((props) => ({ ? props.theme.palette.error.main : props.theme.palette.success.main, }))``; + +export const InfoText = styled.div` + font-size: ${(props) => props.theme.typography.p2.fontSize}; + font-family: ${(props) => props.theme.typography.p2.fontFamily}; + color: ${(props) => props.theme.palette.grey.black}; + padding-top: ${(props) => props.theme.spacing.tiny}; + padding-left: ${(props) => props.theme.spacing.small}; +`; diff --git a/packages/wallet-ui/src/components/ui/molecule/AddressInput/AddressInput.view.tsx b/packages/wallet-ui/src/components/ui/molecule/AddressInput/AddressInput.view.tsx index 74cf5ca8..9ddde03c 100644 --- a/packages/wallet-ui/src/components/ui/molecule/AddressInput/AddressInput.view.tsx +++ b/packages/wallet-ui/src/components/ui/molecule/AddressInput/AddressInput.view.tsx @@ -1,4 +1,4 @@ -import { KeyboardEvent, ChangeEvent } from 'react'; +import { KeyboardEvent, ChangeEvent, useEffect } from 'react'; import { InputHTMLAttributes, useRef, @@ -22,6 +22,8 @@ import { STARKNET_ADDRESS_LENGTH } from 'utils/constants'; interface Props extends InputHTMLAttributes { label?: string; setIsValidAddress?: Dispatch>; + disableValidate?: boolean; + validateError?: string; } export const AddressInputView = ({ @@ -29,6 +31,8 @@ export const AddressInputView = ({ onChange, label, setIsValidAddress, + disableValidate, + validateError, ...otherProps }: Props) => { const [focused, setFocused] = useState(false); @@ -36,6 +40,12 @@ export const AddressInputView = ({ const [error, setError] = useState(''); const [valid, setValid] = useState(false); + useEffect(() => { + if (!disableValidate || !inputRef.current) return; + setValid(inputRef.current.value !== '' && validateError === ''); + setError(validateError ?? ''); + }, [disableValidate, validateError]); + const displayIcon = () => { return valid || error !== ''; }; @@ -54,9 +64,10 @@ export const AddressInputView = ({ //Check if valid address onChange && onChange(event); - if (!inputRef.current) { - return; - } + if (!inputRef.current) return; + + if (disableValidate) return; + const isValid = inputRef.current.value !== '' && isValidAddress(inputRef.current.value); if (isValid) { diff --git a/packages/wallet-ui/src/components/ui/organism/Header/SendModal/SendModal.view.tsx b/packages/wallet-ui/src/components/ui/organism/Header/SendModal/SendModal.view.tsx index 0acc05ca..83bd1fe0 100644 --- a/packages/wallet-ui/src/components/ui/organism/Header/SendModal/SendModal.view.tsx +++ b/packages/wallet-ui/src/components/ui/organism/Header/SendModal/SendModal.view.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { AmountInput } from 'components/ui/molecule/AmountInput'; import { SendSummaryModal } from '../SendSummaryModal'; import { @@ -15,11 +15,13 @@ import { import { useAppSelector } from 'hooks/redux'; import { ethers } from 'ethers'; import { AddressInput } from 'components/ui/molecule/AddressInput'; -import { isValidAddress } from 'utils/utils'; +import { isValidAddress, isValidStarkName, shortenAddress } from 'utils/utils'; import { Bold, Normal } from '../../ConnectInfoModal/ConnectInfoModal.style'; import { DropDown } from 'components/ui/molecule/DropDown'; import { DEFAULT_FEE_TOKEN } from 'utils/constants'; import { FeeToken } from 'types'; +import { useStarkNetSnap } from 'services'; +import { InfoText } from 'components/ui/molecule/AddressInput/AddressInput.style'; interface Props { closeModal?: () => void; @@ -27,7 +29,9 @@ interface Props { export const SendModalView = ({ closeModal }: Props) => { const networks = useAppSelector((state) => state.networks); + const chainId = networks?.items[networks.activeNetwork]?.chainId; const wallet = useAppSelector((state) => state.wallet); + const { getAddrFromStarkName } = useStarkNetSnap(); const [summaryModalOpen, setSummaryModalOpen] = useState(false); const [fields, setFields] = useState({ amount: '', @@ -39,6 +43,9 @@ export const SendModalView = ({ closeModal }: Props) => { feeToken: DEFAULT_FEE_TOKEN, // Default fee token }); const [errors, setErrors] = useState({ amount: '', address: '' }); + const [resolvedAddress, setResolvedAddress] = useState(''); + const debounceRef = useRef(null); + const [loading, setLoading] = useState(false); const handleChange = (fieldName: string, fieldValue: string) => { //Check if input amount does not exceed user balance @@ -64,7 +71,31 @@ export const SendModalView = ({ closeModal }: Props) => { break; case 'address': if (fieldValue !== '') { - if (!isValidAddress(fieldValue)) { + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (isValidAddress(fieldValue)) { + setResolvedAddress(fieldValue); + break; + } else if (isValidStarkName(fieldValue)) { + debounceRef.current = setTimeout(() => { + setLoading(true); + getAddrFromStarkName(fieldValue, chainId) + .then((address) => { + setResolvedAddress(address); + }) + .catch(() => { + setResolvedAddress(''); + setErrors((prevErrors) => ({ + ...prevErrors, + address: '.stark name doesn’t exist', + })); + }) + .finally(() => { + setLoading(false); + }); + }, 300); + } else { + setResolvedAddress(''); setErrors((prevErrors) => ({ ...prevErrors, address: 'Invalid address format', @@ -90,7 +121,8 @@ export const SendModalView = ({ closeModal }: Props) => { !errors.address && !errors.amount && fields.amount.length > 0 && - fields.address.length > 0 + fields.address.length > 0 && + !loading ); }; @@ -108,13 +140,18 @@ export const SendModalView = ({ closeModal }: Props) => { handleChange('address', value.target.value)} + disableValidate + validateError={errors.address} /> + {isValidStarkName(fields.address) && resolvedAddress && ( + {shortenAddress(resolvedAddress, 12)} + )} { {summaryModalOpen && ( { } }; + const getAddrFromStarkName = async (starkName: string, chainId: string) => { + try { + return await provider.request({ + method: 'wallet_invokeSnap', + params: { + snapId, + request: { + method: 'starkNet_getAddrFromStarkName', + params: { + ...defaultParam, + starkName, + chainId, + }, + }, + }, + }); + } catch (err) { + throw err; + } + }; + return { connectToSnap, getNetworks, @@ -972,6 +993,7 @@ export const useStarkNetSnap = () => { switchNetwork, getCurrentNetwork, getStarkName, + getAddrFromStarkName, satisfiesVersion: oldVersionDetected, }; }; diff --git a/packages/wallet-ui/src/utils/utils.ts b/packages/wallet-ui/src/utils/utils.ts index b4767e43..8b8de05b 100644 --- a/packages/wallet-ui/src/utils/utils.ts +++ b/packages/wallet-ui/src/utils/utils.ts @@ -242,3 +242,9 @@ export function getTokenBalanceWithDetails( const { balance } = tokenBalance; return addMissingPropertiesToToken(token, balance.toString(), tokenUSDPrice); } + +export const isValidStarkName = (starkName: string): boolean => { + return /^(?:[a-z0-9-]{1,48}(?:[a-z0-9-]{1,48}[a-z0-9-])?\.)*[a-z0-9-]{1,48}\.stark$/.test( + starkName, + ); +}; From cfdc79da6a0dda518b2dd6dd3c699de254d8e7f1 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:25:36 +0800 Subject: [PATCH 4/6] feat: add `StarkScan` client to fetch transaction history (#341) * 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 starkscan to handle missing selector_name --------- Co-authored-by: khanti42 --- packages/starknet-snap/.env.example | 3 + packages/starknet-snap/snap.config.ts | 1 + .../__tests__/fixture/stark-scan-example.json | 163 ++++++ .../starknet-snap/src/__tests__/helper.ts | 64 +++ .../starknet-snap/src/chain/api-client.ts | 130 +++++ .../starknet-snap/src/chain/data-client.ts | 6 + .../src/chain/data-client/starkscan.test.ts | 483 ++++++++++++++++++ .../src/chain/data-client/starkscan.ts | 300 +++++++++++ .../src/chain/data-client/starkscan.type.ts | 55 ++ packages/starknet-snap/src/config.ts | 16 + packages/starknet-snap/src/types/snapState.ts | 50 +- .../starknet-snap/src/utils/factory.test.ts | 22 + packages/starknet-snap/src/utils/factory.ts | 25 + 13 files changed, 1314 insertions(+), 4 deletions(-) create mode 100644 packages/starknet-snap/src/__tests__/fixture/stark-scan-example.json create mode 100644 packages/starknet-snap/src/chain/api-client.ts create mode 100644 packages/starknet-snap/src/chain/data-client.ts create mode 100644 packages/starknet-snap/src/chain/data-client/starkscan.test.ts create mode 100644 packages/starknet-snap/src/chain/data-client/starkscan.ts create mode 100644 packages/starknet-snap/src/chain/data-client/starkscan.type.ts create mode 100644 packages/starknet-snap/src/utils/factory.test.ts create mode 100644 packages/starknet-snap/src/utils/factory.ts diff --git a/packages/starknet-snap/.env.example b/packages/starknet-snap/.env.example index c5b657e0..b2098771 100644 --- a/packages/starknet-snap/.env.example +++ b/packages/starknet-snap/.env.example @@ -6,6 +6,9 @@ SNAP_ENV=dev # Description: Environment variables for API key of VOYAGER # Required: false VOYAGER_API_KEY= +# Description: Environment variables for API key of STARKSCAN +# Required: false +STARKSCAN_API_KEY= # Description: Environment variables for API key of ALCHEMY # Required: false ALCHEMY_API_KEY= diff --git a/packages/starknet-snap/snap.config.ts b/packages/starknet-snap/snap.config.ts index 7d8bb771..182cb2fb 100644 --- a/packages/starknet-snap/snap.config.ts +++ b/packages/starknet-snap/snap.config.ts @@ -15,6 +15,7 @@ const config: SnapConfig = { SNAP_ENV: process.env.SNAP_ENV ?? 'prod', VOYAGER_API_KEY: process.env.VOYAGER_API_KEY ?? '', ALCHEMY_API_KEY: process.env.ALCHEMY_API_KEY ?? '', + STARKSCAN_API_KEY: process.env.STARKSCAN_API_KEY ?? '', LOG_LEVEL: process.env.LOG_LEVEL ?? '0', /* eslint-disable */ }, diff --git a/packages/starknet-snap/src/__tests__/fixture/stark-scan-example.json b/packages/starknet-snap/src/__tests__/fixture/stark-scan-example.json new file mode 100644 index 00000000..6a4affdd --- /dev/null +++ b/packages/starknet-snap/src/__tests__/fixture/stark-scan-example.json @@ -0,0 +1,163 @@ +{ + "getTransactionsResp": { + "next_url": null, + "data": [] + }, + "invokeTx": { + "transaction_hash": "0x06d05d58dc35f432f8f5c7ae5972434a00d2e567e154fc1226c444f06e369c7d", + "block_hash": "0x02a44fd749221729c63956309d0df4ba03f4291613248d885870b55baf963e0e", + "block_number": 136140, + "transaction_index": 6, + "transaction_status": "ACCEPTED_ON_L1", + "transaction_finality_status": "ACCEPTED_ON_L1", + "transaction_execution_status": "SUCCEEDED", + "transaction_type": "INVOKE_FUNCTION", + "version": 1, + "signature": [ + "0x555fe1b8e5183be2f6c81e5203ee3928aab894ab0b31279c89a3c7f016865fc", + "0x269d0a83634905be76372d3116733afc8a8f0f29776f57d7400b05ded54c9b1" + ], + "max_fee": "95250978959328", + "actual_fee": "62936888346418", + "nonce": "9", + "contract_address": null, + "entry_point_selector": null, + "entry_point_type": null, + "calldata": [ + "0x1", + "0x49d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e", + "0x3", + "0x42ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "0x9184e72a000", + "0x0" + ], + "class_hash": null, + "sender_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "constructor_calldata": null, + "contract_address_salt": null, + "timestamp": 1724759407, + "entry_point_selector_name": "__execute__", + "number_of_events": 3, + "revert_error": null, + "account_calls": [ + { + "block_hash": "0x02a44fd749221729c63956309d0df4ba03f4291613248d885870b55baf963e0e", + "block_number": 136140, + "transaction_hash": "0x06d05d58dc35f432f8f5c7ae5972434a00d2e567e154fc1226c444f06e369c7d", + "caller_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "contract_address": "0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7", + "calldata": [ + "0x42ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "0x9184e72a000", + "0x0" + ], + "result": ["0x1"], + "timestamp": 1724759407, + "call_type": "CALL", + "class_hash": "0x07f3777c99f3700505ea966676aac4a0d692c2a9f5e667f4c606b51ca1dd3420", + "selector": "0x83afd3f4caedc6eebf44246fe54e38c95e3179a5ec9ea81740eca5b482d12e", + "entry_point_type": "EXTERNAL", + "selector_name": "transfer" + } + ] + }, + "upgradeTx": { + "transaction_hash": "0x019a7a7bbda2e52f82ffc867488cace31a04d9340ad56bbe9879aab8bc47f0b6", + "block_hash": "0x002dae3ed3cf7763621da170103384d533ed09fb987a232f23b7d8febbbca67f", + "block_number": 77586, + "transaction_index": 33, + "transaction_status": "ACCEPTED_ON_L1", + "transaction_finality_status": "ACCEPTED_ON_L1", + "transaction_execution_status": "SUCCEEDED", + "transaction_type": "INVOKE_FUNCTION", + "version": 1, + "signature": [ + "0x417671c63219250e0c80d53b1e1b3c0dd76ade552806a51fdfd8c06f7c47a12", + "0x91c7ccadec2ba22bfa5c92b62fc6eaccb56c686279f953c5012f7d6f679570" + ], + "max_fee": "191210494208472", + "actual_fee": "148188646762488", + "nonce": "4", + "contract_address": null, + "entry_point_selector": null, + "entry_point_type": null, + "calldata": [ + "0x1", + "0x42ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "0xf2f7c15cbe06c8d94597cd91fd7f3369eae842359235712def5584f8d270cd", + "0x0", + "0x3", + "0x3", + "0x29927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b", + "0x1", + "0x0" + ], + "class_hash": null, + "sender_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "constructor_calldata": null, + "contract_address_salt": null, + "timestamp": 1719830196, + "entry_point_selector_name": "__execute__", + "number_of_events": 4, + "revert_error": null, + "account_calls": [ + { + "block_hash": "0x002dae3ed3cf7763621da170103384d533ed09fb987a232f23b7d8febbbca67f", + "block_number": 77586, + "transaction_hash": "0x019a7a7bbda2e52f82ffc867488cace31a04d9340ad56bbe9879aab8bc47f0b6", + "caller_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "contract_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "calldata": [ + "0x29927c8af6bccf3f6fda035981e765a7bdbf18a2dc0d630494f8758aa908e2b", + "0x1", + "0x0" + ], + "result": ["0x1", "0x0"], + "timestamp": 1719830196, + "call_type": "CALL", + "class_hash": "0x025ec026985a3bf9d0cc1fe17326b245dfdc3ff89b8fde106542a3ea56c5a918", + "selector": "0xf2f7c15cbe06c8d94597cd91fd7f3369eae842359235712def5584f8d270cd", + "entry_point_type": "EXTERNAL", + "selector_name": "upgrade" + } + ] + }, + "cairo0DeployTx": { + "transaction_hash": "0x06210d8004e1c90723732070c191a3a003f99d1d95e6c7766322ed75d9d83d78", + "block_hash": "0x058a67093c5f642a7910b7aef0c0a846834e1df60f9bf4c0564afb9c8efe3a41", + "block_number": 68074, + "transaction_index": 6, + "transaction_status": "ACCEPTED_ON_L1", + "transaction_finality_status": "ACCEPTED_ON_L1", + "transaction_execution_status": "SUCCEEDED", + "transaction_type": "DEPLOY_ACCOUNT", + "version": 1, + "signature": [ + "0x2de38508b633161a3cdbc0a04b0e09f85c884254552f903417239f95486ceda", + "0x2694930b199802941c996f8aaf48e63a1b2e51ccfaec7864f83f40fcd285286" + ], + "max_fee": "6639218055204", + "actual_fee": "21040570099", + "nonce": null, + "contract_address": "0x042ae4bf23ef08090010662d275982b6070edfecb131e9fb83dfc1414d226529", + "entry_point_selector": null, + "entry_point_type": null, + "calldata": null, + "class_hash": "0x025ec026985a3bf9d0cc1fe17326b245dfdc3ff89b8fde106542a3ea56c5a918", + "sender_address": null, + "constructor_calldata": [ + "0x33434ad846cdd5f23eb73ff09fe6fddd568284a0fb7d1be20ee482f044dabe2", + "0x79dc0da7c54b95f10aa182ad0a46400db63156920adb65eca2654c0945a463", + "0x2", + "0xbd7fccd6d25df79e3fc8dd539efd03fe448d902b8bc5955e60b3830988ce50", + "0x0" + ], + "contract_address_salt": "334816139481647544515869631733577866188380288661138191555306848313001168464", + "timestamp": 1716355916, + "entry_point_selector_name": "constructor", + "number_of_events": 2, + "revert_error": null, + "account_calls": [] + } +} diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 732ca50f..31bbf1f9 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -19,6 +19,10 @@ import { } from 'starknet'; import { v4 as uuidv4 } from 'uuid'; +import type { + StarkScanTransaction, + StarkScanTransactionsResponse, +} from '../chain/data-client/starkscan.type'; import { FeeToken } from '../types/snapApi'; import type { AccContract, @@ -32,6 +36,7 @@ import { PROXY_CONTRACT_HASH, } from '../utils/constants'; import { grindKey } from '../utils/keyPair'; +import { invokeTx, cairo0DeployTx } from './fixture/stark-scan-example.json'; /* eslint-disable */ export type StarknetAccount = AccContract & { @@ -364,6 +369,65 @@ export function generateTransactionRequests({ return requests; } +/** + * Method to generate starkscan transactions. + * + * @param params + * @param params.address - Address of the account. + * @param params.startFrom - start timestamp of the first transactions. + * @param params.timestampReduction - the deduction timestamp per transactions. + * @param params.txnTypes - Array of txn types. + * @param params.cnt - Number of transaction to generate. + * @returns An array of transaction object. + */ +export function generateStarkScanTransactions({ + address, + startFrom = Date.now(), + timestampReduction = 100, + cnt = 10, + txnTypes = [TransactionType.DEPLOY_ACCOUNT, TransactionType.INVOKE], +}: { + address: string; + startFrom?: number; + timestampReduction?: number; + cnt?: number; + txnTypes?: TransactionType[]; +}): StarkScanTransactionsResponse { + let transactionStartFrom = startFrom; + const txs: StarkScanTransaction[] = []; + let totalRecordCnt = txnTypes.includes(TransactionType.DEPLOY_ACCOUNT) + ? cnt - 1 + : cnt; + + for (let i = 0; i < totalRecordCnt; i++) { + let newTx = { + ...invokeTx, + account_calls: [...invokeTx.account_calls], + }; + newTx.sender_address = address; + newTx.account_calls[0].caller_address = address; + newTx.timestamp = transactionStartFrom; + newTx.transaction_hash = `0x${transactionStartFrom.toString(16)}`; + transactionStartFrom -= timestampReduction; + txs.push(newTx as unknown as StarkScanTransaction); + } + + if (txnTypes.includes(TransactionType.DEPLOY_ACCOUNT)) { + let deployTx = { + ...cairo0DeployTx, + account_calls: [...cairo0DeployTx.account_calls], + }; + deployTx.contract_address = address; + deployTx.transaction_hash = `0x${transactionStartFrom.toString(16)}`; + txs.push(deployTx as unknown as StarkScanTransaction); + } + + return { + next_url: null, + data: txs, + }; +} + /** * Method to generate a mock estimate fee response. * diff --git a/packages/starknet-snap/src/chain/api-client.ts b/packages/starknet-snap/src/chain/api-client.ts new file mode 100644 index 00000000..253af4fe --- /dev/null +++ b/packages/starknet-snap/src/chain/api-client.ts @@ -0,0 +1,130 @@ +import type { Json } from '@metamask/snaps-sdk'; +import type { Struct } from 'superstruct'; +import { mask } from 'superstruct'; + +import { logger } from '../utils/logger'; + +export enum HttpMethod { + Get = 'GET', + Post = 'POST', +} + +export type HttpHeaders = Record; + +export type HttpRequest = { + url: string; + method: HttpMethod; + headers: HttpHeaders; + body?: string; +}; + +export type HttpResponse = globalThis.Response; + +export abstract class ApiClient { + /** + * The name of the API Client. + */ + abstract apiClientName: string; + + /** + * An internal method called internally by `submitRequest()` to verify and convert the HTTP response to the expected API response. + * + * @param response - The HTTP response to verify and convert. + * @returns A promise that resolves to the API response. + */ + protected async parseResponse( + response: HttpResponse, + ): Promise { + try { + return (await response.json()) as unknown as ApiResponse; + } catch (error) { + throw new Error( + 'API response error: response body can not be deserialised.', + ); + } + } + + /** + * An internal method used to build the `HttpRequest` object. + * + * @param params - The request parameters. + * @param params.method - The HTTP method (GET or POST). + * @param params.headers - The HTTP headers. + * @param params.url - The request URL. + * @param [params.body] - The request body (optional). + * @returns A `HttpRequest` object. + */ + protected buildHttpRequest({ + method, + headers = {}, + url, + body, + }: { + method: HttpMethod; + headers?: HttpHeaders; + url: string; + body?: Json; + }): HttpRequest { + const request = { + url, + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: + method === HttpMethod.Post && body ? JSON.stringify(body) : undefined, + }; + + return request; + } + + /** + * An internal method used to send a HTTP request. + * + * @param params - The request parameters. + * @param [params.requestName] - The name of the request (optional). + * @param params.request - The `HttpRequest` object. + * @param params.responseStruct - The superstruct used to verify the API response. + * @returns A promise that resolves to a JSON object. + */ + protected async sendHttpRequest({ + requestName = '', + request, + responseStruct, + }: { + requestName?: string; + request: HttpRequest; + responseStruct: Struct; + }): Promise { + const logPrefix = `[${this.apiClientName}.${requestName}]`; + + try { + logger.debug(`${logPrefix} request: ${request.method}`); // Log HTTP method being used. + + const fetchRequest = { + method: request.method, + headers: request.headers, + body: request.body, + }; + + const httpResponse = await fetch(request.url, fetchRequest); + + const jsonResponse = await this.parseResponse(httpResponse); + + logger.debug(`${logPrefix} response:`, JSON.stringify(jsonResponse)); + + // Safeguard to identify if the response has some unexpected changes from the API client + mask(jsonResponse, responseStruct, `Unexpected response from API client`); + + return jsonResponse; + } catch (error) { + logger.info( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${logPrefix} error: ${error.message}`, + ); + + throw error; + } + } +} diff --git a/packages/starknet-snap/src/chain/data-client.ts b/packages/starknet-snap/src/chain/data-client.ts new file mode 100644 index 00000000..e5cea616 --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client.ts @@ -0,0 +1,6 @@ +import type { Transaction } from '../types/snapState'; + +export type IDataClient = { + getTransactions: (address: string, tillTo: number) => Promise; + getDeployTransaction: (address: string) => Promise; +}; diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.test.ts b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts new file mode 100644 index 00000000..e4b24647 --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client/starkscan.test.ts @@ -0,0 +1,483 @@ +import { TransactionType, constants } from 'starknet'; + +import { + generateAccounts, + generateStarkScanTransactions, +} from '../../__tests__/helper'; +import { + ContractFuncName, + TransactionDataVersion, + type Network, + type Transaction, +} from '../../types/snapState'; +import { + STARKNET_MAINNET_NETWORK, + STARKNET_SEPOLIA_TESTNET_NETWORK, +} from '../../utils/constants'; +import { InvalidNetworkError } from '../../utils/exceptions'; +import { StarkScanClient } from './starkscan'; +import { + StarkScanTransactionsResponseStruct, + type StarkScanOptions, + type StarkScanTransaction, + type StarkScanTransactionsResponse, +} from './starkscan.type'; + +jest.mock('../../utils/logger'); + +describe('StarkScanClient', () => { + class MockStarkScanClient extends StarkScanClient { + public toTransaction(data: StarkScanTransaction): Transaction { + return super.toTransaction(data); + } + + get baseUrl(): string { + return super.baseUrl; + } + + async sendApiRequest(request): Promise { + return await super.sendApiRequest(request); + } + + getSenderAddress(tx: StarkScanTransaction): string { + return super.getSenderAddress(tx); + } + } + + const createMockClient = ({ + network = STARKNET_SEPOLIA_TESTNET_NETWORK, + options = { + apiKey: 'api-key', + }, + }: { + network?: Network; + options?: StarkScanOptions; + } = {}) => { + return new MockStarkScanClient(network, options); + }; + + const createMockFetch = () => { + // eslint-disable-next-line no-restricted-globals + Object.defineProperty(global, 'fetch', { + writable: true, + }); + + const fetchSpy = jest.fn(); + // eslint-disable-next-line no-restricted-globals + global.fetch = fetchSpy; + + return { + fetchSpy, + }; + }; + + const mockAccount = async ( + chainId: constants.StarknetChainId = constants.StarknetChainId.SN_SEPOLIA, + ) => { + const [account] = await generateAccounts(chainId, 1); + return account; + }; + + const mockApiSuccess = ({ + fetchSpy, + // eslint-disable-next-line @typescript-eslint/naming-convention + response = { data: [], next_url: null }, + }: { + fetchSpy: jest.SpyInstance; + response?: StarkScanTransactionsResponse; + }) => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(response), + }); + }; + + const mockApiFailure = ({ fetchSpy }: { fetchSpy: jest.SpyInstance }) => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + statusText: 'error', + }); + }; + + const mockTxByType = (txnType: TransactionType, address: string) => { + const mockResponse = generateStarkScanTransactions({ + address, + txnTypes: [txnType], + cnt: 1, + }); + const tx = mockResponse.data[0]; + return tx; + }; + + describe('baseUrl', () => { + it.each([ + { + network: STARKNET_SEPOLIA_TESTNET_NETWORK, + expectedUrl: 'https://api-sepolia.starkscan.co/api/v0', + }, + { + network: STARKNET_MAINNET_NETWORK, + expectedUrl: 'https://api.starkscan.co/api/v0', + }, + ])( + 'returns the api url if the chain id is $network.name', + ({ network, expectedUrl }: { network: Network; expectedUrl: string }) => { + const client = createMockClient({ + network, + }); + + expect(client.baseUrl).toStrictEqual(expectedUrl); + }, + ); + + it('throws `InvalidNetworkError` if the chain id is invalid', () => { + const invalidNetwork: Network = { + name: 'Invalid Network', + chainId: '0x534e5f474f45524c49', + baseUrl: '', + nodeUrl: '', + voyagerUrl: '', + accountClassHash: '', + }; + const client = createMockClient({ + network: invalidNetwork, + }); + + expect(() => client.baseUrl).toThrow(InvalidNetworkError); + }); + }); + + describe('sendApiRequest', () => { + const mockRequest = () => { + return { + apiUrl: `/url`, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions', + }; + }; + + it('fetches data', async () => { + const { fetchSpy } = createMockFetch(); + // eslint-disable-next-line @typescript-eslint/naming-convention + const expectedResponse = { data: [], next_url: null }; + mockApiSuccess({ fetchSpy, response: expectedResponse }); + + const client = createMockClient(); + const result = await client.sendApiRequest(mockRequest()); + + expect(result).toStrictEqual(expectedResponse); + }); + + it('appends a api key to header', async () => { + const { fetchSpy } = createMockFetch(); + mockApiSuccess({ fetchSpy }); + + const apiKey = 'ABCDEFG-API-KEY'; + + const client = createMockClient({ + options: { + apiKey, + }, + }); + await client.sendApiRequest(mockRequest()); + + expect(fetchSpy).toHaveBeenCalledWith(`/url`, { + method: 'GET', + body: undefined, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + }, + }); + }); + + it('throws `API response error: response body can not be deserialised.` error if the response.ok is falsy', async () => { + const { fetchSpy } = createMockFetch(); + mockApiFailure({ fetchSpy }); + + const client = createMockClient(); + await expect(client.sendApiRequest(mockRequest())).rejects.toThrow( + `API response error: response body can not be deserialised.`, + ); + }); + }); + + describe('getTransactions', () => { + const mSecsFor24Hours = 1000 * 60 * 60 * 24; + + const getFromAndToTimestamp = (tillToInDay: number) => { + const from = Math.floor(Date.now() / 1000); + const to = from - tillToInDay * 24 * 60 * 60; + return { + from, + to, + }; + }; + + it('returns transactions', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + const { from, to } = getFromAndToTimestamp(5); + // generate 10 invoke transactions + const mockResponse = generateStarkScanTransactions({ + address: account.address, + startFrom: from, + }); + mockApiSuccess({ fetchSpy, response: mockResponse }); + + const client = createMockClient(); + const result = await client.getTransactions(account.address, to); + + // The result should include the transaction if: + // - it's timestamp is greater than the `tillTo` + // - it's transaction type is `DEPLOY_ACCOUNT` + expect(result).toHaveLength( + mockResponse.data.filter( + (tx) => + tx.transaction_type === TransactionType.DEPLOY_ACCOUNT || + tx.timestamp >= to, + ).length, + ); + expect( + result.find((tx) => tx.txnType === TransactionType.DEPLOY_ACCOUNT), + ).toBeDefined(); + }); + + it('returns empty array if no result found', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + const { to } = getFromAndToTimestamp(5); + // mock the get invoke transactions response with empty data + mockApiSuccess({ fetchSpy }); + // mock the get deploy transaction response with empty data + mockApiSuccess({ fetchSpy }); + + const client = createMockClient(); + const result = await client.getTransactions(account.address, to); + + expect(result).toStrictEqual([]); + }); + + it('continue to fetch if next_url is presented', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + // generate the to timestamp which is 100 days ago + const { to } = getFromAndToTimestamp(100); + const mockPage1Response = generateStarkScanTransactions({ + address: account.address, + txnTypes: [TransactionType.INVOKE], + cnt: 10, + }); + const mockPage2Response = generateStarkScanTransactions({ + address: account.address, + cnt: 10, + }); + const firstPageUrl = `https://api-sepolia.starkscan.co/api/v0/transactions?contract_address=${account.address}&order_by=desc&limit=100`; + const nextPageUrl = `https://api-sepolia.starkscan.co/api/v0/transactions?contract_address=${account.address}&order_by=desc&cursor=MTcyNDc1OTQwNzAwMDAwNjAwMDAwMA%3D%3D`; + + // mock the first page response, which contains the next_url + mockApiSuccess({ + fetchSpy, + response: { + data: mockPage1Response.data, + // eslint-disable-next-line @typescript-eslint/naming-convention + next_url: nextPageUrl, + }, + }); + // mock the send page response + mockApiSuccess({ fetchSpy, response: mockPage2Response }); + + const client = createMockClient(); + await client.getTransactions(account.address, to); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenNthCalledWith( + 1, + firstPageUrl, + expect.any(Object), + ); + expect(fetchSpy).toHaveBeenNthCalledWith( + 2, + nextPageUrl, + expect.any(Object), + ); + }); + + it('fetchs the deploy transaction if it is not present', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + // generate the to timestamp which is 5 days ago + const { from, to } = getFromAndToTimestamp(5); + // generate 10 invoke transactions, and 1 day time gap between each transaction + const mockInvokeResponse = generateStarkScanTransactions({ + address: account.address, + startFrom: from, + timestampReduction: mSecsFor24Hours, + txnTypes: [TransactionType.INVOKE], + }); + // generate another 5 invoke transactions + deploy transactions for testing the fallback case + const mockDeployResponse = generateStarkScanTransactions({ + address: account.address, + // generate transactions that start from 100 days ago, to ensure not overlap with above invoke transactions + startFrom: mSecsFor24Hours * 100, + timestampReduction: mSecsFor24Hours, + txnTypes: [TransactionType.INVOKE, TransactionType.DEPLOY_ACCOUNT], + cnt: 5, + }); + mockApiSuccess({ fetchSpy, response: mockInvokeResponse }); + mockApiSuccess({ fetchSpy, response: mockDeployResponse }); + + const client = createMockClient(); + // We only fetch the transactions from the last 5 days + const result = await client.getTransactions(account.address, to); + + // The result should include a deploy transaction, even it is not from the last 5 days + expect( + result.find((tx) => tx.txnType === TransactionType.DEPLOY_ACCOUNT), + ).toBeDefined(); + }); + }); + + describe('toTransaction', () => { + it('converts an invoke type starkscan transaction to a transaction object', async () => { + const account = await mockAccount(); + const mockTx = mockTxByType(TransactionType.INVOKE, account.address); + + const client = createMockClient(); + const result = client.toTransaction(mockTx); + + const { contract_address: contract, calldata: contractCallData } = + mockTx.account_calls[0]; + + expect(result).toStrictEqual({ + txnHash: mockTx.transaction_hash, + txnType: mockTx.transaction_type, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + senderAddress: account.address, + contractAddress: '', + timestamp: mockTx.timestamp, + finalityStatus: mockTx.transaction_finality_status, + executionStatus: mockTx.transaction_execution_status, + failureReason: mockTx.revert_error ?? '', + maxFee: mockTx.max_fee, + actualFee: mockTx.actual_fee, + accountCalls: { + [contract]: [ + { + contract, + contractFuncName: ContractFuncName.Transfer, + contractCallData, + recipient: contractCallData[0], + amount: contractCallData[1], + }, + ], + }, + version: mockTx.version, + dataVersion: TransactionDataVersion.V2, + }); + }); + + it('converts a deploy type starkscan transaction to a transaction object', async () => { + const account = await mockAccount(); + const mockTx = mockTxByType( + TransactionType.DEPLOY_ACCOUNT, + account.address, + ); + + const client = createMockClient(); + const result = client.toTransaction(mockTx); + + expect(result).toStrictEqual({ + txnHash: mockTx.transaction_hash, + txnType: mockTx.transaction_type, + chainId: STARKNET_SEPOLIA_TESTNET_NETWORK.chainId, + senderAddress: account.address, + contractAddress: account.address, + timestamp: mockTx.timestamp, + finalityStatus: mockTx.transaction_finality_status, + executionStatus: mockTx.transaction_execution_status, + failureReason: mockTx.revert_error ?? '', + maxFee: mockTx.max_fee, + actualFee: mockTx.actual_fee, + accountCalls: null, + version: mockTx.version, + dataVersion: TransactionDataVersion.V2, + }); + }); + }); + + describe('getDeployTransaction', () => { + it('returns a deploy transaction', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + // generate 5 invoke transactions with deploy transaction + const mockResponse = generateStarkScanTransactions({ + address: account.address, + cnt: 5, + }); + mockApiSuccess({ fetchSpy, response: mockResponse }); + + const client = createMockClient(); + const result = await client.getDeployTransaction(account.address); + + expect(result).not.toBeNull(); + expect(result?.txnType).toStrictEqual(TransactionType.DEPLOY_ACCOUNT); + }); + + it('returns null if no deploy transaction found', async () => { + const account = await mockAccount(); + const { fetchSpy } = createMockFetch(); + // generate 5 invoke transactions with deploy transaction + const mockResponse = generateStarkScanTransactions({ + address: account.address, + cnt: 1, + txnTypes: [TransactionType.INVOKE], + }); + mockApiSuccess({ fetchSpy, response: mockResponse }); + + const client = createMockClient(); + const result = await client.getDeployTransaction(account.address); + + expect(result).toBeNull(); + }); + }); + + describe('getSenderAddress', () => { + const prepareMockTx = async (transactionType = TransactionType.INVOKE) => { + const account = await mockAccount(); + const mockTx = mockTxByType(transactionType, account.address); + return mockTx; + }; + + it('returns the sender address', async () => { + const mockTx = await prepareMockTx(); + + const client = createMockClient(); + expect(client.getSenderAddress(mockTx)).toStrictEqual( + mockTx.sender_address, + ); + }); + + it('returns the contract address if it is a deploy transaction', async () => { + const mockTx = await prepareMockTx(TransactionType.DEPLOY_ACCOUNT); + + const client = createMockClient(); + expect(client.getSenderAddress(mockTx)).toStrictEqual( + mockTx.contract_address, + ); + }); + + it('returns an empty string if the sender address is null', async () => { + const mockTx = await prepareMockTx(); + + const client = createMockClient(); + expect( + client.getSenderAddress({ + ...mockTx, + // eslint-disable-next-line @typescript-eslint/naming-convention + sender_address: null, + }), + ).toBe(''); + }); + }); +}); diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.ts b/packages/starknet-snap/src/chain/data-client/starkscan.ts new file mode 100644 index 00000000..8935c80f --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client/starkscan.ts @@ -0,0 +1,300 @@ +import { TransactionType, constants } from 'starknet'; +import type { Struct } from 'superstruct'; + +import { + ContractFuncName, + TransactionDataVersion, + type Network, + type Transaction, + type TranscationAccountCall, +} from '../../types/snapState'; +import { + TRANSFER_SELECTOR_HEX, + UPGRADE_SELECTOR_HEX, +} from '../../utils/constants'; +import { InvalidNetworkError } from '../../utils/exceptions'; +import type { HttpHeaders } from '../api-client'; +import { ApiClient, HttpMethod } from '../api-client'; +import type { IDataClient } from '../data-client'; +import type { StarkScanTransactionsResponse } from './starkscan.type'; +import { + type StarkScanAccountCall, + type StarkScanTransaction, + type StarkScanOptions, + StarkScanTransactionsResponseStruct, +} from './starkscan.type'; + +export class StarkScanClient extends ApiClient implements IDataClient { + apiClientName = 'StarkScanClient'; + + protected limit = 100; + + protected network: Network; + + protected options: StarkScanOptions; + + protected deploySelectorName = 'constructor'; + + constructor(network: Network, options: StarkScanOptions) { + super(); + this.network = network; + this.options = options; + } + + protected get baseUrl(): string { + switch (this.network.chainId) { + case constants.StarknetChainId.SN_SEPOLIA: + return 'https://api-sepolia.starkscan.co/api/v0'; + case constants.StarknetChainId.SN_MAIN: + return 'https://api.starkscan.co/api/v0'; + default: + throw new InvalidNetworkError(); + } + } + + protected getApiUrl(endpoint: string): string { + return `${this.baseUrl}${endpoint}`; + } + + protected getHttpHeaders(): HttpHeaders { + return { + 'x-api-key': this.options.apiKey, + }; + } + + protected async sendApiRequest({ + apiUrl, + responseStruct, + requestName, + }: { + apiUrl: string; + responseStruct: Struct; + requestName: string; + }): Promise { + return await super.sendHttpRequest({ + request: this.buildHttpRequest({ + method: HttpMethod.Get, + url: apiUrl, + headers: this.getHttpHeaders(), + }), + responseStruct, + requestName, + }); + } + + /** + * Fetches the transactions for a given contract address. + * The transactions are fetched in descending order and it will include the deploy transaction. + * + * @param address - The address of the contract to fetch the transactions for. + * @param to - The filter includes transactions with a timestamp that is >= a specified value, but the deploy transaction is always included regardless of its timestamp. + * @returns A Promise that resolve an array of Transaction object. + */ + async getTransactions(address: string, to: number): Promise { + let apiUrl = this.getApiUrl( + `/transactions?contract_address=${address}&order_by=desc&limit=${this.limit}`, + ); + + const txs: Transaction[] = []; + let deployTxFound = false; + let process = true; + let timestamp = 0; + + // Scan the transactions in descending order by timestamp + // Include the transaction if: + // - it's timestamp is greater than the `tillTo` AND + // - there is an next data to fetch + while (process && (timestamp === 0 || timestamp >= to)) { + process = false; + + const result = await this.sendApiRequest({ + apiUrl, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions', + }); + + for (const data of result.data) { + const tx = this.toTransaction(data); + const isDeployTx = this.isDeployTransaction(data); + + if (isDeployTx) { + deployTxFound = true; + } + + timestamp = tx.timestamp; + // Only include the records that newer than or equal to the `to` timestamp from the same batch of result + // If there is an deploy transaction from the result, it should included too. + // e.g + // to: 1000 + // [ + // { timestamp: 1100, transaction_type: "invoke" }, <-- include + // { timestamp: 900, transaction_type: "invoke" }, <-- exclude + // { timestamp: 100, transaction_type: "deploy" } <-- include + // ] + if (timestamp >= to || isDeployTx) { + txs.push(tx); + } + } + + if (result.next_url) { + apiUrl = result.next_url; + process = true; + } + } + + // In case no deploy transaction found from above, + // then scan the transactions in asc order by timestamp, + // the deploy transaction should usually be the first transaction from the list + if (!deployTxFound) { + const deployTx = await this.getDeployTransaction(address); + deployTx && txs.push(deployTx); + } + + return txs; + } + + /** + * Fetches the deploy transaction for a given contract address. + * + * @param address - The address of the contract to fetch the deploy transaction for. + * @returns A Promise that resolve the Transaction object or null if the transaction can not be found. + */ + async getDeployTransaction(address: string): Promise { + // Fetch the first 5 transactions to find the deploy transaction + // The deploy transaction usually is the first transaction from the list + const apiUrl = this.getApiUrl( + `/transactions?contract_address=${address}&order_by=asc&limit=5`, + ); + + const result = await this.sendApiRequest({ + apiUrl, + responseStruct: StarkScanTransactionsResponseStruct, + requestName: 'getTransactions', + }); + + for (const data of result.data) { + if (this.isDeployTransaction(data)) { + return this.toTransaction(data); + } + } + + return null; + } + + protected isDeployTransaction(tx: StarkScanTransaction): boolean { + return tx.transaction_type === TransactionType.DEPLOY_ACCOUNT; + } + + protected isFundTransferTransaction(entrypoint: string): boolean { + return entrypoint === TRANSFER_SELECTOR_HEX; + } + + protected getContractAddress(tx: StarkScanTransaction): string { + // backfill the contract address if it is null + return tx.contract_address ?? ''; + } + + protected getSenderAddress(tx: StarkScanTransaction): string { + let sender = tx.sender_address; + + if (this.isDeployTransaction(tx)) { + // if it is a deploy transaction, the contract address is the sender address + sender = tx.contract_address as unknown as string; + } + + // backfill the sender address if it is null + return sender ?? ''; + } + + protected toTransaction(tx: StarkScanTransaction): Transaction { + /* eslint-disable @typescript-eslint/naming-convention */ + const { + transaction_hash: txnHash, + transaction_type: txnType, + timestamp, + transaction_finality_status: finalityStatus, + transaction_execution_status: executionStatus, + max_fee: maxFee, + actual_fee: actualFee, + revert_error: failureReason, + account_calls: calls, + version, + } = tx; + + // account_calls representing the calls to invoke from the account contract, it can be multiple + // If the transaction is a deploy transaction, the account_calls is a empty array + const accountCalls = this.toAccountCall(calls); + + return { + txnHash, + txnType, + chainId: this.network.chainId, + senderAddress: this.getSenderAddress(tx), + timestamp, + finalityStatus, + executionStatus, + maxFee, + actualFee, + contractAddress: this.getContractAddress(tx), + accountCalls, + failureReason: failureReason ?? '', + version, + dataVersion: TransactionDataVersion.V2, + }; + + /* eslint-enable */ + } + + protected toAccountCall( + accountCalls: StarkScanAccountCall[], + ): Record | null { + if (!accountCalls || accountCalls.length === 0) { + return null; + } + + return accountCalls.reduce( + ( + data: Record, + accountCallArg: StarkScanAccountCall, + ) => { + const { + contract_address: contract, + selector, + calldata: contractCallData, + } = accountCallArg; + + const contractFuncName = this.selectorHexToName(selector); + if (!Object.prototype.hasOwnProperty.call(data, contract)) { + data[contract] = []; + } + + const accountCall: TranscationAccountCall = { + contract, + contractFuncName, + contractCallData, + }; + + if (this.isFundTransferTransaction(selector)) { + accountCall.recipient = accountCallArg.calldata[0]; + accountCall.amount = accountCallArg.calldata[1]; + } + + data[contract].push(accountCall); + + return data; + }, + {}, + ); + } + + protected selectorHexToName(selector: string): string { + switch (selector.toLowerCase()) { + case TRANSFER_SELECTOR_HEX.toLowerCase(): + return ContractFuncName.Transfer; + case UPGRADE_SELECTOR_HEX.toLowerCase(): + return ContractFuncName.Upgrade; + default: + return selector; + } + } +} diff --git a/packages/starknet-snap/src/chain/data-client/starkscan.type.ts b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts new file mode 100644 index 00000000..c15972ea --- /dev/null +++ b/packages/starknet-snap/src/chain/data-client/starkscan.type.ts @@ -0,0 +1,55 @@ +import { + TransactionExecutionStatus, + TransactionFinalityStatus, + TransactionType, +} from 'starknet'; +import type { Infer } from 'superstruct'; +import { array, nullable, number, object, string, enums } from 'superstruct'; + +/* eslint-disable @typescript-eslint/naming-convention */ +const NullableStringStruct = nullable(string()); +const NullableStringArrayStruct = nullable(array(string())); + +export const StarkScanAccountCallStruct = object({ + contract_address: string(), + calldata: array(string()), + selector: string(), +}); + +export const StarkScanTransactionStruct = object({ + transaction_hash: string(), + transaction_finality_status: enums(Object.values(TransactionFinalityStatus)), + transaction_execution_status: enums( + Object.values(TransactionExecutionStatus), + ), + transaction_type: enums(Object.values(TransactionType)), + // The transaction version, 1 or 3, where 3 represents the fee will be paid in STRK + version: number(), + max_fee: NullableStringStruct, + actual_fee: NullableStringStruct, + nonce: NullableStringStruct, + contract_address: NullableStringStruct, + calldata: NullableStringArrayStruct, + sender_address: NullableStringStruct, + timestamp: number(), + revert_error: NullableStringStruct, + account_calls: array(StarkScanAccountCallStruct), +}); + +export type StarkScanAccountCall = Infer; + +export type StarkScanTransaction = Infer; + +export const StarkScanTransactionsResponseStruct = object({ + next_url: nullable(string()), + data: array(StarkScanTransactionStruct), +}); + +export type StarkScanTransactionsResponse = Infer< + typeof StarkScanTransactionsResponseStruct +>; + +export type StarkScanOptions = { + apiKey: string; +}; +/* eslint-enable */ diff --git a/packages/starknet-snap/src/config.ts b/packages/starknet-snap/src/config.ts index f92c0935..abe5caa9 100644 --- a/packages/starknet-snap/src/config.ts +++ b/packages/starknet-snap/src/config.ts @@ -25,8 +25,17 @@ export type SnapConfig = { explorer: { [key: string]: string; }; + dataClient: { + [key: string]: { + apiKey: string | undefined; + }; + }; }; +export enum DataClient { + STARKSCAN = 'starkscan', +} + export const Config: SnapConfig = { // eslint-disable-next-line no-restricted-globals logLevel: process.env.LOG_LEVEL ?? LogLevel.OFF.valueOf().toString(), @@ -49,6 +58,13 @@ export const Config: SnapConfig = { 'https://sepolia.voyager.online/contract/${address}', }, + dataClient: { + [DataClient.STARKSCAN]: { + // eslint-disable-next-line no-restricted-globals + apiKey: process.env.STARKSCAN_API_KEY, + }, + }, + preloadTokens: [ ETHER_MAINNET, ETHER_SEPOLIA_TESTNET, diff --git a/packages/starknet-snap/src/types/snapState.ts b/packages/starknet-snap/src/types/snapState.ts index fa9f210c..d139df1f 100644 --- a/packages/starknet-snap/src/types/snapState.ts +++ b/packages/starknet-snap/src/types/snapState.ts @@ -1,6 +1,9 @@ import type { - EstimateFee, RawCalldata, + TransactionType as StarkNetTransactionType, + TransactionExecutionStatus, + TransactionFinalityStatus, + EstimateFee, TransactionType as StarknetTransactionType, } from 'starknet'; @@ -116,12 +119,18 @@ export enum TransactionStatusType { // for retrieving txn from StarkNet feeder g DEPRECATION = 'status', } -export type Transaction = { +export type TranscationAccountCall = { + contract: string; + contractFuncName: string; + contractCallData: string[]; + recipient?: string; + amount?: string; +}; + +export type LegacyTransaction = { txnHash: string; // in hex - // TODO: Change the type of txnType to `TransactionType` in the SnapState, when this state manager apply to getTransactions, there is no migration neeeded, as the state is override for every fetch for getTransactions txnType: VoyagerTransactionType | string; chainId: string; // in hex - // TODO: rename it to address to sync with the same naming convention in the AccContract senderAddress: string; // in hex contractAddress: string; // in hex contractFuncName: string; @@ -134,4 +143,37 @@ export type Transaction = { timestamp: number; }; +export enum TransactionDataVersion { + V2 = 'V2', +} + +export enum ContractFuncName { + Upgrade = 'upgrade', + Transfer = 'transfer', +} + +export type V2Transaction = { + txnHash: string; // in hex + txnType: StarkNetTransactionType; + chainId: string; // in hex + senderAddress: string; // in hex + contractAddress: string; // in hex + executionStatus?: TransactionExecutionStatus | string; + finalityStatus?: TransactionFinalityStatus | string; + failureReason: string; + timestamp: number; + maxFee?: string | null; + actualFee?: string | null; + // using Record to support O(1) searching + accountCalls?: Record | null; + version: number; + // Snap data Version to support backward compatibility , migration. + dataVersion: TransactionDataVersion.V2; +}; + +// FIXME: temp solution for backward compatibility before StarkScan implemented in get transactions +export type Transaction = + | LegacyTransaction + | (V2Transaction & { status?: TransactionStatus | string }); + /* eslint-disable */ diff --git a/packages/starknet-snap/src/utils/factory.test.ts b/packages/starknet-snap/src/utils/factory.test.ts new file mode 100644 index 00000000..12466fac --- /dev/null +++ b/packages/starknet-snap/src/utils/factory.test.ts @@ -0,0 +1,22 @@ +import { StarkScanClient } from '../chain/data-client/starkscan'; +import { Config, DataClient } from '../config'; +import { STARKNET_SEPOLIA_TESTNET_NETWORK } from './constants'; +import { createStarkScanClient } from './factory'; + +describe('createStarkScanClient', () => { + const config = Config.dataClient[DataClient.STARKSCAN]; + + it('creates a StarkScan client', () => { + config.apiKey = 'API_KEY'; + expect( + createStarkScanClient(STARKNET_SEPOLIA_TESTNET_NETWORK), + ).toBeInstanceOf(StarkScanClient); + config.apiKey = undefined; + }); + + it('throws `Missing StarkScan API key` error if the StarkScan API key is missing', () => { + expect(() => + createStarkScanClient(STARKNET_SEPOLIA_TESTNET_NETWORK), + ).toThrow('Missing StarkScan API key'); + }); +}); diff --git a/packages/starknet-snap/src/utils/factory.ts b/packages/starknet-snap/src/utils/factory.ts new file mode 100644 index 00000000..41811241 --- /dev/null +++ b/packages/starknet-snap/src/utils/factory.ts @@ -0,0 +1,25 @@ +import type { IDataClient } from '../chain/data-client'; +import { StarkScanClient } from '../chain/data-client/starkscan'; +import { Config, DataClient } from '../config'; +import type { Network } from '../types/snapState'; + +/** + * Create a StarkScan client. + * + * @param network - The network to create the data client for. + * @returns The StarkScan client. + * @throws Error if the StarkScan API key is missing. + */ +export function createStarkScanClient(network: Network): IDataClient { + const { apiKey } = Config.dataClient[DataClient.STARKSCAN]; + + if (!apiKey) { + throw new Error('Missing StarkScan API key'); + } + + const dataClient = new StarkScanClient(network, { + apiKey, + }); + + return dataClient; +} From bc1b20734dc4a8f63159e85baed3e5c1350d3842 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:43:40 +0800 Subject: [PATCH 5/6] chore: refactor `TransactionStateManager` to handle new transaction data structure (#450) * 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 --------- Co-authored-by: khanti42 --- .../starknet-snap/src/__tests__/helper.ts | 315 ++++++++++++------ .../src/state/__tests__/helper.ts | 17 + .../state/transaction-state-manager.test.ts | 60 +++- .../src/state/transaction-state-manager.ts | 40 ++- 4 files changed, 321 insertions(+), 111 deletions(-) diff --git a/packages/starknet-snap/src/__tests__/helper.ts b/packages/starknet-snap/src/__tests__/helper.ts index 31bbf1f9..b363226c 100644 --- a/packages/starknet-snap/src/__tests__/helper.ts +++ b/packages/starknet-snap/src/__tests__/helper.ts @@ -6,8 +6,9 @@ import type { UserInputEvent } from '@metamask/snaps-sdk'; import { UserInputEventType } from '@metamask/snaps-sdk'; import { generateMnemonic } from 'bip39'; import { getRandomValues } from 'crypto'; -import type { constants, EstimateFee } from 'starknet'; +import type { EstimateFee } from 'starknet'; import { + constants, ec, CallData, hash, @@ -24,10 +25,11 @@ import type { StarkScanTransactionsResponse, } from '../chain/data-client/starkscan.type'; import { FeeToken } from '../types/snapApi'; -import type { - AccContract, - Transaction, - TransactionRequest, +import { + TransactionDataVersion, + type AccContract, + type Transaction, + type TransactionRequest, } from '../types/snapState'; import { ACCOUNT_CLASS_HASH, @@ -62,6 +64,30 @@ export function generateRandomValue() { return getRandomValues(u32Arr)[0] / maxU32; } +/** + * Method to get a random value. + * + * @param dataLength - The length of the data. + * @returns An random number. + */ +export function getRandomValue(dataLength: number) { + return Math.floor(generateRandomValue() * dataLength); +} + +/** + * Method to get a random data. + * + * @param data - The data to get a random value. + * @returns A random data. + * */ +export function getRandomData(data: DataType[]) { + return data[getRandomValue(data.length)]; +} + +const SixtyThreeHexInBigInt = BigInt( + '1000000000000000000000000000000000000000000000000000000000000000000000000000', +); + /** * Method to generate Bip44 Entropy. * @@ -169,20 +195,24 @@ export async function generateAccounts( * @param params.finalityStatuses - Array of transaction finality status. * @param params.executionStatuses - Array of transaction execution status. * @param params.cnt - Number of transaction to generate. + * @param params.timestamp - The timestamp of the first transaction. + * @param params.transactionVersions - The transaction version, 1 or 3, where 3 represents the fee will be paid in STRK. * @returns An array of transaction object. */ export function generateTransactions({ chainId, address, + baseTxnHashInBigInt = SixtyThreeHexInBigInt, contractAddresses = PRELOADED_TOKENS.map((token) => token.address), txnTypes = Object.values(TransactionType), finalityStatuses = Object.values(TransactionFinalityStatus), executionStatuses = Object.values(TransactionExecutionStatus), // The timestamp from data source is in seconds timestamp = Math.floor(Date.now() / 1000), + transactionVersions = [1, 3], cnt = 1, }: { - chainId: constants.StarknetChainId; + chainId: constants.StarknetChainId | string; address: string; contractAddresses?: string[]; txnTypes?: TransactionType[]; @@ -190,29 +220,12 @@ export function generateTransactions({ executionStatuses?: TransactionExecutionStatus[]; timestamp?: number; cnt?: number; + transactionVersions?: number[]; + baseTxnHashInBigInt?: bigint; }): Transaction[] { - const transaction = { - chainId: chainId, - contractAddress: '', - contractCallData: [], - contractFuncName: '', - senderAddress: address, - timestamp: timestamp, - txnHash: '', - txnType: '', - failureReason: '', - status: '', - executionStatus: '', - finalityStatus: '', - eventIds: [], - }; - let accumulatedTimestamp = timestamp; - let accumulatedTxnHash = BigInt( - '0x2a8c2d5d4908a6561de87ecb18a76305c64800e3f81b393b9988de1abd37284', - ); - + let baseTimeStamp = timestamp; let createCnt = cnt; - let filteredTxnTypes = txnTypes; + let _txnTypes = txnTypes; const transactions: Transaction[] = []; // only 1 deploy account transaction to generate @@ -220,82 +233,192 @@ export function generateTransactions({ txnTypes.includes(TransactionType.DEPLOY_ACCOUNT) || txnTypes.includes(TransactionType.DEPLOY) ) { - transactions.push({ - ...transaction, - contractAddress: address, - txnType: TransactionType.DEPLOY_ACCOUNT, - finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, - executionStatus: TransactionExecutionStatus.SUCCEEDED, - timestamp: accumulatedTimestamp, - txnHash: '0x' + accumulatedTxnHash.toString(16), - }); + transactions.push( + generateDeployTransaction({ + address, + txnHash: getTransactionHash(baseTxnHashInBigInt), + timestamp: baseTimeStamp, + version: getRandomData(transactionVersions), + chainId, + }), + ); + createCnt -= 1; - // exclude deploy txnType - filteredTxnTypes = filteredTxnTypes.filter( + + // after generate a deploy transaction, we dont need to re-generate another deploy transaction, + // so we can remove it from the txnTypes, to make sure we only random the types that are not deploy. + _txnTypes = txnTypes.filter( (type) => type !== TransactionType.DEPLOY_ACCOUNT && type !== TransactionType.DEPLOY, ); } - if (filteredTxnTypes.length === 0) { - filteredTxnTypes = [TransactionType.INVOKE]; + for (let i = 1; i <= createCnt; i++) { + // Make sure the timestamp is increasing + baseTimeStamp += i * 100; + // Make sure the txn hash is unique + baseTxnHashInBigInt += BigInt(i * 100); + + const executionStatus = getRandomData(executionStatuses); + const finalityStatus = + executionStatus === TransactionExecutionStatus.REJECTED + ? TransactionFinalityStatus.ACCEPTED_ON_L2 + : getRandomData(finalityStatuses); + const txnType = getRandomData(_txnTypes); + const contractFuncName = + txnType == TransactionType.INVOKE + ? getRandomData(['transfer', 'upgrade']) + : ''; + + transactions.push( + generateInvokeTransaction({ + address, + contractAddress: getRandomData(contractAddresses), + txnHash: getTransactionHash(baseTxnHashInBigInt), + timestamp: baseTimeStamp, + version: getRandomData(transactionVersions), + chainId, + txnType, + finalityStatus, + executionStatus, + contractFuncName, + }), + ); } - for (let i = 1; i <= createCnt; i++) { - const randomContractAddress = - contractAddresses[ - Math.floor(generateRandomValue() * contractAddresses.length) - ]; - const randomTxnType = - filteredTxnTypes[ - Math.floor(generateRandomValue() * filteredTxnTypes.length) - ]; - let randomFinalityStatus = - finalityStatuses[ - Math.floor(generateRandomValue() * finalityStatuses.length) - ]; - let randomExecutionStatus = - executionStatuses[ - Math.floor(generateRandomValue() * executionStatuses.length) - ]; - let randomContractFuncName = ['transfer', 'upgrade'][ - Math.floor(generateRandomValue() * 2) - ]; - accumulatedTimestamp += i * 100; - accumulatedTxnHash += BigInt(i * 100); - - if (randomExecutionStatus === TransactionExecutionStatus.REJECTED) { - if ( - [ - TransactionFinalityStatus.NOT_RECEIVED, - TransactionFinalityStatus.RECEIVED, - TransactionFinalityStatus.ACCEPTED_ON_L1, - ].includes(randomFinalityStatus) - ) { - randomFinalityStatus = TransactionFinalityStatus.ACCEPTED_ON_L2; - } - } + return transactions.sort((a, b) => b.timestamp - a.timestamp); +} - if (randomFinalityStatus === TransactionFinalityStatus.NOT_RECEIVED) { - randomFinalityStatus = TransactionFinalityStatus.ACCEPTED_ON_L2; - randomExecutionStatus = TransactionExecutionStatus.SUCCEEDED; - } +function getTransactionTemplate() { + return { + chainId: constants.StarknetChainId.SN_SEPOLIA, + timestamp: 0, + senderAddress: '', + contractAddress: '', + txnHash: '', + txnType: '', + failureReason: '', + executionStatus: '', + finalityStatus: '', + accountCalls: null, + version: 1, + dataVersion: TransactionDataVersion.V2, + }; +} - transactions.push({ - ...transaction, - contractAddress: randomContractAddress, - txnType: randomTxnType, - finalityStatus: randomFinalityStatus, - executionStatus: randomExecutionStatus, - timestamp: accumulatedTimestamp, - contractFuncName: - randomTxnType === TransactionType.INVOKE ? randomContractFuncName : '', - txnHash: '0x' + accumulatedTxnHash.toString(16), - }); - } +/** + * Method to generate a deploy transaction. + * + * @param params + * @param params.address - The address of the account. + * @param params.txnHash - The transaction hash. + * @param params.timestamp - The timestamp of the transaction. + * @param params.version - The version of the transaction. + * @param params.chainId - The chain id of the transaction. + * @returns A transaction object. + * */ +export function generateDeployTransaction({ + address, + txnHash, + timestamp, + version, + chainId, +}: { + address: string; + txnHash: string; + timestamp: number; + version: number; + chainId: constants.StarknetChainId | string; +}): Transaction { + const transaction = getTransactionTemplate(); - return transactions.sort((a, b) => b.timestamp - a.timestamp); + return { + ...transaction, + chainId: chainId, + txnHash, + senderAddress: address, + contractAddress: address, + txnType: TransactionType.DEPLOY_ACCOUNT, + finalityStatus: TransactionFinalityStatus.ACCEPTED_ON_L1, + executionStatus: TransactionExecutionStatus.SUCCEEDED, + timestamp: timestamp, + version: version, + }; +} + +/** + * Method to generate an invoke transaction. + * + * @param params + * @param params.address - The address of the account. + * @param params.contractAddress - The contract address. + * @param params.txnHash - The transaction hash. + * @param params.timestamp - The timestamp of the transaction. + * @param params.version - The version of the transaction. + * @param params.chainId - The chain id of the transaction. + * @param params.txnType - The type of the transaction. + * @param params.finalityStatus - The finality status of the transaction. + * @param params.executionStatus - The execution status of the transaction. + * @param params.contractFuncName - The contract function name. + * @returns A transaction object. + * */ +export function generateInvokeTransaction({ + address, + contractAddress, + txnHash, + timestamp, + version, + chainId, + txnType, + finalityStatus, + executionStatus, + contractFuncName, +}: { + address: string; + txnHash: string; + contractAddress: string; + timestamp: number; + version: number; + chainId: constants.StarknetChainId | string; + finalityStatus: TransactionFinalityStatus; + executionStatus: TransactionExecutionStatus; + txnType: TransactionType; + contractFuncName: string; +}): Transaction { + const transaction = getTransactionTemplate(); + + return { + ...transaction, + chainId: chainId, + contractAddress: '', + txnType, + finalityStatus, + executionStatus, + timestamp, + txnHash, + senderAddress: address, + accountCalls: { + [contractAddress]: [ + { + contract: contractAddress, + contractFuncName, + contractCallData: [address, getRandomValue(1000).toString(16)], + }, + ], + }, + version: version, + }; +} + +/** + * Method to generate a random transaction hash. + * + * @param base - The base number to generate the transaction hash. + * @returns A transaction hash. + * */ +export function getTransactionHash(base = SixtyThreeHexInBigInt) { + return `0x` + base.toString(16); } export function generateTransactionRequests({ @@ -333,16 +456,10 @@ export function generateTransactionRequests({ signer: address, addressIndex: 0, maxFee: '100', - selectedFeeToken: - selectedFeeTokens[ - Math.floor(generateRandomValue() * selectedFeeTokens.length) - ], + selectedFeeToken: getRandomData(selectedFeeTokens), calls: [ { - contractAddress: - contractAddresses[ - Math.floor(generateRandomValue() * contractAddresses.length) - ], + contractAddress: getRandomData(contractAddresses), calldata: CallData.compile({ to: address, amount: '1', diff --git a/packages/starknet-snap/src/state/__tests__/helper.ts b/packages/starknet-snap/src/state/__tests__/helper.ts index 05044039..0efdc479 100644 --- a/packages/starknet-snap/src/state/__tests__/helper.ts +++ b/packages/starknet-snap/src/state/__tests__/helper.ts @@ -15,6 +15,7 @@ import * as snapHelper from '../../utils/snap'; import { NetworkStateManager } from '../network-state-manager'; import { TransactionRequestStateManager } from '../request-state-manager'; import { TokenStateManager } from '../token-state-manager'; +import { TransactionStateManager } from '../transaction-state-manager'; jest.mock('../../utils/snap'); jest.mock('../../utils/logger'); @@ -74,6 +75,22 @@ export const mockTokenStateManager = () => { }; }; +export const mockTransactionStateManager = () => { + const removeTransactionsSpy = jest.spyOn( + TransactionStateManager.prototype, + 'removeTransactions', + ); + const findTransactionsSpy = jest.spyOn( + TransactionStateManager.prototype, + 'findTransactions', + ); + + return { + removeTransactionsSpy, + findTransactionsSpy, + }; +}; + export const mockTransactionRequestStateManager = () => { const upsertTransactionRequestSpy = jest.spyOn( TransactionRequestStateManager.prototype, diff --git a/packages/starknet-snap/src/state/transaction-state-manager.test.ts b/packages/starknet-snap/src/state/transaction-state-manager.test.ts index b67344b4..bc0c90e9 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.test.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.test.ts @@ -6,6 +6,8 @@ import { } from 'starknet'; import { generateTransactions } from '../__tests__/helper'; +import type { V2Transaction } from '../types/snapState'; +import { TransactionDataVersion } from '../types/snapState'; import { PRELOADED_TOKENS } from '../utils/constants'; import { mockAcccounts, mockState } from './__tests__/helper'; import { StateManagerError } from './state-manager'; @@ -132,20 +134,60 @@ describe('TransactionStateManager', () => { expect(result).toStrictEqual(txns); }); + it('returns the list of transaction by data version', async () => { + const chainId = constants.StarknetChainId.SN_SEPOLIA; + const { + txns: [legacyData, ...newData], + state, + } = await prepareMockData(chainId); + + const legacyTxn = { + txnHash: legacyData.txnHash, + txnType: legacyData.txnType, + chainId: legacyData.chainId, + senderAddress: legacyData.senderAddress, + contractAddress: legacyData.contractAddress, + contractFuncName: 'transfer', + contractCallData: ['0x123', '0x456'], + executionStatus: legacyData.executionStatus, + finalityStatus: legacyData.finalityStatus, + timestamp: legacyData.timestamp, + eventIds: [], + failureReason: legacyData.failureReason, + }; + // simulate the data source return the legacy data and new data + state.transactions = newData.concat([legacyTxn]); + + const stateManager = new TransactionStateManager(); + + const result = await stateManager.findTransactions({ + dataVersion: [TransactionDataVersion.V2], + }); + + expect(result).toStrictEqual(newData); + }); + it('returns the list of transaction by contract address', async () => { const { txns, stateManager } = await prepareFindTransctions(); const tokenAddress1 = PRELOADED_TOKENS.map((token) => token.address)[0]; const tokenAddress2 = PRELOADED_TOKENS.map((token) => token.address)[2]; + const contractAddress = [ + tokenAddress1.toLowerCase(), + tokenAddress2.toLowerCase(), + ]; + const contractAddressSet = new Set(contractAddress); const result = await stateManager.findTransactions({ - contractAddress: [tokenAddress1, tokenAddress2], + contractAddress, }); expect(result).toStrictEqual( txns.filter( - (txn) => - txn.contractAddress === tokenAddress1 || - txn.contractAddress === tokenAddress2, + (txn: V2Transaction) => + txn.accountCalls && + Object.keys(txn.accountCalls).some((contract) => + contractAddressSet.has(contract.toLowerCase()), + ), ), ); }); @@ -212,8 +254,9 @@ describe('TransactionStateManager', () => { TransactionExecutionStatus.REJECTED, ]; const contractAddressCond = [ - PRELOADED_TOKENS.map((token) => token.address)[0], + PRELOADED_TOKENS.map((token) => token.address.toLowerCase())[0], ]; + const contractAddressSet = new Set(contractAddressCond); const timestampCond = txns[5].timestamp * 1000; const chainIdCond = [ txns[0].chainId as unknown as constants.StarknetChainId, @@ -229,7 +272,7 @@ describe('TransactionStateManager', () => { }); expect(result).toStrictEqual( - txns.filter((txn) => { + txns.filter((txn: V2Transaction) => { return ( (finalityStatusCond.includes( txn.finalityStatus as unknown as TransactionFinalityStatus, @@ -238,7 +281,10 @@ describe('TransactionStateManager', () => { txn.executionStatus as unknown as TransactionExecutionStatus, )) && txn.timestamp >= txns[5].timestamp && - contractAddressCond.includes(txn.contractAddress) && + txn.accountCalls && + Object.keys(txn.accountCalls).some((contract) => + contractAddressSet.has(contract.toLowerCase()), + ) && chainIdCond.includes( txn.chainId as unknown as constants.StarknetChainId, ) && diff --git a/packages/starknet-snap/src/state/transaction-state-manager.ts b/packages/starknet-snap/src/state/transaction-state-manager.ts index a4805bb9..0973ce82 100644 --- a/packages/starknet-snap/src/state/transaction-state-manager.ts +++ b/packages/starknet-snap/src/state/transaction-state-manager.ts @@ -5,8 +5,11 @@ import { } from 'starknet'; import { assert, enums, number } from 'superstruct'; -import type { Transaction, SnapState } from '../types/snapState'; -import { TransactionStatusType } from '../types/snapState'; +import type { Transaction, SnapState, V2Transaction } from '../types/snapState'; +import { + TransactionDataVersion, + TransactionStatusType, +} from '../types/snapState'; import type { IFilter } from './filter'; import { BigIntFilter, @@ -23,11 +26,24 @@ export class ChainIdFilter implements ITxFilter {} export class ContractAddressFilter - extends BigIntFilter + extends StringFllter implements ITxFilter { - dataKey = 'contractAddress'; + protected _apply(data: Transaction): boolean { + const txn = data as V2Transaction; + const { accountCalls } = txn; + if (!accountCalls) { + return false; + } + for (const contract in accountCalls) { + if (this.search.has(contract.toLowerCase())) { + return true; + } + } + return false; + } } + export class SenderAddressFilter extends BigIntFilter implements ITxFilter @@ -61,6 +77,13 @@ export class TxnTypeFilter dataKey = 'txnType'; } +export class DataVersionFilter + extends StringFllter + implements ITxFilter +{ + dataKey = 'dataVersion'; +} + // Filter for transaction status // Search for transactions based on the finality status and execution status // It compare the finality status and execution status in OR condition, due to our use case is to find the transactions that fit to the given finality status or the given execution status @@ -112,12 +135,13 @@ export class TxStatusFilter implements ITxFilter { export type SearchFilter = { txnHash?: string[]; txnType?: TransactionType[]; - chainId?: constants.StarknetChainId[]; + chainId?: constants.StarknetChainId[] | string[]; senderAddress?: string[]; contractAddress?: string[]; executionStatus?: TransactionExecutionStatus[]; finalityStatus?: TransactionFinalityStatus[]; timestamp?: number; + dataVersion?: string[]; }; export class TransactionStateManager extends StateManager { @@ -158,10 +182,16 @@ export class TransactionStateManager extends StateManager { executionStatus, finalityStatus, timestamp, + // default return the latest version of the data + dataVersion = [TransactionDataVersion.V2], }: SearchFilter, state?: SnapState, ): Promise { const filters: ITxFilter[] = []; + if (dataVersion !== undefined && dataVersion.length > 0) { + filters.push(new DataVersionFilter(dataVersion)); + } + if (txnHash !== undefined && txnHash.length > 0) { filters.push(new TxHashFilter(txnHash)); } From 46770eebef4e88e3a4280c67ac5fea1be7d0f4d3 Mon Sep 17 00:00:00 2001 From: Stanley Yuen <102275989+stanleyyconsensys@users.noreply.github.com> Date: Fri, 6 Dec 2024 21:13:19 +0800 Subject: [PATCH 6/6] chore: fix permission boundary (#454) * chore: fix permission * fix: lint * chore: lint --- packages/starknet-snap/src/utils/permission.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/starknet-snap/src/utils/permission.ts b/packages/starknet-snap/src/utils/permission.ts index 0a264b1c..79277a60 100644 --- a/packages/starknet-snap/src/utils/permission.ts +++ b/packages/starknet-snap/src/utils/permission.ts @@ -29,6 +29,7 @@ export enum RpcMethod { GetAddressByStarkName = 'starkNet_getAddrFromStarkName', ReadContract = 'starkNet_getValue', GetStoredErc20Tokens = 'starkNet_getStoredErc20Tokens', + Ping = 'ping', } // RpcMethod that are allowed to be called by any origin const publicPermissions = [ @@ -51,6 +52,7 @@ const publicPermissions = [ RpcMethod.GetTransactionStatus, RpcMethod.EstimateFee, RpcMethod.VerifySignedMessage, + RpcMethod.Ping, ]; // RpcMethod that are restricted to be called by wallet UI origins const walletUIDappPermissions = publicPermissions.concat([ @@ -59,6 +61,7 @@ const walletUIDappPermissions = publicPermissions.concat([ RpcMethod.GetTransactions, RpcMethod.UpgradeAccContract, RpcMethod.GetStarkName, + RpcMethod.GetAddressByStarkName, RpcMethod.ReadContract, RpcMethod.GetStoredErc20Tokens, ]);