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/3] 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/3] 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/3] 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, + ); +};