From ed916e1bf7caec798d6a3f875a3a3ba6b5c0a7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 24 Jan 2025 12:29:07 -0800 Subject: [PATCH 01/29] First cut on data flow --- @shared/api/external.ts | 22 +++ @shared/api/internal.ts | 10 + @shared/api/types.ts | 9 +- @shared/constants/services.ts | 2 + @stellar/freighter-api/src/addToken.ts | 23 +++ @stellar/freighter-api/src/index.ts | 3 + README.md | 2 + docs/docs/playground/addToken.mdx | 11 ++ .../playground/components/AddTokenDemo.tsx | 56 ++++++ .../components/SignTransactionDemo.tsx | 2 +- docs/sidebars.js | 1 + .../freighterApiMessageListener.ts | 123 ++++++++++-- .../messageListener/popupMessageListener.ts | 88 ++++++--- extension/src/helpers/urls.ts | 17 +- extension/src/popup/Router.tsx | 4 + .../manageAssets/AddAsset/index.tsx | 148 ++------------- .../ManageAssetRowButton/index.tsx | 120 ++---------- extension/src/popup/constants/metricsNames.ts | 7 + extension/src/popup/constants/routes.ts | 1 + extension/src/popup/ducks/access.ts | 8 + .../src/popup/helpers/useChangeTrustline.ts | 151 +++++++++++++++ .../src/popup/helpers/useSetupAddTokenFlow.ts | 92 +++++++++ extension/src/popup/helpers/useTokenLookup.ts | 166 +++++++++++++++++ extension/src/popup/metrics/access.ts | 13 ++ extension/src/popup/metrics/views.ts | 9 + extension/src/popup/views/AddToken/index.tsx | 176 ++++++++++++++++++ 26 files changed, 980 insertions(+), 284 deletions(-) create mode 100644 @stellar/freighter-api/src/addToken.ts create mode 100644 docs/docs/playground/addToken.mdx create mode 100644 docs/docs/playground/components/AddTokenDemo.tsx create mode 100644 extension/src/popup/helpers/useChangeTrustline.ts create mode 100644 extension/src/popup/helpers/useSetupAddTokenFlow.ts create mode 100644 extension/src/popup/helpers/useTokenLookup.ts create mode 100644 extension/src/popup/views/AddToken/index.tsx diff --git a/@shared/api/external.ts b/@shared/api/external.ts index ace3fe5b60..c8a32c77b4 100644 --- a/@shared/api/external.ts +++ b/@shared/api/external.ts @@ -40,6 +40,28 @@ export const requestPublicKey = async (): Promise<{ return { publicKey: response?.publicKey || "", error: response?.apiError }; }; +export const submitToken = async (args: { + contractId: string; + networkPassphrase?: string; +}): Promise<{ + error?: FreighterApiError; +}> => { + let response; + try { + response = await sendMessageToContentScript({ + contractId: args.contractId, + networkPassphrase: args.networkPassphrase, + type: EXTERNAL_SERVICE_TYPES.SUBMIT_TOKEN, + }); + } catch (e) { + return { + error: FreighterApiInternalError, + }; + } + + return { error: response?.apiError }; +}; + export const submitTransaction = async ( transactionXdr: string, opts?: diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 174a030a7f..ea35c60418 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -957,6 +957,16 @@ export const handleSignedHwPayload = async ({ } }; +export const addToken = async (): Promise => { + try { + await sendMessageToBackground({ + type: SERVICE_TYPES.ADD_TOKEN, + }); + } catch (e) { + console.error(e); + } +}; + export const signTransaction = async (): Promise => { try { await sendMessageToBackground({ diff --git a/@shared/api/types.ts b/@shared/api/types.ts index 03ddbe1dd6..ef33e26c67 100644 --- a/@shared/api/types.ts +++ b/@shared/api/types.ts @@ -103,15 +103,19 @@ export interface MemoRequiredAccount { } export interface ExternalRequestBase { - network: string; - networkPassphrase: string; accountToSign?: string; address?: string; + networkPassphrase?: string; type: EXTERNAL_SERVICE_TYPES; } +export interface ExternalRequestToken extends ExternalRequestBase { + contractId: string; +} + export interface ExternalRequestTx extends ExternalRequestBase { transactionXdr: string; + network?: string; } export interface ExternalRequestBlob extends ExternalRequestBase { @@ -124,6 +128,7 @@ export interface ExternalRequestAuthEntry extends ExternalRequestBase { } export type ExternalRequest = + | ExternalRequestToken | ExternalRequestTx | ExternalRequestBlob | ExternalRequestAuthEntry; diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 3a8e1f0aa6..ea5119ad1e 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -14,6 +14,7 @@ export enum SERVICE_TYPES { CONFIRM_PASSWORD = "CONFIRM_PASSWORD", REJECT_ACCESS = "REJECT_ACCESS", GRANT_ACCESS = "GRANT_ACCESS", + ADD_TOKEN = "ADD_TOKEN", SIGN_TRANSACTION = "SIGN_TRANSACTION", SIGN_BLOB = "SIGN_BLOB", SIGN_AUTH_ENTRY = "SIGN_AUTH_ENTRY", @@ -53,6 +54,7 @@ export enum SERVICE_TYPES { export enum EXTERNAL_SERVICE_TYPES { REQUEST_ACCESS = "REQUEST_ACCESS", REQUEST_PUBLIC_KEY = "REQUEST_PUBLIC_KEY", + SUBMIT_TOKEN = "SUBMIT_TOKEN", SUBMIT_TRANSACTION = "SUBMIT_TRANSACTION", SUBMIT_BLOB = "SUBMIT_BLOB", SUBMIT_AUTH_ENTRY = "SUBMIT_AUTH_ENTRY", diff --git a/@stellar/freighter-api/src/addToken.ts b/@stellar/freighter-api/src/addToken.ts new file mode 100644 index 0000000000..3085d69145 --- /dev/null +++ b/@stellar/freighter-api/src/addToken.ts @@ -0,0 +1,23 @@ +import { submitToken } from "@shared/api/external"; +import { FreighterApiError } from "@shared/api/types"; +import { FreighterApiNodeError } from "@shared/api/helpers/extensionMessaging"; +import { isBrowser } from "."; + +export const addToken = async (args: { + contractId: string; + networkPassphrase?: string; +}): Promise<{ + error?: FreighterApiError; +}> => { + if (isBrowser) { + const req = await submitToken(args); + + if (req.error) { + return { error: req.error }; + } + + return {}; + } + + return { error: FreighterApiNodeError }; +}; diff --git a/@stellar/freighter-api/src/index.ts b/@stellar/freighter-api/src/index.ts index e9aebeda34..558095d190 100644 --- a/@stellar/freighter-api/src/index.ts +++ b/@stellar/freighter-api/src/index.ts @@ -1,4 +1,5 @@ import { getAddress } from "./getAddress"; +import { addToken } from "./addToken"; import { signTransaction } from "./signTransaction"; import { signMessage } from "./signMessage"; import { signAuthEntry } from "./signAuthEntry"; @@ -14,6 +15,7 @@ export const isBrowser = typeof window !== "undefined"; export { getAddress, + addToken, signTransaction, signMessage, signAuthEntry, @@ -27,6 +29,7 @@ export { }; export default { getAddress, + addToken, signTransaction, signMessage, signAuthEntry, diff --git a/README.md b/README.md index f85231e461..b8d05dd5e6 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ That should launch your project in xcode. You should run the project, with a tar [The `signTransaction` playground](http://localhost:3000/docs/playground/signTransaction) +[The `addToken` playground](http://localhost:3000/docs/playground/addToken) + It's important to note that these last functions won't interact with the _dev server_ popup UI on `localhost:9000` — you'll need to re-install the unpacked extension each time you make a change. diff --git a/docs/docs/playground/addToken.mdx b/docs/docs/playground/addToken.mdx new file mode 100644 index 0000000000..da6d623168 --- /dev/null +++ b/docs/docs/playground/addToken.mdx @@ -0,0 +1,11 @@ +--- +id: addToken +title: addToken +--- + +#### `addToken({ contractId: string, networkPassphrase?: string }) -> >` + +import { AddTokenDemo } from "./components/AddTokenDemo"; + +Test Freighter's `addToken` method: + diff --git a/docs/docs/playground/components/AddTokenDemo.tsx b/docs/docs/playground/components/AddTokenDemo.tsx new file mode 100644 index 0000000000..6b8d968466 --- /dev/null +++ b/docs/docs/playground/components/AddTokenDemo.tsx @@ -0,0 +1,56 @@ +import { addToken } from "@stellar/freighter-api"; +import React, { useState } from "react"; +import { PlaygroundInput, PlaygroundTextarea } from "./basics/inputs"; + +export const AddTokenDemo = () => { + const [contractId, setContractId] = useState(""); + const [networkPassphrase, setNetworkPassphrase] = useState(""); + const [result, setResult] = useState(""); + + const contractIdOnChangeHandler = ( + e: React.ChangeEvent + ) => { + setContractId(e.currentTarget.value); + }; + + const networkPassphraseOnChangeHandler = ( + e: React.ChangeEvent + ) => { + setNetworkPassphrase(e.currentTarget.value); + }; + + const btnHandler = async () => { + let tokenResult; + + tokenResult = await addToken({ + contractId, + networkPassphrase, + }); + + if (tokenResult.error) { + setResult(JSON.stringify(tokenResult.error)); + } else { + setResult("Token info successfully sent."); + } + }; + + return ( +
+
+ Enter Token's Contract Id: + +
+
+ Enter network passphrase: + +
+
+ Result: + +
+ +
+ ); +}; diff --git a/docs/docs/playground/components/SignTransactionDemo.tsx b/docs/docs/playground/components/SignTransactionDemo.tsx index a43344656d..935a277b82 100644 --- a/docs/docs/playground/components/SignTransactionDemo.tsx +++ b/docs/docs/playground/components/SignTransactionDemo.tsx @@ -1,5 +1,5 @@ -import React, { useState } from "react"; import { signTransaction } from "@stellar/freighter-api"; +import React, { useState } from "react"; import { PlaygroundInput, PlaygroundTextarea } from "./basics/inputs"; export const SignTransactionDemo = () => { diff --git a/docs/sidebars.js b/docs/sidebars.js index a6094bfe52..29d8d1b384 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -7,6 +7,7 @@ const playgroundPaths = [ "getAddress", "getNetwork", "getNetworkDetails", + "addToken", "signTransaction", "signAuthEntry", "signMessage", diff --git a/extension/src/background/messageListener/freighterApiMessageListener.ts b/extension/src/background/messageListener/freighterApiMessageListener.ts index 9e71feec4c..b31979ada5 100644 --- a/extension/src/background/messageListener/freighterApiMessageListener.ts +++ b/extension/src/background/messageListener/freighterApiMessageListener.ts @@ -1,13 +1,13 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ - -import * as StellarSdk from "stellar-sdk"; -import browser from "webextension-polyfill"; import { Store } from "redux"; import semver from "semver"; +import * as StellarSdk from "stellar-sdk"; +import browser from "webextension-polyfill"; import { ExternalRequestAuthEntry, ExternalRequestBlob, + ExternalRequestToken, ExternalRequestTx, ExternalRequest as Request, } from "@shared/api/types"; @@ -16,22 +16,15 @@ import { FreighterApiInternalError, FreighterApiDeclinedError, } from "@shared/api/helpers/extensionMessaging"; -import { MessageResponder } from "background/types"; -import { FlaggedKeys, TransactionInfo } from "types/transactions"; - import { EXTERNAL_SERVICE_TYPES } from "@shared/constants/services"; import { MAINNET_NETWORK_DETAILS, NetworkDetails, } from "@shared/constants/stellar"; -import { STELLAR_EXPERT_MEMO_REQUIRED_ACCOUNTS_URL } from "background/constants/apiUrls"; -import { POPUP_HEIGHT, POPUP_WIDTH } from "constants/dimensions"; -import { - ALLOWLIST_ID, - CACHED_MEMO_REQUIRED_ACCOUNTS_ID, -} from "constants/localStorageTypes"; -import { TRANSACTION_WARNING } from "constants/transaction"; +import { getSdk } from "@shared/helpers/stellar"; +import { MessageResponder } from "background/types"; +import { STELLAR_EXPERT_MEMO_REQUIRED_ACCOUNTS_URL } from "background/constants/apiUrls"; import { getIsMainnet, getIsMemoValidationEnabled, @@ -39,18 +32,35 @@ import { } from "background/helpers/account"; import { isSenderAllowed } from "background/helpers/allowListAuthorization"; import { cachedFetch } from "background/helpers/cachedFetch"; -import { encodeObject, getUrlHostname, getPunycodedDomain } from "helpers/urls"; import { dataStorageAccess, browserLocalStorage, } from "background/helpers/dataStorageAccess"; import { publicKeySelector } from "background/ducks/session"; -import { getSdk } from "@shared/helpers/stellar"; + +import { POPUP_HEIGHT, POPUP_WIDTH } from "constants/dimensions"; +import { + ALLOWLIST_ID, + CACHED_MEMO_REQUIRED_ACCOUNTS_ID, +} from "constants/localStorageTypes"; +import { TRANSACTION_WARNING } from "constants/transaction"; + +import { + encodeObject, + getUrlHostname, + getPunycodedDomain, + TokenToAdd, + MessageToSign, + EntryToSign, +} from "helpers/urls"; + +import { FlaggedKeys, TransactionInfo } from "types/transactions"; import { authEntryQueue, blobQueue, responseQueue, + tokenQueue, transactionQueue, } from "./popupMessageListener"; @@ -130,6 +140,80 @@ export const freighterApiMessageListener = ( } }; + const submitToken = async () => { + try { + const { contractId, networkPassphrase } = request as ExternalRequestToken; + + const { tab, url: tabUrl = "" } = sender; + const domain = getUrlHostname(tabUrl); + const punycodedDomain = getPunycodedDomain(domain); + + const allowListStr = (await localStore.getItem(ALLOWLIST_ID)) || ""; + const allowList = allowListStr.split(","); + const isDomainListedAllowed = await isSenderAllowed({ sender }); + + const tokenInfo: TokenToAdd = { + isDomainListedAllowed, + domain, + tab, + url: tabUrl, + contractId, + networkPassphrase, + }; + + tokenQueue.push(tokenInfo); + const encodedTokenInfo = encodeObject(tokenInfo); + + const popup = browser.windows.create({ + url: chrome.runtime.getURL( + `/index.html#/add-token?${encodedTokenInfo}`, + ), + ...WINDOW_SETTINGS, + }); + + return new Promise((resolve) => { + if (!popup) { + resolve({ + // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface + apiError: FreighterApiInternalError, + error: FreighterApiInternalError.message, + }); + } else { + browser.windows.onRemoved.addListener(() => + resolve({ + // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface + apiError: FreighterApiDeclinedError, + error: FreighterApiDeclinedError.message, + }), + ); + } + const response = (success: boolean) => { + if (success) { + if (!isDomainListedAllowed) { + allowList.push(punycodedDomain); + localStore.setItem(ALLOWLIST_ID, allowList.join()); + } + resolve({}); + } + + resolve({ + // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface + apiError: FreighterApiDeclinedError, + error: FreighterApiDeclinedError.message, + }); + }; + + responseQueue.push(response); + }); + } catch (e) { + return { + // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface + apiError: FreighterApiInternalError, + error: FreighterApiInternalError.message, + }; + } + }; + const submitTransaction = async () => { try { const { @@ -210,7 +294,7 @@ export const freighterApiMessageListener = ( }); } - const server = stellarSdkServer(networkUrl, networkPassphrase); + const server = stellarSdkServer(networkUrl, networkPassphrase || ""); try { await server.checkMemoRequired(transaction as StellarSdk.Transaction); @@ -280,7 +364,6 @@ export const freighterApiMessageListener = ( } catch (e) { return { // return 2 error formats: one for clients running older versions of freighter-api, and one to adhere to the standard wallet interface - apiError: FreighterApiInternalError, error: FreighterApiInternalError.message, }; @@ -300,7 +383,7 @@ export const freighterApiMessageListener = ( const allowList = allowListStr.split(","); const isDomainListedAllowed = await isSenderAllowed({ sender }); - const blobData = { + const blobData: MessageToSign = { isDomainListedAllowed, domain, tab, @@ -382,7 +465,8 @@ export const freighterApiMessageListener = ( const allowList = allowListStr.split(","); const isDomainListedAllowed = await isSenderAllowed({ sender }); - const authEntry = { + const authEntry: EntryToSign = { + isDomainListedAllowed, entry: entryXdr, accountToSign: accountToSign || address, tab, @@ -551,6 +635,7 @@ export const freighterApiMessageListener = ( const messageResponder: MessageResponder = { [EXTERNAL_SERVICE_TYPES.REQUEST_ACCESS]: requestAccess, [EXTERNAL_SERVICE_TYPES.REQUEST_PUBLIC_KEY]: requestPublicKey, + [EXTERNAL_SERVICE_TYPES.SUBMIT_TOKEN]: submitToken, [EXTERNAL_SERVICE_TYPES.SUBMIT_TRANSACTION]: submitTransaction, [EXTERNAL_SERVICE_TYPES.SUBMIT_BLOB]: submitBlob, [EXTERNAL_SERVICE_TYPES.SUBMIT_AUTH_ENTRY]: submitAuthEntry, diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index 34b2f5ce8b..050752355a 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ - +import { BigNumber } from "bignumber.js"; import { Store } from "redux"; import * as StellarSdk from "stellar-sdk"; +// @ts-ignore +import { fromMnemonic, generateMnemonic } from "stellar-hd-wallet"; import { KeyManager, BrowserStorageKeyStore, @@ -9,10 +11,6 @@ import { KeyType, } from "@stellar/typescript-wallet-sdk-km"; import { BrowserStorageConfigParams } from "@stellar/typescript-wallet-sdk-km/lib/Plugins/BrowserStorageFacade"; -import browser from "webextension-polyfill"; -// @ts-ignore -import { fromMnemonic, generateMnemonic } from "stellar-hd-wallet"; -import { BigNumber } from "bignumber.js"; import { SERVICE_TYPES } from "@shared/constants/services"; import { APPLICATION_STATE } from "@shared/constants/applicationState"; @@ -62,7 +60,13 @@ import { } from "@shared/constants/stellar"; import { EXPERIMENTAL } from "constants/featureFlag"; -import { getPunycodedDomain, getUrlHostname } from "helpers/urls"; +import { + EntryToSign, + getPunycodedDomain, + getUrlHostname, + MessageToSign, + TokenToAdd, +} from "helpers/urls"; import { addAccountName, getAccountNameList, @@ -129,24 +133,14 @@ const sessionTimer = new SessionTimer(); export const responseQueue: Array< (message?: any, messageAddress?: any) => void > = []; + export const transactionQueue: StellarSdk.Transaction[] = []; -export const blobQueue: { - isDomainListedAllowed: boolean; - domain: string; - tab: browser.Tabs.Tab | undefined; - message: string; - url: string; - accountToSign?: string; - address?: string; -}[] = []; - -export const authEntryQueue: { - accountToSign?: string; - address?: string; - tab: browser.Tabs.Tab | undefined; - entry: string; // xdr.SorobanAuthorizationEntry - url: string; -}[] = []; + +export const tokenQueue: TokenToAdd[] = []; + +export const blobQueue: MessageToSign[] = []; + +export const authEntryQueue: EntryToSign[] = []; interface KeyPair { publicKey: string; @@ -1071,6 +1065,34 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { return { error: "Session timed out" }; }; + const addToken = async () => { + const privateKey = privateKeySelector(sessionStore.getState()); + const networkDetails = await getNetworkDetails(); + + const Sdk = getSdk(networkDetails.networkPassphrase); + + if (privateKey.length) { + const sourceKeys = Sdk.Keypair.fromSecret(privateKey); + const tokenInfo = tokenQueue.pop(); + + const response = await addTokenWithContractId({ + contractId: tokenInfo?.contractId || "", + network: networkDetails.network, + publicKey: sourceKeys.publicKey(), + }); + + const tokenResponse = responseQueue.pop(); + + if (typeof tokenResponse === "function") { + // We're only interested here if it was a success or not + tokenResponse(!response.error); + return {}; + } + } + + return { error: "Session timed out" }; + }; + const signTransaction = async () => { const privateKey = privateKeySelector(sessionStore.getState()); const networkDetails = await getNetworkDetails(); @@ -1113,8 +1135,8 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { if (privateKey.length) { const sourceKeys = Sdk.Keypair.fromSecret(privateKey); - const blob = blobQueue.pop(); + const response = blob ? sourceKeys.sign(Buffer.from(blob.message, "base64")) : null; @@ -1411,6 +1433,23 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { const addTokenId = async () => { const { tokenId, network, publicKey } = request; + + const response = await addTokenWithContractId({ + contractId: tokenId, + network, + publicKey, + }); + + return response; + }; + + const addTokenWithContractId = async (args: { + contractId: string; + network: string; + publicKey: string; + }) => { + const { contractId: tokenId, network, publicKey } = args; + const tokenIdsByNetwork = (await localStore.getItem(TOKEN_ID_LIST)) || {}; const tokenIdList = tokenIdsByNetwork[network] || {}; const keyId = (await localStore.getItem(KEY_ID)) || ""; @@ -1787,6 +1826,7 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { [SERVICE_TYPES.CONFIRM_PASSWORD]: confirmPassword, [SERVICE_TYPES.GRANT_ACCESS]: grantAccess, [SERVICE_TYPES.REJECT_ACCESS]: rejectAccess, + [SERVICE_TYPES.ADD_TOKEN]: addToken, [SERVICE_TYPES.SIGN_TRANSACTION]: signTransaction, [SERVICE_TYPES.SIGN_BLOB]: signBlob, [SERVICE_TYPES.SIGN_AUTH_ENTRY]: signAuthEntry, diff --git a/extension/src/helpers/urls.ts b/extension/src/helpers/urls.ts index 01fc1de807..64b4b1fcd6 100644 --- a/extension/src/helpers/urls.ts +++ b/extension/src/helpers/urls.ts @@ -2,13 +2,22 @@ import punycode from "punycode"; import browser from "webextension-polyfill"; import { TransactionInfo } from "../types/transactions"; +export interface TokenToAdd { + isDomainListedAllowed: boolean; + domain: string; + tab?: browser.Tabs.Tab; + url: string; + contractId: string; + networkPassphrase?: string; +} + export interface MessageToSign { isDomainListedAllowed: boolean; domain: string; tab?: browser.Tabs.Tab; message: string; url: string; - accountToSign: string; + accountToSign?: string; networkPassphrase?: string; } @@ -16,9 +25,9 @@ export interface EntryToSign { isDomainListedAllowed: boolean; domain: string; tab?: browser.Tabs.Tab; - entry: string; + entry: string; // xdr.SorobanAuthorizationEntry url: string; - accountToSign: string; + accountToSign?: string; networkPassphrase?: string; } @@ -35,7 +44,7 @@ export const removeQueryParam = (url = "") => url.replace(/\?(.*)/, ""); export const parsedSearchParam = ( param: string, -): TransactionInfo | MessageToSign | EntryToSign => { +): TransactionInfo | TokenToAdd | MessageToSign | EntryToSign => { const decodedSearchParam = decodeString(param.replace("?", "")); return decodedSearchParam ? JSON.parse(decodedSearchParam) : {}; }; diff --git a/extension/src/popup/Router.tsx b/extension/src/popup/Router.tsx index 3162d493b9..98085f0d61 100644 --- a/extension/src/popup/Router.tsx +++ b/extension/src/popup/Router.tsx @@ -47,6 +47,7 @@ import { GrantAccess } from "popup/views/GrantAccess"; import { MnemonicPhrase } from "popup/views/MnemonicPhrase"; import { FullscreenSuccessMessage } from "popup/views/FullscreenSuccessMessage"; import { RecoverAccount } from "popup/views/RecoverAccount"; +import { AddToken } from "popup/views/AddToken"; import { SignTransaction } from "popup/views/SignTransaction"; import { SignAuthEntry } from "popup/views/SignAuthEntry"; import { UnlockAccount } from "popup/views/UnlockAccount"; @@ -319,6 +320,9 @@ const Outlet = () => { + + + diff --git a/extension/src/popup/components/manageAssets/AddAsset/index.tsx b/extension/src/popup/components/manageAssets/AddAsset/index.tsx index 3f5edad3ce..875c5ba795 100644 --- a/extension/src/popup/components/manageAssets/AddAsset/index.tsx +++ b/extension/src/popup/components/manageAssets/AddAsset/index.tsx @@ -1,41 +1,26 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ -import React, { useEffect, useCallback, useRef, useState } from "react"; -import { useSelector } from "react-redux"; -import { Networks, StellarToml, StrKey } from "stellar-sdk"; -import { captureException } from "@sentry/browser"; import { Formik, Form, Field, FieldProps } from "formik"; import debounce from "lodash/debounce"; +import React, { useEffect, useCallback, useRef, useState } from "react"; +import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; -import { getTokenDetails } from "@shared/api/internal"; +import { Networks, StellarToml, StrKey } from "stellar-sdk"; + import { stellarSdkServer } from "@shared/api/helpers/stellarSdkServer"; -import { isSacContractExecutable } from "@shared/helpers/soroban/token"; import { FormRows } from "popup/basics/Forms"; - -import { publicKeySelector } from "popup/ducks/accountServices"; -import { - settingsNetworkDetailsSelector, - settingsSelector, -} from "popup/ducks/settings"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { isMainnet, isTestnet } from "helpers/stellar"; -import { - getVerifiedTokens, - getNativeContractDetails, - VerifiedTokenRecord, -} from "popup/helpers/searchAsset"; import { isContractId } from "popup/helpers/soroban"; -import { - isAssetSuspicious, - scanAsset, - scanAssetBulk, -} from "popup/helpers/blockaid"; - +import { isAssetSuspicious, scanAssetBulk } from "popup/helpers/blockaid"; +import { useTokenLookup } from "popup/helpers/useTokenLookup"; import { AssetNotifcation } from "popup/components/AssetNotification"; import { SubviewHeader } from "popup/components/SubviewHeader"; import { View } from "popup/basics/layout/View"; import { ManageAssetRows, ManageAssetCurrency } from "../ManageAssetRows"; import { SearchInput, SearchCopy, SearchResults } from "../AssetResults"; + import "./styles.scss"; interface FormValues { @@ -53,7 +38,6 @@ interface AssetDomainToml { export const AddAsset = () => { const { t } = useTranslation(); - const publicKey = useSelector(publicKeySelector); const networkDetails = useSelector(settingsNetworkDetailsSelector); const [assetRows, setAssetRows] = useState([] as ManageAssetCurrency[]); const [isSearching, setIsSearching] = useState(false); @@ -62,120 +46,18 @@ export const AddAsset = () => { const [isVerificationInfoShowing, setIsVerificationInfoShowing] = useState(false); const [verifiedLists, setVerifiedLists] = useState([] as string[]); - const { assetsLists } = useSelector(settingsSelector); const ResultsRef = useRef(null); const isAllowListVerificationEnabled = isMainnet(networkDetails) || isTestnet(networkDetails); - const handleTokenLookup = async (contractId: string) => { - // clear the UI while we work through the flow - setIsSearching(true); - setIsVerifiedToken(false); - setIsVerificationInfoShowing(false); - setAssetRows([]); - - const nativeContractDetails = getNativeContractDetails(networkDetails); - let verifiedTokens = [] as VerifiedTokenRecord[]; - - // step around verification for native contract and unverifiable networks - - if (nativeContractDetails.contract === contractId) { - // override our rules for verification for XLM - setIsVerificationInfoShowing(false); - setAssetRows([ - { - code: nativeContractDetails.code, - issuer: contractId, - domain: nativeContractDetails.domain, - }, - ]); - setIsSearching(false); - return; - } - - const tokenLookup = async () => { - // lookup contract - setIsVerifiedToken(false); - let tokenDetailsResponse; - - try { - tokenDetailsResponse = await getTokenDetails({ - contractId, - publicKey, - networkDetails, - }); - } catch (e) { - setAssetRows([]); - } - - const isSacContract = await isSacContractExecutable( - contractId, - networkDetails, - ); - - if (!tokenDetailsResponse) { - setAssetRows([]); - } else { - const issuer = isSacContract - ? tokenDetailsResponse.name.split(":")[1] || "" - : contractId; // get the issuer name, if applicable , - const scannedAsset = await scanAsset( - `${tokenDetailsResponse.symbol}-${issuer}`, - networkDetails, - ); - setAssetRows([ - { - code: tokenDetailsResponse.symbol, - contract: contractId, - issuer, - domain: "", - name: tokenDetailsResponse.name, - isSuspicious: isAssetSuspicious(scannedAsset), - }, - ]); - } - }; - - if (isAllowListVerificationEnabled) { - // usual binary case of a token being verified or unverified - verifiedTokens = await getVerifiedTokens({ - networkDetails, - contractId, - assetsLists, - }); - - try { - if (verifiedTokens.length) { - setIsVerifiedToken(true); - setVerifiedLists(verifiedTokens[0].verifiedLists); - setAssetRows( - verifiedTokens.map((record: VerifiedTokenRecord) => ({ - code: record.code || record.contract, - issuer: record.issuer || record.contract, - image: record.icon, - domain: record.domain, - contract: record.contract, - })), - ); - } else { - // token not found on asset list, look up the details manually - await tokenLookup(); - } - } catch (e) { - setAssetRows([]); - captureException( - `Failed to fetch token details - ${JSON.stringify(e)}`, - ); - console.error(e); - } - } else { - // Futurenet token lookup - await tokenLookup(); - } - setIsSearching(false); - setIsVerificationInfoShowing(isAllowListVerificationEnabled); - }; + const { handleTokenLookup } = useTokenLookup({ + setAssetRows, + setIsSearching, + setIsVerifiedToken, + setIsVerificationInfoShowing, + setVerifiedLists, + }); const handleIssuerLookup = async (issuer: string) => { let assetDomainToml = {} as AssetDomainToml; diff --git a/extension/src/popup/components/manageAssets/ManageAssetRowButton/index.tsx b/extension/src/popup/components/manageAssets/ManageAssetRowButton/index.tsx index fca25481f5..1c9372f617 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRowButton/index.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRowButton/index.tsx @@ -1,37 +1,28 @@ +import { stellarSdkServer } from "@shared/api/helpers/stellarSdkServer"; +import { NETWORKS } from "@shared/constants/stellar"; +import { Button, Icon, CopyText } from "@stellar/design-system"; import React, { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Networks, StrKey } from "stellar-sdk"; import { useDispatch, useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; -import { Button, Icon, CopyText } from "@stellar/design-system"; import { AppDispatch } from "popup/App"; import { navigateTo } from "popup/helpers/navigate"; -import { stellarSdkServer } from "@shared/api/helpers/stellarSdkServer"; -import { emitMetric } from "helpers/metrics"; + import { getCanonicalFromAsset } from "helpers/stellar"; -import { getManageAssetXDR } from "popup/helpers/getManageAssetXDR"; import { checkForSuspiciousAsset } from "popup/helpers/checkForSuspiciousAsset"; import { isAssetSuspicious, scanAsset } from "popup/helpers/blockaid"; -import { METRIC_NAMES } from "popup/constants/metricsNames"; -import { - publicKeySelector, - hardwareWalletTypeSelector, - addTokenId, -} from "popup/ducks/accountServices"; +import { useChangeTrustline } from "popup/helpers/useChangeTrustline"; +import { publicKeySelector, addTokenId } from "popup/ducks/accountServices"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { - getAccountBalances, - resetSubmission, - signFreighterTransaction, - submitFreighterTransaction, transactionSubmissionSelector, - startHwSign, removeTokenId, resetSubmitStatus, } from "popup/ducks/transactionSubmission"; import { ActionStatus } from "@shared/api/types"; -import { NETWORKS } from "@shared/constants/stellar"; + import { ROUTES } from "popup/constants/routes"; import IconAdd from "popup/assets/icon-add.svg"; @@ -92,105 +83,30 @@ export const ManageAssetRowButton = ({ const [isSigningWithHardwareWallet, setIsSigningWithHardwareWallet] = useState(false); const { submitStatus } = useSelector(transactionSubmissionSelector); - const walletType = useSelector(hardwareWalletTypeSelector); const networkDetails = useSelector(settingsNetworkDetailsSelector); const publicKey = useSelector(publicKeySelector); - const isHardwareWallet = !!walletType; const ManageAssetRowDropdownRef = useRef(null); const server = stellarSdkServer( networkDetails.networkUrl, networkDetails.networkPassphrase, ); + const { changeTrustline } = useChangeTrustline({ + assetCode: code, + assetIssuer: issuer, + recommendedFee, + setAssetSubmitting, + setIsSigningWithHardwareWallet, + setIsTrustlineErrorShowing, + setRowButtonShowing, + }); + const handleBackgroundClick = () => { setRowButtonShowing(""); }; const canonicalAsset = getCanonicalFromAsset(code, issuer); - const signAndSubmit = async ( - transactionXDR: string, - trackChangeTrustline: () => void, - successfulCallback?: () => Promise, - ) => { - const res = await dispatch( - signFreighterTransaction({ - transactionXDR, - network: networkDetails.networkPassphrase, - }), - ); - - if (signFreighterTransaction.fulfilled.match(res)) { - const submitResp = await dispatch( - submitFreighterTransaction({ - publicKey, - signedXDR: res.payload.signedTransaction, - networkDetails, - }), - ); - - if (submitFreighterTransaction.fulfilled.match(submitResp)) { - dispatch( - getAccountBalances({ - publicKey, - networkDetails, - }), - ); - trackChangeTrustline(); - dispatch(resetSubmission()); - if (successfulCallback) { - await successfulCallback(); - } - } - - if (submitFreighterTransaction.rejected.match(submitResp)) { - setIsTrustlineErrorShowing(true); - } - - setAssetSubmitting(""); - setRowButtonShowing(""); - } - }; - - const changeTrustline = async ( - addTrustline: boolean, - successfulCallback?: () => Promise, - ) => { - setAssetSubmitting(canonicalAsset); - - const transactionXDR: string = await getManageAssetXDR({ - publicKey, - assetCode: code, - assetIssuer: issuer, - addTrustline, - server, - recommendedFee, - networkDetails, - }); - - const trackChangeTrustline = () => { - emitMetric( - addTrustline - ? METRIC_NAMES.manageAssetAddAsset - : METRIC_NAMES.manageAssetRemoveAsset, - { code, issuer }, - ); - }; - - if (isHardwareWallet) { - // eslint-disable-next-line - await dispatch(startHwSign({ transactionXDR, shouldSubmit: true })); - setIsSigningWithHardwareWallet(true); - trackChangeTrustline(); - } else { - await signAndSubmit( - transactionXDR, - trackChangeTrustline, - successfulCallback, - ); - } - }; - const handleRowClick = async ( assetRowData = { code: "", @@ -246,6 +162,7 @@ export const ManageAssetRowButton = ({ ) => { const contractId = assetRowData.contract; setAssetSubmitting(canonicalAsset || contractId); + if (!isTrustlineActive) { const addSac = async () => { const addToken = async () => { @@ -259,6 +176,7 @@ export const ManageAssetRowButton = ({ navigateTo(ROUTES.account); }; + if (StrKey.isValidEd25519PublicKey(assetRowData.issuer)) { await changeTrustline(true, addToken); } else { diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 187f3e95b6..abe66154c1 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -17,6 +17,7 @@ export const METRIC_NAMES = { viewMnemonicPhraseConfirmed: "loaded screen: account creator finished", viewRecoverAccount: "loaded screen: recover account", viewRecoverAccountSuccess: "loaded screen: recover account: success", + viewAddToken: "loaded screen: add token", viewSignTransaction: "loaded screen: sign transaction", viewReviewAuthorization: "loaded screen: review authorization", viewSignMessage: "loaded screen: sign message", @@ -125,6 +126,9 @@ export const METRIC_NAMES = { grantAccessSuccess: "grant access: granted", grantAccessFail: "grant access: rejected", + addToken: "add token: confirmed", + rejectToken: "add token: rejected", + signTransaction: "sign transaction: confirmed", signTransactionMemoRequired: "sign transaction: memo required error", rejectTransaction: "sign transaction: rejected", @@ -149,6 +153,9 @@ export const METRIC_NAMES = { invalidAuthEntry: "invalid authorization entry", + tokenAddedApi: "user added token through api", + tokenRejectApi: "user cancelled adding token through api", + rejectSigning: "user cancelled signing flow", approveSign: "user signed transaction", reviewedAuthEntry: "reviewed authorization entry", diff --git a/extension/src/popup/constants/routes.ts b/extension/src/popup/constants/routes.ts index 5076788c75..5033e63f6f 100644 --- a/extension/src/popup/constants/routes.ts +++ b/extension/src/popup/constants/routes.ts @@ -26,6 +26,7 @@ export enum ROUTES { swapSettingsTimeout = "/swap/settings/timeout", swapConfirm = "/swap/confirm", addAccount = "/add-account", + addToken = "/add-token", signTransaction = "/sign-transaction", reviewAuthorization = "/review-auth", signMessage = "/sign-message", diff --git a/extension/src/popup/ducks/access.ts b/extension/src/popup/ducks/access.ts index f763fed89d..3a2b3bb62b 100644 --- a/extension/src/popup/ducks/access.ts +++ b/extension/src/popup/ducks/access.ts @@ -3,6 +3,7 @@ import { createAsyncThunk } from "@reduxjs/toolkit"; import { rejectAccess as internalRejectAccess, grantAccess as internalGrantAccess, + addToken as internalAddToken, signTransaction as internalSignTransaction, signBlob as internalSignBlob, signAuthEntry as internalSignAuthEntry, @@ -23,6 +24,13 @@ export const signTransaction = createAsyncThunk( export const signBlob = createAsyncThunk("signBlob", internalSignBlob); export const signEntry = createAsyncThunk("signEntry", internalSignAuthEntry); +export const addToken = createAsyncThunk("addToken", internalAddToken); + +export const rejectToken = createAsyncThunk( + "rejectToken", + internalRejectAccess, +); + // Basically an alias for metrics purposes export const rejectTransaction = createAsyncThunk( "rejectTransaction", diff --git a/extension/src/popup/helpers/useChangeTrustline.ts b/extension/src/popup/helpers/useChangeTrustline.ts new file mode 100644 index 0000000000..e6e401d7fd --- /dev/null +++ b/extension/src/popup/helpers/useChangeTrustline.ts @@ -0,0 +1,151 @@ +import { useDispatch, useSelector } from "react-redux"; + +import { stellarSdkServer } from "@shared/api/helpers/stellarSdkServer"; + +import { emitMetric } from "helpers/metrics"; +import { getCanonicalFromAsset } from "helpers/stellar"; + +import { AppDispatch } from "popup/App"; +import { getManageAssetXDR } from "popup/helpers/getManageAssetXDR"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { + publicKeySelector, + hardwareWalletTypeSelector, +} from "popup/ducks/accountServices"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; +import { + getAccountBalances, + resetSubmission, + signFreighterTransaction, + submitFreighterTransaction, + startHwSign, +} from "popup/ducks/transactionSubmission"; + +import { useNetworkFees } from "./useNetworkFees"; + +export const useChangeTrustline = ({ + assetCode, + assetIssuer, + recommendedFee: inputFee, + setAssetSubmitting, + setIsSigningWithHardwareWallet, + setIsTrustlineErrorShowing, + setRowButtonShowing, +}: { + assetCode: string; + assetIssuer: string; + recommendedFee?: string; + setAssetSubmitting?: (rowButtonShowing: string) => void; + setIsSigningWithHardwareWallet?: (value: boolean) => void; + setIsTrustlineErrorShowing?: (value: boolean) => void; + setRowButtonShowing?: (value: string) => void; +}): { + changeTrustline: ( + addTrustline: boolean, + successfulCallback?: () => Promise, + ) => Promise; +} => { + const dispatch: AppDispatch = useDispatch(); + const walletType = useSelector(hardwareWalletTypeSelector); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const publicKey = useSelector(publicKeySelector); + + const isHardwareWallet = !!walletType; + + const server = stellarSdkServer( + networkDetails.networkUrl, + networkDetails.networkPassphrase, + ); + + const networkFees = useNetworkFees(); + const recommendedFee = inputFee || networkFees.recommendedFee; + + const canonicalAsset = getCanonicalFromAsset(assetCode, assetIssuer); + + const signAndSubmit = async ( + transactionXDR: string, + trackChangeTrustline: () => void, + successfulCallback?: () => Promise, + ) => { + const res = await dispatch( + signFreighterTransaction({ + transactionXDR, + network: networkDetails.networkPassphrase, + }), + ); + + if (signFreighterTransaction.fulfilled.match(res)) { + const submitResp = await dispatch( + submitFreighterTransaction({ + publicKey, + signedXDR: res.payload.signedTransaction, + networkDetails, + }), + ); + + if (submitFreighterTransaction.fulfilled.match(submitResp)) { + dispatch( + getAccountBalances({ + publicKey, + networkDetails, + }), + ); + trackChangeTrustline(); + dispatch(resetSubmission()); + if (successfulCallback) { + await successfulCallback(); + } + } + + if (submitFreighterTransaction.rejected.match(submitResp)) { + setIsTrustlineErrorShowing?.(true); + } + + setAssetSubmitting?.(""); + setRowButtonShowing?.(""); + } + }; + + const changeTrustline = async ( + addTrustline: boolean, // false removes the trustline + successfulCallback?: () => Promise, + ) => { + setAssetSubmitting?.(canonicalAsset); + + const transactionXDR: string = await getManageAssetXDR({ + publicKey, + assetCode, + assetIssuer, + addTrustline, + server, + recommendedFee, + networkDetails, + }); + + const trackChangeTrustline = () => { + emitMetric( + addTrustline + ? METRIC_NAMES.manageAssetAddAsset + : METRIC_NAMES.manageAssetRemoveAsset, + { code: assetCode, issuer: assetIssuer }, + ); + }; + + if (isHardwareWallet) { + // eslint-disable-next-line + await dispatch(startHwSign({ transactionXDR, shouldSubmit: true })); + setIsSigningWithHardwareWallet?.(true); + trackChangeTrustline(); + } else { + await signAndSubmit( + transactionXDR, + trackChangeTrustline, + successfulCallback, + ); + } + }; + + return { + changeTrustline, + }; +}; diff --git a/extension/src/popup/helpers/useSetupAddTokenFlow.ts b/extension/src/popup/helpers/useSetupAddTokenFlow.ts new file mode 100644 index 0000000000..d9e4dacdea --- /dev/null +++ b/extension/src/popup/helpers/useSetupAddTokenFlow.ts @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { StrKey } from "stellar-sdk"; + +import { emitMetric } from "helpers/metrics"; + +import { AppDispatch } from "popup/App"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { rejectToken, addToken } from "popup/ducks/access"; +import { + confirmPassword, + hasPrivateKeySelector, +} from "popup/ducks/accountServices"; + +import { useChangeTrustline } from "./useChangeTrustline"; + +export const useSetupAddTokenFlow = ({ + rejectToken: rejectTokenFn, + addToken: addTokenFn, + assetCode, + assetIssuer, +}: { + rejectToken: typeof rejectToken; + addToken: typeof addToken; + assetCode: string; + assetIssuer: string; +}): { + isConfirming: boolean; + isPasswordRequired: boolean; + setIsPasswordRequired: (value: boolean) => void; + verifyPasswordThenAddToken: (password: string) => Promise; + handleApprove: () => Promise; + rejectAndClose: () => void; +} => { + const [isConfirming, setIsConfirming] = useState(false); + const [isPasswordRequired, setIsPasswordRequired] = useState(false); + + const dispatch: AppDispatch = useDispatch(); + const hasPrivateKey = useSelector(hasPrivateKeySelector); + + const { changeTrustline } = useChangeTrustline({ assetCode, assetIssuer }); + + const rejectAndClose = () => { + emitMetric(METRIC_NAMES.tokenRejectApi); + dispatch(rejectTokenFn()); + window.close(); + }; + + const addTokenAndClose = async () => { + const addTokenDispatch = async () => { + await dispatch(addTokenFn()); + }; + + if (StrKey.isValidEd25519PublicKey(assetIssuer)) { + await changeTrustline(true, addTokenDispatch); + } else { + await addTokenDispatch(); + } + + await emitMetric(METRIC_NAMES.tokenAddedApi); + window.close(); + }; + + const handleApprove = async () => { + setIsConfirming(true); + + if (hasPrivateKey) { + await addTokenAndClose(); + } else { + setIsPasswordRequired(true); + } + + setIsConfirming(false); + }; + + const verifyPasswordThenAddToken = async (password: string) => { + const confirmPasswordResp = await dispatch(confirmPassword(password)); + + if (confirmPassword.fulfilled.match(confirmPasswordResp)) { + await addTokenAndClose(); + } + }; + + return { + isConfirming, + isPasswordRequired, + setIsPasswordRequired, + verifyPasswordThenAddToken, + handleApprove, + rejectAndClose, + }; +}; diff --git a/extension/src/popup/helpers/useTokenLookup.ts b/extension/src/popup/helpers/useTokenLookup.ts new file mode 100644 index 0000000000..8d8fd63472 --- /dev/null +++ b/extension/src/popup/helpers/useTokenLookup.ts @@ -0,0 +1,166 @@ +import { captureException } from "@sentry/browser"; +import { useCallback } from "react"; +import { useSelector } from "react-redux"; + +import { getTokenDetails } from "@shared/api/internal"; +import { isSacContractExecutable } from "@shared/helpers/soroban/token"; + +import { isMainnet, isTestnet } from "helpers/stellar"; + +import { publicKeySelector } from "popup/ducks/accountServices"; +import { + settingsNetworkDetailsSelector, + settingsSelector, +} from "popup/ducks/settings"; +import { + getVerifiedTokens, + getNativeContractDetails, + VerifiedTokenRecord, +} from "popup/helpers/searchAsset"; +import { isAssetSuspicious, scanAsset } from "popup/helpers/blockaid"; +import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; + +export const useTokenLookup = ({ + setAssetRows, + setIsSearching, + setIsVerifiedToken, + setIsVerificationInfoShowing, + setVerifiedLists, +}: { + setAssetRows: (rows: ManageAssetCurrency[]) => void; + setIsSearching: (value: boolean) => void; + setIsVerifiedToken: (value: boolean) => void; + setIsVerificationInfoShowing: (value: boolean) => void; + setVerifiedLists: (lists: string[]) => void; +}): { + handleTokenLookup: (contractId: string) => Promise; +} => { + const publicKey = useSelector(publicKeySelector); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const { assetsLists } = useSelector(settingsSelector); + const isAllowListVerificationEnabled = + isMainnet(networkDetails) || isTestnet(networkDetails); + + const handleTokenLookup = useCallback( + async (contractId: string) => { + // clear the UI while we work through the flow + setIsSearching(true); + setIsVerifiedToken(false); + setIsVerificationInfoShowing(false); + setAssetRows([]); + + const nativeContractDetails = getNativeContractDetails(networkDetails); + let verifiedTokens = [] as VerifiedTokenRecord[]; + + // step around verification for native contract and unverifiable networks + if (nativeContractDetails.contract === contractId) { + // override our rules for verification for XLM + setIsVerificationInfoShowing(false); + setAssetRows([ + { + code: nativeContractDetails.code, + issuer: contractId, + domain: nativeContractDetails.domain, + }, + ]); + setIsSearching(false); + return; + } + + const tokenLookup = async () => { + // lookup contract + setIsVerifiedToken(false); + let tokenDetailsResponse; + + try { + tokenDetailsResponse = await getTokenDetails({ + contractId, + publicKey, + networkDetails, + }); + } catch (e) { + setAssetRows([]); + } + + const isSacContract = await isSacContractExecutable( + contractId, + networkDetails, + ); + + if (!tokenDetailsResponse) { + setAssetRows([]); + } else { + const issuer = isSacContract + ? tokenDetailsResponse.name.split(":")[1] || "" + : contractId; // get the issuer name, if applicable , + const scannedAsset = await scanAsset( + `${tokenDetailsResponse.symbol}-${issuer}`, + networkDetails, + ); + setAssetRows([ + { + code: tokenDetailsResponse.symbol, + contract: contractId, + issuer, + domain: "", + name: tokenDetailsResponse.name, + isSuspicious: isAssetSuspicious(scannedAsset), + }, + ]); + } + }; + + if (isAllowListVerificationEnabled) { + // usual binary case of a token being verified or unverified + verifiedTokens = await getVerifiedTokens({ + networkDetails, + contractId, + assetsLists, + }); + + try { + if (verifiedTokens.length) { + setIsVerifiedToken(true); + setVerifiedLists(verifiedTokens[0].verifiedLists); + setAssetRows( + verifiedTokens.map((record: VerifiedTokenRecord) => ({ + code: record.code || record.contract, + issuer: record.issuer || record.contract, + image: record.icon, + domain: record.domain, + contract: record.contract, + })), + ); + } else { + // token not found on asset list, look up the details manually + await tokenLookup(); + } + } catch (e) { + setAssetRows([]); + captureException( + `Failed to fetch token details - ${JSON.stringify(e)}`, + ); + console.error(e); + } + } else { + // Futurenet token lookup + await tokenLookup(); + } + setIsSearching(false); + setIsVerificationInfoShowing(isAllowListVerificationEnabled); + }, + [ + assetsLists, + isAllowListVerificationEnabled, + networkDetails, + publicKey, + setAssetRows, + setIsSearching, + setIsVerificationInfoShowing, + setIsVerifiedToken, + setVerifiedLists, + ], + ); + + return { handleTokenLookup }; +}; diff --git a/extension/src/popup/metrics/access.ts b/extension/src/popup/metrics/access.ts index a547e2d488..793a847fb3 100644 --- a/extension/src/popup/metrics/access.ts +++ b/extension/src/popup/metrics/access.ts @@ -4,6 +4,8 @@ import { grantAccess, rejectAccess, signEntry, + addToken, + rejectToken, signTransaction, signBlob, rejectTransaction, @@ -18,6 +20,17 @@ registerHandler(grantAccess.fulfilled, () => { registerHandler(rejectAccess.fulfilled, () => { emitMetric(METRIC_NAMES.grantAccessFail); }); +registerHandler(addToken.fulfilled, () => { + const metricsData: MetricsData = JSON.parse( + localStorage.getItem(METRICS_DATA) || "{}", + ); + emitMetric(METRIC_NAMES.addToken, { + accountType: metricsData.accountType, + }); +}); +registerHandler(rejectToken.fulfilled, () => { + emitMetric(METRIC_NAMES.rejectToken); +}); registerHandler(signTransaction.fulfilled, () => { const metricsData: MetricsData = JSON.parse( localStorage.getItem(METRICS_DATA) || "{}", diff --git a/extension/src/popup/metrics/views.ts b/extension/src/popup/metrics/views.ts index 71c10f42dc..6d54083ac2 100644 --- a/extension/src/popup/metrics/views.ts +++ b/extension/src/popup/metrics/views.ts @@ -17,6 +17,7 @@ const routeToEventName = { [ROUTES.connectWallet]: METRIC_NAMES.viewConnectWallet, [ROUTES.connectWalletPlugin]: METRIC_NAMES.viewConnectWalletPlugin, [ROUTES.connectDevice]: METRIC_NAMES.viewConnectDevice, + [ROUTES.addToken]: METRIC_NAMES.viewAddToken, [ROUTES.signMessage]: METRIC_NAMES.viewSignMessage, [ROUTES.signTransaction]: METRIC_NAMES.viewSignTransaction, [ROUTES.reviewAuthorization]: METRIC_NAMES.viewReviewAuthorization, @@ -99,6 +100,14 @@ registerHandler(navigate, (_, a) => { }; emitMetric(eventName, METRIC_OPTION_DOMAIN); + } else if (pathname === ROUTES.addToken) { + const { url } = parsedSearchParam(search); + const METRIC_OPTIONS = { + domain: getUrlDomain(url), + subdomain: getUrlHostname(url), + }; + + emitMetric(eventName, METRIC_OPTIONS); } else if (pathname === ROUTES.signTransaction) { const { url } = parsedSearchParam(search); const info = getTransactionInfo(search); diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx new file mode 100644 index 0000000000..316d865522 --- /dev/null +++ b/extension/src/popup/views/AddToken/index.tsx @@ -0,0 +1,176 @@ +import { Button, Text } from "@stellar/design-system"; +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; +import { useSelector } from "react-redux"; + +import { parsedSearchParam, TokenToAdd } from "helpers/urls"; + +import { rejectToken, addToken } from "popup/ducks/access"; +import { + isNonSSLEnabledSelector, + settingsNetworkDetailsSelector, +} from "popup/ducks/settings"; +import { useSetupAddTokenFlow } from "popup/helpers/useSetupAddTokenFlow"; +import { + WarningMessageVariant, + WarningMessage, + SSLWarningMessage, +} from "popup/components/WarningMessages"; +import { VerifyAccount } from "popup/views/VerifyAccount"; +import { View } from "popup/basics/layout/View"; +import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; +import { SearchResults } from "popup/components/manageAssets/AssetResults"; +import { AssetNotifcation } from "popup/components/AssetNotification"; +import { useTokenLookup } from "popup/helpers/useTokenLookup"; +import { isContractId } from "popup/helpers/soroban"; + +import "./styles.scss"; + +export const AddToken = () => { + const location = useLocation(); + const params = parsedSearchParam(location.search) as TokenToAdd; + const { url, contractId, networkPassphrase: entryNetworkPassphrase } = params; + + const { t } = useTranslation(); + const isNonSSLEnabled = useSelector(isNonSSLEnabledSelector); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const { networkName, networkPassphrase } = networkDetails; + + const [assetRows, setAssetRows] = useState([] as ManageAssetCurrency[]); + const [isSearching, setIsSearching] = useState(false); + const [hasNoResults, setHasNoResults] = useState(false); + const [isVerifiedToken, setIsVerifiedToken] = useState(false); + const [isVerificationInfoShowing, setIsVerificationInfoShowing] = + useState(false); + const [_, setVerifiedLists] = useState([] as string[]); + + const ResultsRef = useRef(null); + + const assetCurrency: ManageAssetCurrency | undefined = assetRows[0]; + const assetCode = assetCurrency?.code || ""; + const assetIssuer = assetCurrency?.issuer || ""; + + const { + isConfirming, + isPasswordRequired, + setIsPasswordRequired, + verifyPasswordThenAddToken, + handleApprove, + rejectAndClose, + } = useSetupAddTokenFlow({ + rejectToken, + addToken, + assetCode, + assetIssuer, + }); + + const { handleTokenLookup } = useTokenLookup({ + setAssetRows, + setIsSearching, + setIsVerifiedToken, + setIsVerificationInfoShowing, + setVerifiedLists, + }); + + useEffect(() => { + setHasNoResults(!assetRows.length); + }, [assetRows]); + + useEffect(() => { + if (!isContractId(contractId)) { + return; + } + + handleTokenLookup(contractId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contractId, handleTokenLookup]); + + if (entryNetworkPassphrase && entryNetworkPassphrase !== networkPassphrase) { + return ( + window.close()} + isActive + header={`${t("Freighter is set to")} ${networkName}`} + > +

+ {t("The token you’re trying to add is on")} {entryNetworkPassphrase}. +

+

{t("Adding this token is not possible at the moment.")}

+
+ ); + } + + if (!url.startsWith("https") && !isNonSSLEnabled) { + return ; + } + + if (isPasswordRequired) { + return ( + setIsPasswordRequired(false)} + customSubmit={verifyPasswordThenAddToken} + /> + ); + } + + return ( + + + + {t("Test Add Token")} + + + + {t( + `You are trying to add this Contract Id: ${contractId}, with this NetworkPassphrase: ${networkPassphrase}`, + )} + + + + {assetCurrency + ? t( + `You are trying to add this Token: ${JSON.stringify( + assetCurrency, + )}`, + ) + : t("Loading...")} + + + + {assetRows.length && isVerificationInfoShowing ? ( + + ) : null} + + {hasNoResults && !isSearching ? ( + + {t("Asset not found")} + + ) : null} + + + + + + + + + ); +}; From 1aa2a0f13cd688a419ae31bcf73f6b1cc20b756f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 24 Jan 2025 13:48:17 -0800 Subject: [PATCH 02/29] Fix lint warnings --- extension/src/popup/helpers/useTokenLookup.ts | 4 ++-- extension/src/popup/views/AddToken/index.tsx | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/extension/src/popup/helpers/useTokenLookup.ts b/extension/src/popup/helpers/useTokenLookup.ts index 8d8fd63472..26168ccc2c 100644 --- a/extension/src/popup/helpers/useTokenLookup.ts +++ b/extension/src/popup/helpers/useTokenLookup.ts @@ -31,7 +31,7 @@ export const useTokenLookup = ({ setIsSearching: (value: boolean) => void; setIsVerifiedToken: (value: boolean) => void; setIsVerificationInfoShowing: (value: boolean) => void; - setVerifiedLists: (lists: string[]) => void; + setVerifiedLists?: (lists: string[]) => void; }): { handleTokenLookup: (contractId: string) => Promise; } => { @@ -121,7 +121,7 @@ export const useTokenLookup = ({ try { if (verifiedTokens.length) { setIsVerifiedToken(true); - setVerifiedLists(verifiedTokens[0].verifiedLists); + setVerifiedLists?.(verifiedTokens[0].verifiedLists); setAssetRows( verifiedTokens.map((record: VerifiedTokenRecord) => ({ code: record.code || record.contract, diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index 316d865522..eb4443c401 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -25,8 +25,6 @@ import { AssetNotifcation } from "popup/components/AssetNotification"; import { useTokenLookup } from "popup/helpers/useTokenLookup"; import { isContractId } from "popup/helpers/soroban"; -import "./styles.scss"; - export const AddToken = () => { const location = useLocation(); const params = parsedSearchParam(location.search) as TokenToAdd; @@ -43,7 +41,6 @@ export const AddToken = () => { const [isVerifiedToken, setIsVerifiedToken] = useState(false); const [isVerificationInfoShowing, setIsVerificationInfoShowing] = useState(false); - const [_, setVerifiedLists] = useState([] as string[]); const ResultsRef = useRef(null); @@ -70,7 +67,6 @@ export const AddToken = () => { setIsSearching, setIsVerifiedToken, setIsVerificationInfoShowing, - setVerifiedLists, }); useEffect(() => { From 167c6ed83d223efeb252852f822a87b1ff578d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 24 Jan 2025 14:06:14 -0800 Subject: [PATCH 03/29] Clean up --- extension/src/popup/helpers/useTokenLookup.ts | 16 +++--- extension/src/popup/views/AddToken/index.tsx | 50 +++++-------------- 2 files changed, 21 insertions(+), 45 deletions(-) diff --git a/extension/src/popup/helpers/useTokenLookup.ts b/extension/src/popup/helpers/useTokenLookup.ts index 26168ccc2c..1a71204b15 100644 --- a/extension/src/popup/helpers/useTokenLookup.ts +++ b/extension/src/popup/helpers/useTokenLookup.ts @@ -29,8 +29,8 @@ export const useTokenLookup = ({ }: { setAssetRows: (rows: ManageAssetCurrency[]) => void; setIsSearching: (value: boolean) => void; - setIsVerifiedToken: (value: boolean) => void; - setIsVerificationInfoShowing: (value: boolean) => void; + setIsVerifiedToken?: (value: boolean) => void; + setIsVerificationInfoShowing?: (value: boolean) => void; setVerifiedLists?: (lists: string[]) => void; }): { handleTokenLookup: (contractId: string) => Promise; @@ -45,8 +45,8 @@ export const useTokenLookup = ({ async (contractId: string) => { // clear the UI while we work through the flow setIsSearching(true); - setIsVerifiedToken(false); - setIsVerificationInfoShowing(false); + setIsVerifiedToken?.(false); + setIsVerificationInfoShowing?.(false); setAssetRows([]); const nativeContractDetails = getNativeContractDetails(networkDetails); @@ -55,7 +55,7 @@ export const useTokenLookup = ({ // step around verification for native contract and unverifiable networks if (nativeContractDetails.contract === contractId) { // override our rules for verification for XLM - setIsVerificationInfoShowing(false); + setIsVerificationInfoShowing?.(false); setAssetRows([ { code: nativeContractDetails.code, @@ -69,7 +69,7 @@ export const useTokenLookup = ({ const tokenLookup = async () => { // lookup contract - setIsVerifiedToken(false); + setIsVerifiedToken?.(false); let tokenDetailsResponse; try { @@ -120,7 +120,7 @@ export const useTokenLookup = ({ try { if (verifiedTokens.length) { - setIsVerifiedToken(true); + setIsVerifiedToken?.(true); setVerifiedLists?.(verifiedTokens[0].verifiedLists); setAssetRows( verifiedTokens.map((record: VerifiedTokenRecord) => ({ @@ -147,7 +147,7 @@ export const useTokenLookup = ({ await tokenLookup(); } setIsSearching(false); - setIsVerificationInfoShowing(isAllowListVerificationEnabled); + setIsVerificationInfoShowing?.(isAllowListVerificationEnabled); }, [ assetsLists, diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index eb4443c401..b9b95220c3 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -1,5 +1,5 @@ import { Button, Text } from "@stellar/design-system"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; import { useSelector } from "react-redux"; @@ -20,8 +20,6 @@ import { import { VerifyAccount } from "popup/views/VerifyAccount"; import { View } from "popup/basics/layout/View"; import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; -import { SearchResults } from "popup/components/manageAssets/AssetResults"; -import { AssetNotifcation } from "popup/components/AssetNotification"; import { useTokenLookup } from "popup/helpers/useTokenLookup"; import { isContractId } from "popup/helpers/soroban"; @@ -37,12 +35,6 @@ export const AddToken = () => { const [assetRows, setAssetRows] = useState([] as ManageAssetCurrency[]); const [isSearching, setIsSearching] = useState(false); - const [hasNoResults, setHasNoResults] = useState(false); - const [isVerifiedToken, setIsVerifiedToken] = useState(false); - const [isVerificationInfoShowing, setIsVerificationInfoShowing] = - useState(false); - - const ResultsRef = useRef(null); const assetCurrency: ManageAssetCurrency | undefined = assetRows[0]; const assetCode = assetCurrency?.code || ""; @@ -65,14 +57,8 @@ export const AddToken = () => { const { handleTokenLookup } = useTokenLookup({ setAssetRows, setIsSearching, - setIsVerifiedToken, - setIsVerificationInfoShowing, }); - useEffect(() => { - setHasNoResults(!assetRows.length); - }, [assetRows]); - useEffect(() => { if (!isContractId(contractId)) { return; @@ -115,37 +101,27 @@ export const AddToken = () => { return ( - + {t("Test Add Token")} - + {t( `You are trying to add this Contract Id: ${contractId}, with this NetworkPassphrase: ${networkPassphrase}`, )} - - {assetCurrency - ? t( - `You are trying to add this Token: ${JSON.stringify( - assetCurrency, - )}`, - ) - : t("Loading...")} + + {assetCurrency && + !isSearching && + t( + `You are trying to add this Token: ${JSON.stringify( + assetCurrency, + )}`, + )} + {!assetCurrency && isSearching && t("Loading...")} + {!assetCurrency && !isSearching && t("Asset not found")} - - - {assetRows.length && isVerificationInfoShowing ? ( - - ) : null} - - {hasNoResults && !isSearching ? ( - - {t("Asset not found")} - - ) : null} - From 891402c3a8c29b9aa2f915da2cf6c09a0fba1b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 24 Jan 2025 14:16:13 -0800 Subject: [PATCH 04/29] Only enable button if asset found --- extension/src/popup/views/AddToken/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index b9b95220c3..2803394bb7 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -134,6 +134,7 @@ export const AddToken = () => { {t("Cancel")} - - +
+ + {t("Asset info")} + +
+
+ +
+ + {t("Symbol")} + + + {(assetCurrency && assetCurrency.code) || ""} + +
+
+
+ +
+ + {t("Name")} + + {/* TODO: hide if name does not exist */} + + {(assetCurrency && assetCurrency.name) || "TOKEN NAME"} + +
+
+ + {/* TODO: fetch actual values */} +
+ + {t("Simulated Balance Changes")} + +
+
+ +
+ + {t("Amount")} + + + +1000.00 GO + +
+
+ +
+ + +
+ + +
); }; diff --git a/extension/src/popup/views/AddToken/styles.scss b/extension/src/popup/views/AddToken/styles.scss new file mode 100644 index 0000000000..788f27d72f --- /dev/null +++ b/extension/src/popup/views/AddToken/styles.scss @@ -0,0 +1,94 @@ +@use "../../styles/utils.scss" as *; + +.AddToken { + position: relative; + flex-direction: column; + display: flex; + height: 100%; + justify-content: flex-end; + + &__loader { + display: flex; + height: 100%; + width: 100%; + justify-content: center; + align-items: center; + } + + &__wrapper { + overflow: scroll; + position: relative; + flex-direction: column; + display: flex; + border-radius: pxToRem(16px); + padding: pxToRem(32px) pxToRem(24px) pxToRem(24px) pxToRem(24px); + background: var(--sds-clr-gray-02); + + &__header { + position: relative; + flex-direction: column; + display: flex; + align-items: center; + } + + &__domain-logo { + height: pxToRem(40px); + width: pxToRem(40px); + border-radius: pxToRem(60px); + margin-bottom: pxToRem(8px); + background-color: gray; + } + + &__badge { + margin-top: pxToRem(14px); + margin-bottom: pxToRem(24px); + } + + &__domain-label { + color: var(--sds-clr-gray-11); + } + + &__info { + position: relative; + gap: pxToRem(6px); + margin-bottom: pxToRem(8px); + background: var(--sds-clr-gray-03); + border-radius: pxToRem(8px); + display: flex; + width: 100%; + padding: pxToRem(12px) pxToRem(16px); + flex-direction: column; + + &--title { + color: var(--sds-clr-gray-11); + } + + &__row { + display: flex; + flex-direction: row; + gap: pxToRem(8px); + + &--icon { + color: var(--sds-clr-gray-09); + } + + &__right_label { + text-align: right; + flex: 1; + } + } + + &--amount { + color: var(--sds-clr-green-11); + } + } + + &__footer { + margin-top: pxToRem(16px); + display: flex; + align-items: flex-start; + gap: pxToRem(8px); + align-self: stretch; + } + } +} From 71042df55538fa13dc2dca5695ef547cce01f965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 31 Jan 2025 10:45:23 -0800 Subject: [PATCH 19/29] only display info when it exists --- extension/src/popup/views/AddToken/index.tsx | 92 +++++++++++--------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index b171a7a606..c342c56c2d 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -54,6 +54,8 @@ export const AddToken = () => { const assetCurrency: ManageAssetCurrency | undefined = assetRows[0]; const assetCode = assetCurrency?.code || ""; const assetIssuer = assetCurrency?.issuer || ""; + const assetName = assetCurrency?.name || ""; + const assetDomain = assetCurrency?.domain || ""; const { isConfirming, @@ -153,18 +155,23 @@ export const AddToken = () => {
- {/* TODO: replace with real logo */} + {/* TODO: replace with real logo */}
- - {t("Go Stellar")} - - - {t("go-stellar.app")} - + + {assetCurrency && ( + + {assetName || assetCode} + + )} + {assetDomain && ( + + {assetDomain} + + )}
{ > {t("Asset info")} -
-
- + + {assetCode && ( +
+
+ +
+ + {t("Symbol")} + + + {assetCode} +
- - {t("Symbol")} - - - {(assetCurrency && assetCurrency.code) || ""} - -
-
-
- + )} + + {assetName && ( +
+
+ +
+ + {t("Name")} + + + {assetName} +
- - {t("Name")} - - {/* TODO: hide if name does not exist */} - - {(assetCurrency && assetCurrency.name) || "TOKEN NAME"} - -
+ )}
{/* TODO: fetch actual values */} From 33fe94eeae09fb844998062abb69d1f65c0e2e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 31 Jan 2025 11:31:58 -0800 Subject: [PATCH 20/29] Display token image when available --- extension/src/popup/views/AddToken/index.tsx | 58 +++++++++++++++++-- .../src/popup/views/AddToken/styles.scss | 18 ++++-- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index c342c56c2d..777c0ae6c4 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -1,10 +1,18 @@ -import { Badge, Button, Icon, Loader, Text } from "@stellar/design-system"; +import { + Asset, + Badge, + Button, + Icon, + Loader, + Text, +} from "@stellar/design-system"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; import { useSelector } from "react-redux"; import { BlockAidScanAssetResult } from "@shared/api/types"; +import { getIconUrlFromIssuer } from "@shared/api/helpers/getIconUrlFromIssuer"; import { parsedSearchParam, TokenToAdd } from "helpers/urls"; @@ -46,6 +54,7 @@ export const AddToken = () => { const { networkName, networkPassphrase } = networkDetails; const [assetRows, setAssetRows] = useState([] as ManageAssetCurrency[]); + const [assetIcon, setAssetIcon] = useState(undefined); const [isSearching, setIsSearching] = useState(false); const [blockaidData, setBlockaidData] = useState< BlockAidScanAssetResult | undefined @@ -107,6 +116,24 @@ export const AddToken = () => { getBlockaidData(); }, [assetCode, assetIssuer, blockaidData, networkDetails]); + useEffect(() => { + if (!assetCode || !assetIssuer || assetIcon !== undefined) { + return; + } + + const getAssetIcon = async () => { + const iconUrl = await getIconUrlFromIssuer({ + key: assetIssuer, + code: assetCode, + networkDetails, + }); + + setAssetIcon(iconUrl || ""); + }; + + getAssetIcon(); + }, [assetCode, assetIssuer, assetIcon, networkDetails]); + if (entryNetworkPassphrase && entryNetworkPassphrase !== networkPassphrase) { return ( {
- {/* TODO: replace with real logo */} -
+ {assetIcon && ( +
+ +
+ )} + + {!assetIcon && assetCode && ( +
+ + {assetCode.slice(0, 2)} + +
+ )} {assetCurrency && ( @@ -167,7 +217,7 @@ export const AddToken = () => { {assetDomain} diff --git a/extension/src/popup/views/AddToken/styles.scss b/extension/src/popup/views/AddToken/styles.scss index 788f27d72f..8c4d56fea2 100644 --- a/extension/src/popup/views/AddToken/styles.scss +++ b/extension/src/popup/views/AddToken/styles.scss @@ -31,12 +31,21 @@ align-items: center; } - &__domain-logo { + &__icon-logo { + margin-bottom: pxToRem(8px); + align-items: center; + } + + &__code-logo { height: pxToRem(40px); width: pxToRem(40px); - border-radius: pxToRem(60px); + border-radius: pxToRem(40px); margin-bottom: pxToRem(8px); - background-color: gray; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--sds-clr-gray-06); + color: var(--sds-clr-gray-09); } &__badge { @@ -44,7 +53,8 @@ margin-bottom: pxToRem(24px); } - &__domain-label { + &--logo-label, + &--domain-label { color: var(--sds-clr-gray-11); } From 1b167fc62a633938555c7bcc9f0118ac19e8138c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 31 Jan 2025 12:22:04 -0800 Subject: [PATCH 21/29] Display "Asset on your lists" notification --- extension/src/popup/helpers/useTokenLookup.ts | 16 ++++++++-------- extension/src/popup/views/AddToken/index.tsx | 12 +++++++++++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/extension/src/popup/helpers/useTokenLookup.ts b/extension/src/popup/helpers/useTokenLookup.ts index 1a71204b15..26168ccc2c 100644 --- a/extension/src/popup/helpers/useTokenLookup.ts +++ b/extension/src/popup/helpers/useTokenLookup.ts @@ -29,8 +29,8 @@ export const useTokenLookup = ({ }: { setAssetRows: (rows: ManageAssetCurrency[]) => void; setIsSearching: (value: boolean) => void; - setIsVerifiedToken?: (value: boolean) => void; - setIsVerificationInfoShowing?: (value: boolean) => void; + setIsVerifiedToken: (value: boolean) => void; + setIsVerificationInfoShowing: (value: boolean) => void; setVerifiedLists?: (lists: string[]) => void; }): { handleTokenLookup: (contractId: string) => Promise; @@ -45,8 +45,8 @@ export const useTokenLookup = ({ async (contractId: string) => { // clear the UI while we work through the flow setIsSearching(true); - setIsVerifiedToken?.(false); - setIsVerificationInfoShowing?.(false); + setIsVerifiedToken(false); + setIsVerificationInfoShowing(false); setAssetRows([]); const nativeContractDetails = getNativeContractDetails(networkDetails); @@ -55,7 +55,7 @@ export const useTokenLookup = ({ // step around verification for native contract and unverifiable networks if (nativeContractDetails.contract === contractId) { // override our rules for verification for XLM - setIsVerificationInfoShowing?.(false); + setIsVerificationInfoShowing(false); setAssetRows([ { code: nativeContractDetails.code, @@ -69,7 +69,7 @@ export const useTokenLookup = ({ const tokenLookup = async () => { // lookup contract - setIsVerifiedToken?.(false); + setIsVerifiedToken(false); let tokenDetailsResponse; try { @@ -120,7 +120,7 @@ export const useTokenLookup = ({ try { if (verifiedTokens.length) { - setIsVerifiedToken?.(true); + setIsVerifiedToken(true); setVerifiedLists?.(verifiedTokens[0].verifiedLists); setAssetRows( verifiedTokens.map((record: VerifiedTokenRecord) => ({ @@ -147,7 +147,7 @@ export const useTokenLookup = ({ await tokenLookup(); } setIsSearching(false); - setIsVerificationInfoShowing?.(isAllowListVerificationEnabled); + setIsVerificationInfoShowing(isAllowListVerificationEnabled); }, [ assetsLists, diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index 777c0ae6c4..318e29ba92 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -32,6 +32,7 @@ import { import { VerifyAccount } from "popup/views/VerifyAccount"; import { View } from "popup/basics/layout/View"; import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; +import { AssetNotifcation } from "popup/components/AssetNotification"; import { useTokenLookup } from "popup/helpers/useTokenLookup"; import { isContractId } from "popup/helpers/soroban"; import { isAssetSuspicious, scanAsset } from "popup/helpers/blockaid"; @@ -55,7 +56,10 @@ export const AddToken = () => { const [assetRows, setAssetRows] = useState([] as ManageAssetCurrency[]); const [assetIcon, setAssetIcon] = useState(undefined); - const [isSearching, setIsSearching] = useState(false); + const [isSearching, setIsSearching] = useState(true); + const [isVerifiedToken, setIsVerifiedToken] = useState(false); + const [isVerificationInfoShowing, setIsVerificationInfoShowing] = + useState(false); const [blockaidData, setBlockaidData] = useState< BlockAidScanAssetResult | undefined >(undefined); @@ -83,6 +87,8 @@ export const AddToken = () => { const { handleTokenLookup } = useTokenLookup({ setAssetRows, setIsSearching, + setIsVerifiedToken, + setIsVerificationInfoShowing, }); useEffect(() => { @@ -237,6 +243,10 @@ export const AddToken = () => { {!isDomainListedAllowed && } + {assetCurrency && isVerificationInfoShowing && ( + + )} + {blockaidData && ( )} From edc64f66acb838fa6225f2ff11189974b56cb78b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 31 Jan 2025 12:52:20 -0800 Subject: [PATCH 22/29] Fetch asset name from TOML --- extension/src/popup/views/AddToken/index.tsx | 44 ++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index 318e29ba92..2aca742739 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -10,6 +10,7 @@ import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; import { useSelector } from "react-redux"; +import { StellarToml } from "stellar-sdk"; import { BlockAidScanAssetResult } from "@shared/api/types"; import { getIconUrlFromIssuer } from "@shared/api/helpers/getIconUrlFromIssuer"; @@ -56,6 +57,9 @@ export const AddToken = () => { const [assetRows, setAssetRows] = useState([] as ManageAssetCurrency[]); const [assetIcon, setAssetIcon] = useState(undefined); + const [assetTomlName, setAssetTomlName] = useState( + undefined, + ); const [isSearching, setIsSearching] = useState(true); const [isVerifiedToken, setIsVerifiedToken] = useState(false); const [isVerificationInfoShowing, setIsVerificationInfoShowing] = @@ -67,9 +71,12 @@ export const AddToken = () => { const assetCurrency: ManageAssetCurrency | undefined = assetRows[0]; const assetCode = assetCurrency?.code || ""; const assetIssuer = assetCurrency?.issuer || ""; - const assetName = assetCurrency?.name || ""; + const assetName = assetTomlName || assetCurrency?.name?.split(":")[0]; const assetDomain = assetCurrency?.domain || ""; + const isLoading = + isSearching || assetIcon === undefined || assetName === undefined; + const { isConfirming, isPasswordRequired, @@ -140,6 +147,37 @@ export const AddToken = () => { getAssetIcon(); }, [assetCode, assetIssuer, assetIcon, networkDetails]); + useEffect(() => { + if (assetCode && assetIssuer && !assetDomain) { + setAssetTomlName(""); + return; + } + + if ( + !assetDomain || + !assetCode || + !assetIssuer || + assetTomlName !== undefined + ) { + return; + } + + const getAssetTomlName = async () => { + try { + const toml = await StellarToml.Resolver.resolve(assetDomain); + const currency = toml?.CURRENCIES?.find( + ({ code, issuer }) => code === assetCode && issuer === assetIssuer, + ); + setAssetTomlName(currency?.name || ""); + } catch (e) { + console.error(e); + setAssetTomlName(""); + } + }; + + getAssetTomlName(); + }, [assetDomain, assetCode, assetIssuer, assetTomlName]); + if (entryNetworkPassphrase && entryNetworkPassphrase !== networkPassphrase) { return ( { ); } - if (isSearching) { + if (isLoading) { return ( @@ -278,7 +316,7 @@ export const AddToken = () => {
)} - {assetName && ( + {assetName && assetName !== assetCode && (
From 46d17853b8c80cf5ee8da4601ec9e8afdcdb1545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Fri, 31 Jan 2025 12:57:00 -0800 Subject: [PATCH 23/29] Prevent infinity spinner --- extension/src/popup/views/AddToken/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index 2aca742739..d9832e153b 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -75,7 +75,7 @@ export const AddToken = () => { const assetDomain = assetCurrency?.domain || ""; const isLoading = - isSearching || assetIcon === undefined || assetName === undefined; + isSearching || assetIcon === undefined || assetTomlName === undefined; const { isConfirming, From d8a7b7554d4118e4d2da1d0583e9c394be1a2aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Mon, 3 Feb 2025 14:58:04 -0800 Subject: [PATCH 24/29] Fetch and display balance --- @shared/api/internal.ts | 30 +++++++++- .../manageAssets/ManageAssetRows/index.tsx | 2 + extension/src/popup/helpers/useTokenLookup.ts | 3 + extension/src/popup/views/AddToken/index.tsx | 60 ++++++++++++------- 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index ea35c60418..b652a18521 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -780,12 +780,34 @@ export const getTokenDetails = async ({ contractId, publicKey, networkDetails, + fetchBalance, }: { contractId: string; publicKey: string; networkDetails: NetworkDetails; -}): Promise<{ name: string; decimals: number; symbol: string } | null> => { + fetchBalance?: boolean; +}): Promise<{ + name: string; + decimals: number; + symbol: string; + balance?: number; +} | null> => { try { + let balance; + if (fetchBalance && networkDetails.sorobanRpcUrl) { + const server = buildSorobanServer( + networkDetails.sorobanRpcUrl, + networkDetails.networkPassphrase, + ); + + balance = await getBalance( + contractId, + [new Address(publicKey).toScVal()], + server, + await getNewTxBuilder(publicKey, networkDetails, server), + ); + } + if (isCustomNetwork(networkDetails)) { if (!networkDetails.sorobanRpcUrl) { throw new SorobanRpcNotSupportedError(); @@ -816,6 +838,7 @@ export const getTokenDetails = async ({ name, symbol, decimals, + balance, }; } @@ -826,6 +849,11 @@ export const getTokenDetails = async ({ if (!response.ok) { throw new Error(data); } + + if (fetchBalance && balance) { + data.balance = balance; + } + return data; } catch (error) { console.error(error); diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx b/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx index 607554445b..6c29faf8d9 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx @@ -42,6 +42,8 @@ export type ManageAssetCurrency = StellarToml.Api.Currency & { contract?: string; icon?: string; isSuspicious?: boolean; + decimals?: number; + balance?: number; }; export interface NewAssetFlags { diff --git a/extension/src/popup/helpers/useTokenLookup.ts b/extension/src/popup/helpers/useTokenLookup.ts index 26168ccc2c..fb0832918d 100644 --- a/extension/src/popup/helpers/useTokenLookup.ts +++ b/extension/src/popup/helpers/useTokenLookup.ts @@ -77,6 +77,7 @@ export const useTokenLookup = ({ contractId, publicKey, networkDetails, + fetchBalance: true, }); } catch (e) { setAssetRows([]); @@ -104,6 +105,8 @@ export const useTokenLookup = ({ issuer, domain: "", name: tokenDetailsResponse.name, + balance: tokenDetailsResponse.balance, + decimals: tokenDetailsResponse.decimals, isSuspicious: isAssetSuspicious(scannedAsset), }, ]); diff --git a/extension/src/popup/views/AddToken/index.tsx b/extension/src/popup/views/AddToken/index.tsx index d9832e153b..e1c7ab9687 100644 --- a/extension/src/popup/views/AddToken/index.tsx +++ b/extension/src/popup/views/AddToken/index.tsx @@ -6,6 +6,7 @@ import { Loader, Text, } from "@stellar/design-system"; +import BigNumber from "bignumber.js"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; @@ -35,7 +36,7 @@ import { View } from "popup/basics/layout/View"; import { ManageAssetCurrency } from "popup/components/manageAssets/ManageAssetRows"; import { AssetNotifcation } from "popup/components/AssetNotification"; import { useTokenLookup } from "popup/helpers/useTokenLookup"; -import { isContractId } from "popup/helpers/soroban"; +import { formatTokenAmount, isContractId } from "popup/helpers/soroban"; import { isAssetSuspicious, scanAsset } from "popup/helpers/blockaid"; import "./styles.scss"; @@ -69,10 +70,28 @@ export const AddToken = () => { >(undefined); const assetCurrency: ManageAssetCurrency | undefined = assetRows[0]; + + const getAssetBalance = () => { + const code = assetCurrency?.code; + const balance = assetCurrency?.balance; + const decimals = assetCurrency?.decimals; + if (code && balance && decimals) { + const formattedTokenAmount = formatTokenAmount( + new BigNumber(balance), + decimals, + ); + return `+${formattedTokenAmount} ${code}`; + } + + return undefined; + }; + const assetCode = assetCurrency?.code || ""; const assetIssuer = assetCurrency?.issuer || ""; const assetName = assetTomlName || assetCurrency?.name?.split(":")[0]; const assetDomain = assetCurrency?.domain || ""; + const assetBalance = getAssetBalance(); + const hasBalance = assetBalance !== undefined; const isLoading = isSearching || assetIcon === undefined || assetTomlName === undefined; @@ -335,31 +354,32 @@ export const AddToken = () => { )}
- {/* TODO: fetch actual values */} -
- - {t("Simulated Balance Changes")} - -
-
- -
- - {t("Amount")} - + {hasBalance && ( +
- +1000.00 GO + {t("Simulated Balance Changes")} +
+
+ +
+ + {t("Amount")} + + + {assetBalance} + +
-
+ )}