diff --git a/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json b/packages/starknet-snap/openrpc/starknet_snap_api_openrpc.json index c596193b..fa0da237 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/getAddrFromStarkName.ts b/packages/starknet-snap/src/getAddrFromStarkName.ts new file mode 100644 index 00000000..1196ca82 --- /dev/null +++ b/packages/starknet-snap/src/getAddrFromStarkName.ts @@ -0,0 +1,32 @@ +import { toJson } from './utils/serializer'; +import { getAddrFromStarkNameUtil } from '../src/utils/starknetUtils'; +import { ApiParams, GetAddrFromStarkNameRequestParam } from './types/snapApi'; +import { getNetworkFromChainId } from './utils/snapUtils'; +import { logger } from './utils/logger'; + +export async function getAddrFromStarkName(params: ApiParams) { + try { + const { state, requestParams } = params; + const requestParamsObj = requestParams as GetAddrFromStarkNameRequestParam; + + if (!requestParamsObj.starkName) { + throw new Error( + `The given stark name need to be non-empty string, got: ${toJson( + requestParamsObj, + )}`, + ); + } + + const starkName = requestParamsObj.starkName; + + const network = getNetworkFromChainId(state, requestParamsObj.chainId); + + const resp = await getAddrFromStarkNameUtil(network, starkName); + logger.log(`getAddrFromStarkName: addr:\n${toJson(resp)}`); + + return resp; + } catch (err) { + logger.error(`Problem found: ${err}`); + throw err; + } +} diff --git a/packages/starknet-snap/src/index.tsx b/packages/starknet-snap/src/index.tsx index 0d8f4585..24f62d0f 100644 --- a/packages/starknet-snap/src/index.tsx +++ b/packages/starknet-snap/src/index.tsx @@ -19,6 +19,7 @@ import { extractPublicKey } from './extractPublicKey'; import { getCurrentNetwork } from './getCurrentNetwork'; import { getErc20TokenBalance } from './getErc20TokenBalance'; import { getStarkName } from './getStarkName'; +import { getAddrFromStarkName } from './getAddrFromStarkName'; import { getStoredErc20Tokens } from './getStoredErc20Tokens'; import { getStoredNetworks } from './getStoredNetworks'; import { getStoredTransactions } from './getStoredTransactions'; @@ -289,6 +290,9 @@ export const onRpcRequest: OnRpcRequestHandler = async ({ request }) => { apiParams.requestParams as unknown as GetDeploymentDataParams, ); + case 'starkNet_getAddrFromStarkName': + return await getAddrFromStarkName(apiParams); + default: throw new MethodNotFoundError() as unknown as Error; } diff --git a/packages/starknet-snap/src/types/snapApi.ts b/packages/starknet-snap/src/types/snapApi.ts index 639952de..6f0ae061 100644 --- a/packages/starknet-snap/src/types/snapApi.ts +++ b/packages/starknet-snap/src/types/snapApi.ts @@ -183,4 +183,9 @@ export enum FeeTokenUnit { ETH = 'wei', STRK = 'fri', } + +export type GetAddrFromStarkNameRequestParam = { + starkName: string; +} & BaseRequestParams; + /* eslint-enable */ diff --git a/packages/starknet-snap/src/utils/starknetUtils.ts b/packages/starknet-snap/src/utils/starknetUtils.ts index 7c610528..881d89c2 100644 --- a/packages/starknet-snap/src/utils/starknetUtils.ts +++ b/packages/starknet-snap/src/utils/starknetUtils.ts @@ -1367,3 +1367,11 @@ export const validateAccountRequireUpgradeOrDeploy = async ( throw new DeployRequiredError(); } }; + +export const getAddrFromStarkNameUtil = async ( + network: Network, + starkName: string, +) => { + const provider = getProvider(network); + return Account.getAddressFromStarkName(provider, starkName); +}; diff --git a/packages/starknet-snap/test/src/getAddrFromStarkName.test.ts b/packages/starknet-snap/test/src/getAddrFromStarkName.test.ts new file mode 100644 index 00000000..ecfe79c0 --- /dev/null +++ b/packages/starknet-snap/test/src/getAddrFromStarkName.test.ts @@ -0,0 +1,100 @@ +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 { SnapState } from '../../src/types/snapState'; +import { STARKNET_SEPOLIA_TESTNET_NETWORK } from '../../src/utils/constants'; +import { Mutex } from 'async-mutex'; +import { + ApiParams, + GetAddrFromStarkNameRequestParam, +} from '../../src/types/snapApi'; +import { getAddrFromStarkName } from '../../src/getAddrFromStarkName'; + +chai.use(sinonChai); +const sandbox = sinon.createSandbox(); + +describe('Test function: getAddrFromStarkName', function () { + const walletStub = new WalletMock(); + const state: SnapState = { + accContracts: [], + erc20Tokens: [], + networks: [STARKNET_SEPOLIA_TESTNET_NETWORK], + transactions: [], + }; + const apiParams: ApiParams = { + state, + requestParams: {}, + wallet: walletStub, + saveMutex: new Mutex(), + }; + + afterEach(function () { + walletStub.reset(); + sandbox.restore(); + }); + + it('should retrieve the address from stark name successfully', async function () { + sandbox.stub(utils, 'getAddrFromStarkNameUtil').callsFake(async () => { + return '0x01c744953f1d671673f46a9179a58a7e58d9299499b1e076cdb908e7abffe69f'; + }); + const requestObject: GetAddrFromStarkNameRequestParam = { + starkName: 'testName.stark', + }; + apiParams.requestParams = requestObject; + const result = await getAddrFromStarkName(apiParams); + expect(result).to.be.eq( + '0x01c744953f1d671673f46a9179a58a7e58d9299499b1e076cdb908e7abffe69f', + ); + }); + + it('should throw error if getAddrFromStarkNameUtil failed', async function () { + sandbox.stub(utils, 'getAddrFromStarkNameUtil').throws(new Error()); + const requestObject: GetAddrFromStarkNameRequestParam = { + starkName: 'testName.stark', + }; + apiParams.requestParams = requestObject; + + let result; + try { + await getAddrFromStarkName(apiParams); + } catch (err) { + result = err; + } finally { + expect(result).to.be.an('Error'); + } + }); + + it('should throw error if the stark name is empty', async function () { + const requestObject: GetAddrFromStarkNameRequestParam = { + starkName: '', + }; + apiParams.requestParams = requestObject; + + let result; + try { + await getAddrFromStarkName(apiParams); + } catch (err) { + result = err; + } finally { + expect(result).to.be.an('Error'); + } + }); + + it('should throw error if the user address is invalid', async function () { + const requestObject: GetAddrFromStarkNameRequestParam = { + starkName: 'invalidName', + }; + apiParams.requestParams = requestObject; + + let result; + try { + await getAddrFromStarkName(apiParams); + } catch (err) { + result = err; + } finally { + expect(result).to.be.an('Error'); + } + }); +}); 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..220e7a59 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 @@ -6,11 +6,17 @@ import { Dispatch, SetStateAction, } from 'react'; -import { isSpecialInputKey, isValidAddress } from 'utils/utils'; +import { + isSpecialInputKey, + isValidAddress, + isValidStarkName, + shortenAddress, +} from 'utils/utils'; import { HelperText } from 'components/ui/atom/HelperText'; import { Label } from 'components/ui/atom/Label'; import { Icon, + InfoText, Input, InputContainer, Left, @@ -18,10 +24,13 @@ import { Wrapper, } from './AddressInput.style'; import { STARKNET_ADDRESS_LENGTH } from 'utils/constants'; +import { useStarkNetSnap } from 'services'; +import { useAppSelector } from 'hooks/redux'; interface Props extends InputHTMLAttributes { label?: string; setIsValidAddress?: Dispatch>; + onResolvedAddress?: (address: string) => void; } export const AddressInputView = ({ @@ -29,12 +38,17 @@ export const AddressInputView = ({ onChange, label, setIsValidAddress, + onResolvedAddress, ...otherProps }: Props) => { + const networks = useAppSelector((state) => state.networks); + const chainId = networks?.items[networks.activeNetwork]?.chainId; + const { getAddrFromStarkName } = useStarkNetSnap(); const [focused, setFocused] = useState(false); const inputRef = useRef(null); const [error, setError] = useState(''); const [valid, setValid] = useState(false); + const [info, setInfo] = useState(''); const displayIcon = () => { return valid || error !== ''; @@ -62,9 +76,27 @@ export const AddressInputView = ({ if (isValid) { setValid(true); setError(''); + onResolvedAddress?.(inputRef.current.value); + } else if (isValidStarkName(inputRef.current.value)) { + setValid(false); + setError(''); + + getAddrFromStarkName(inputRef.current.value, chainId).then((address) => { + if (isValidAddress(address)) { + setValid(true); + setError(''); + setInfo(shortenAddress(address as string, 12) as string); + onResolvedAddress?.(address as string); + } else { + setValid(false); + setError('.stark name not found'); + setInfo(''); + } + }); } else { setValid(false); setError('Invalid address format'); + setInfo(''); } if (setIsValidAddress) { @@ -106,6 +138,7 @@ export const AddressInputView = ({ {error && {error}} + {info && {info}} ); }; 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..8b839e6b 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 @@ -15,11 +15,12 @@ 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 } 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'; interface Props { closeModal?: () => void; @@ -27,7 +28,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 +42,7 @@ export const SendModalView = ({ closeModal }: Props) => { feeToken: DEFAULT_FEE_TOKEN, // Default fee token }); const [errors, setErrors] = useState({ amount: '', address: '' }); + const [resolvedAddress, setResolvedAddress] = useState(''); const handleChange = (fieldName: string, fieldValue: string) => { //Check if input amount does not exceed user balance @@ -64,7 +68,20 @@ export const SendModalView = ({ closeModal }: Props) => { break; case 'address': if (fieldValue !== '') { - if (!isValidAddress(fieldValue)) { + if (isValidAddress(fieldValue)) { + break; + } else if (isValidStarkName(fieldValue)) { + getAddrFromStarkName(fieldValue, chainId).then((address) => { + if (isValidAddress(address)) { + setResolvedAddress(address); + } else { + setErrors((prevErrors) => ({ + ...prevErrors, + address: '.stark name doesn’t exist', + })); + } + }); + } else { setErrors((prevErrors) => ({ ...prevErrors, address: 'Invalid address format', @@ -108,8 +125,9 @@ export const SendModalView = ({ closeModal }: Props) => { handleChange('address', value.target.value)} + onResolvedAddress={(address) => setResolvedAddress(address)} /> { {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, + ); +};