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