diff --git a/@shared/api/external.ts b/@shared/api/external.ts index ace3fe5b60..0d6af1c168 100644 --- a/@shared/api/external.ts +++ b/@shared/api/external.ts @@ -40,6 +40,32 @@ export const requestPublicKey = async (): Promise<{ return { publicKey: response?.publicKey || "", error: response?.apiError }; }; +export const submitToken = async (args: { + contractId: string; + networkPassphrase?: string; +}): Promise<{ + contractId?: string; + 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 { + contractId: response.contractId, + error: response?.apiError, + }; +}; + export const submitTransaction = async ( transactionXdr: string, opts?: @@ -54,9 +80,9 @@ export const submitTransaction = async ( signerAddress: string; error?: FreighterApiError; }> => { - let network = ""; - let _accountToSign = ""; - let networkPassphrase = ""; + let network; + let _accountToSign; + let networkPassphrase; /* As of v1.3.0, this method now accepts an object as its second param. @@ -64,11 +90,11 @@ export const submitTransaction = async ( This logic maintains backwards compatibility for older versions */ if (typeof opts === "object") { - _accountToSign = opts.accountToSign || ""; - networkPassphrase = opts.networkPassphrase || ""; + _accountToSign = opts.accountToSign; + networkPassphrase = opts.networkPassphrase; } else { - network = opts || ""; - _accountToSign = accountToSign || ""; + network = opts; + _accountToSign = accountToSign; } let response; @@ -106,7 +132,7 @@ export const submitMessage = async ( }> => { let response; const _opts = opts || {}; - const accountToSign = _opts.address || ""; + const accountToSign = _opts.address; try { response = await sendMessageToContentScript({ blob, @@ -142,7 +168,7 @@ export const submitAuthEntry = async ( error?: FreighterApiError; }> => { const _opts = opts || {}; - const accountToSign = _opts.address || ""; + const accountToSign = _opts.address; let response; try { response = await sendMessageToContentScript({ diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 174a030a7f..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); @@ -957,6 +985,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/__tests__/addToken.test.js b/@stellar/freighter-api/src/__tests__/addToken.test.js new file mode 100644 index 0000000000..b6fef62c61 --- /dev/null +++ b/@stellar/freighter-api/src/__tests__/addToken.test.js @@ -0,0 +1,27 @@ +import * as extensionMessaging from "@shared/api/helpers/extensionMessaging"; +import { addToken } from "../addToken"; + +describe("addToken", () => { + it("returns contract id with no error", async () => { + const TEST_CONTRACT_ID = "TEST_CONTRACT_ID"; + extensionMessaging.sendMessageToContentScript = jest.fn().mockReturnValue({ + contractId: TEST_CONTRACT_ID, + }); + const response = await addToken({ contractId: TEST_CONTRACT_ID }); + expect(response).toEqual({ + contractId: TEST_CONTRACT_ID, + }); + }); + it("returns an error", async () => { + const TEST_ERROR = "TEST_ERROR"; + extensionMessaging.sendMessageToContentScript = jest + .fn() + .mockReturnValue({ contractId: "", apiError: TEST_ERROR }); + const response = await addToken({ contractId: "" }); + + expect(response).toEqual({ + contractId: "", + error: TEST_ERROR, + }); + }); +}); diff --git a/@stellar/freighter-api/src/__tests__/index.test.js b/@stellar/freighter-api/src/__tests__/index.test.js index 13de21a076..bd7c32aafe 100644 --- a/@stellar/freighter-api/src/__tests__/index.test.js +++ b/@stellar/freighter-api/src/__tests__/index.test.js @@ -4,6 +4,7 @@ describe("freighter API", () => { it("has keys", () => { expect(typeof FreighterAPI.isConnected).toBe("function"); expect(typeof FreighterAPI.getAddress).toBe("function"); + expect(typeof FreighterAPI.addToken).toBe("function"); expect(typeof FreighterAPI.signTransaction).toBe("function"); expect(typeof FreighterAPI.signMessage).toBe("function"); expect(typeof FreighterAPI.signAuthEntry).toBe("function"); diff --git a/@stellar/freighter-api/src/addToken.ts b/@stellar/freighter-api/src/addToken.ts new file mode 100644 index 0000000000..8fabe5339b --- /dev/null +++ b/@stellar/freighter-api/src/addToken.ts @@ -0,0 +1,21 @@ +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<{ contractId: string } & { error?: FreighterApiError }> => { + if (isBrowser) { + const req = await submitToken(args); + + if (req.error) { + return { contractId: "", error: req.error }; + } + + return { contractId: req.contractId || "" }; + } + + return { contractId: "", 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/guide/addAsset.md b/docs/docs/guide/addAsset.md index 405ffe5b6d..fabae57945 100644 --- a/docs/docs/guide/addAsset.md +++ b/docs/docs/guide/addAsset.md @@ -1,10 +1,10 @@ --- id: addAsset -title: Adding an Asset +title: Manually adding an Asset slug: /add-asset --- -## Adding a new asset to your account +## Manually adding a new asset to your account If you would like to add an asset to your account balances, you can do that by clicking "Manage Assets" on the account balances screen. This will navigate you to a screen where you can see your current assets and an "Add Asset" button. You can click that to find and add another asset. diff --git a/docs/docs/guide/addToken.md b/docs/docs/guide/addToken.md new file mode 100644 index 0000000000..67705c3d7c --- /dev/null +++ b/docs/docs/guide/addToken.md @@ -0,0 +1,16 @@ +--- +id: addToken +title: Adding a Token through the API +slug: /add-token +--- + +## Adding a Soroban Token through the `addToken` API + +You can trigger an "add token" workflow by utilizing the [addToken API](https://docs.freighter.app/docs/playground/addToken). +The API takes a **Contract Id** and a (optional) **Network Passphrase** as input which Freighter will use to load token details like **symbol, name, decimals and balance**. If the passphrase is ommited it will default to Pubnet's passphrase. + +Freighter will then show these token details on a modal popup along with any applicable warnings so you can review it and verify the token's legitimacy. You will need to approve it in order for the token to be added. + +Freighter will return the same Contract Id to the application that called the API after user confirmation in case the request succeeds, otherwise it'll return an error. + +After adding the token Freighter will keep track of its balance and display it along with the other existing account balances. diff --git a/docs/docs/guide/usingFreighterWebApp.md b/docs/docs/guide/usingFreighterWebApp.md index d07ad57e83..2fcf155857 100644 --- a/docs/docs/guide/usingFreighterWebApp.md +++ b/docs/docs/guide/usingFreighterWebApp.md @@ -22,6 +22,7 @@ import { signAuthEntry, signTransaction, signBlob, + addToken, } from "@stellar/freighter-api"; ``` @@ -172,6 +173,39 @@ const retrieveNetwork = async () => { const result = retrieveNetwork(); ``` +### getNetworkDetails + +#### `getNetworkDetails() -> >` + +Similar to `getNetwork()`, this function retrieves network information from Freighter. However, while `getNetwork()` returns only the network name (such as "PUBLIC" or "TESTNET"), `getNetworkDetails()` provides comprehensive network configuration including the full network URL, network passphrase, and Soroban RPC URL when available. + +```typescript +import { + isConnected, + getNetwork, + getNetworkDetails, +} from "@stellar/freighter-api"; + +const checkNetworks = async () => { + if (!(await isConnected())) { + return; + } + + // Basic network name + const network = await getNetwork(); + console.log("Network:", network); // e.g., "TESTNET" + + // Detailed network information + const details = await getNetworkDetails(); + console.log("Network:", details.network); // e.g., "TESTNET" + console.log("Network URL:", details.networkUrl); // e.g., "https://horizon-testnet.stellar.org" + console.log("Network Passphrase:", details.networkPassphrase); // e.g., "Test SDF Network ; September 2015" + console.log("Soroban RPC URL:", details.sorobanRpcUrl); // e.g., "https://soroban-testnet.stellar.org" +}; +``` + +Use this method when you need detailed network configuration information, particularly when working with Soroban smart contracts or when the specific network endpoints are required. + ### signTransaction #### `signTransaction(xdr: string, opts?: { network?: string, networkPassphrase?: string, address?: string }) -> >` @@ -291,6 +325,43 @@ const transactionToSubmit = TransactionBuilder.fromXDR( const response = await server.submitTransaction(transactionToSubmit); ``` +### addToken + +#### `addToken({ contractId: string, networkPassphrase?: string }) -> >` + +This function allows you to trigger an "add token" workflow to add a Soroban token to the user's Freighter wallet. It takes a contract ID as a required parameter and an optional network passphrase. If the network passphrase is omitted, it defaults to Pubnet's passphrase. + +When called, Freighter will load the token details (symbol, name, decimals, and balance) from the contract and display them in a modal popup for user review. The user can then verify the token's legitimacy and approve adding it to their wallet. After approval, Freighter will track the token's balance and display it alongside other account balances. + +```typescript +import { isConnected, addToken } from "@stellar/freighter-api"; + +const addSorobanToken = async () => { + if (!(await isConnected())) { + return; + } + + const result = await addToken({ + contractId: "CC...ABCD", // The Soroban token contract ID + networkPassphrase: "Test SDF Network ; September 2015", // Optional, defaults to Pubnet + }); + + if (result.error) { + console.error(result.error); + return; + } + + console.log( + `Successfully added token with contract ID: ${result.contractId}` + ); +}; +``` + +The function returns a Promise that resolves to an object containing either: + +- The contract ID of the added token on success +- An error message if the request fails or the user rejects it + ### WatchWalletChanges #### `WatchWalletChanges -> new WatchWalletChanges(timeout?: number)` diff --git a/docs/docs/playground/addToken.mdx b/docs/docs/playground/addToken.mdx new file mode 100644 index 0000000000..b7b1fb0ab7 --- /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..bfc2139f45 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -10,6 +10,7 @@ const playgroundPaths = [ "signTransaction", "signAuthEntry", "signMessage", + "addToken", "watchWalletChanges", ]; @@ -18,6 +19,7 @@ const introPaths = ["introduction", "gettingStarted"]; const userGuidePaths = [ "account", "advancedSettings", + "addToken", "addAsset", "makePayment", "signXdr", diff --git a/extension/src/background/helpers/account.ts b/extension/src/background/helpers/account.ts index a166508a05..7cf3d60eca 100644 --- a/extension/src/background/helpers/account.ts +++ b/extension/src/background/helpers/account.ts @@ -235,18 +235,20 @@ export const subscribeAccount = async (publicKey: string) => { try { const networkDetails = await getNetworkDetails(); + + /* eslint-disable @typescript-eslint/naming-convention */ const options = { method: "POST", headers: { - // eslint-disable-next-line "Content-Type": "application/json", }, body: JSON.stringify({ - // eslint-disable-next-line pub_key: publicKey, network: networkDetails.network, }), }; + /* eslint-enable @typescript-eslint/naming-convention */ + const res = await fetch(`${INDEXER_URL}/subscription/account`, options); const subsByKeyId = { ...hasAccountSubByKeyId, @@ -266,26 +268,30 @@ export const subscribeAccount = async (publicKey: string) => { return { publicKey }; }; -export const subscribeTokenBalance = async ( - publicKey: string, - contractId: string, -) => { +export const subscribeTokenBalance = async ({ + publicKey, + contractId, + network, +}: { + publicKey: string; + contractId: string; + network: string; +}) => { try { - const networkDetails = await getNetworkDetails(); + /* eslint-disable @typescript-eslint/naming-convention */ const options = { method: "POST", headers: { - // eslint-disable-next-line "Content-Type": "application/json", }, body: JSON.stringify({ - // eslint-disable-next-line pub_key: publicKey, - // eslint-disable-next-line contract_id: contractId, - network: networkDetails.network, + network, }), }; + /* eslint-enable @typescript-eslint/naming-convention */ + const res = await fetch( `${INDEXER_URL}/subscription/token-balance`, options, @@ -301,20 +307,30 @@ export const subscribeTokenBalance = async ( } }; -export const subscribeTokenHistory = async ( - publicKey: string, - contractId: string, -) => { +export const subscribeTokenHistory = async ({ + publicKey, + contractId, + network, +}: { + publicKey: string; + contractId: string; + network: string; +}) => { try { + /* eslint-disable @typescript-eslint/naming-convention */ const options = { method: "POST", headers: { - // eslint-disable-next-line "Content-Type": "application/json", }, - // eslint-disable-next-line - body: JSON.stringify({ pub_key: publicKey, contract_id: contractId }), + body: JSON.stringify({ + pub_key: publicKey, + contract_id: contractId, + network, + }), }; + /* eslint-enable @typescript-eslint/naming-convention */ + const res = await fetch(`${INDEXER_URL}/subscription/token`, options); if (!res.ok) { diff --git a/extension/src/background/messageListener/freighterApiMessageListener.ts b/extension/src/background/messageListener/freighterApiMessageListener.ts index 9e71feec4c..54c3b6e92d 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,78 @@ export const freighterApiMessageListener = ( } }; + const submitToken = async () => { + try { + const { contractId, networkPassphrase: reqNetworkPassphrase } = + request as ExternalRequestToken; + + const networkPassphrase = + reqNetworkPassphrase || MAINNET_NETWORK_DETAILS.networkPassphrase; + + 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({ + apiError: FreighterApiInternalError, + }); + } else { + browser.windows.onRemoved.addListener(() => + resolve({ + apiError: FreighterApiDeclinedError, + }), + ); + } + const response = (success: boolean) => { + if (success) { + if (!isDomainListedAllowed) { + allowList.push(punycodedDomain); + localStore.setItem(ALLOWLIST_ID, allowList.join()); + } + resolve({ + contractId, + }); + } + + resolve({ + apiError: FreighterApiDeclinedError, + }); + }; + + responseQueue.push(response); + }); + } catch (e) { + return { + apiError: FreighterApiInternalError, + }; + } + }; + const submitTransaction = async () => { try { const { @@ -210,7 +292,10 @@ export const freighterApiMessageListener = ( }); } - const server = stellarSdkServer(networkUrl, networkPassphrase); + const server = stellarSdkServer( + networkUrl, + networkPassphrase || transaction.networkPassphrase, + ); try { await server.checkMemoRequired(transaction as StellarSdk.Transaction); @@ -280,7 +365,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 +384,7 @@ export const freighterApiMessageListener = ( const allowList = allowListStr.split(","); const isDomainListedAllowed = await isSenderAllowed({ sender }); - const blobData = { + const blobData: MessageToSign = { isDomainListedAllowed, domain, tab, @@ -382,7 +466,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 +636,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..92ad5a5f0e 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,35 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { return { error: "Session timed out" }; }; + const addToken = async () => { + const publicKey = publicKeySelector(sessionStore.getState()); + const networkDetails = await getNetworkDetails(); + + if (publicKey.length) { + const tokenInfo = tokenQueue.pop(); + + if (!tokenInfo?.contractId) { + throw Error("Missing contract id"); + } + + const response = await addTokenWithContractId({ + contractId: tokenInfo.contractId, + network: networkDetails.network, + 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 +1136,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 +1434,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)) || ""; @@ -1422,8 +1462,8 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { } try { - await subscribeTokenBalance(publicKey, tokenId); - await subscribeTokenHistory(publicKey, tokenId); + await subscribeTokenBalance({ publicKey, contractId: tokenId, network }); + await subscribeTokenHistory({ publicKey, contractId: tokenId, network }); accountTokenIdList.push(tokenId); await localStore.setItem(TOKEN_ID_LIST, { @@ -1787,6 +1827,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/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/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 187f3e95b6..2908bee1cc 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,10 @@ export const METRIC_NAMES = { invalidAuthEntry: "invalid authorization entry", + tokenAddedApi: "user added token through api", + tokenFailedApi: "failed adding 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..7aaf26ec77 --- /dev/null +++ b/extension/src/popup/helpers/useSetupAddTokenFlow.ts @@ -0,0 +1,101 @@ +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"; + +type Params = { + rejectToken: typeof rejectToken; + addToken: typeof addToken; + assetCode: string; + assetIssuer: string; +}; + +type Response = { + isConfirming: boolean; + isPasswordRequired: boolean; + setIsPasswordRequired: (value: boolean) => void; + verifyPasswordThenAddToken: (password: string) => Promise; + handleApprove: () => Promise; + rejectAndClose: () => void; +}; + +export const useSetupAddTokenFlow = ({ + rejectToken: rejectTokenFn, + addToken: addTokenFn, + assetCode, + assetIssuer, +}: Params): Response => { + 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()); + }; + + try { + if (StrKey.isValidEd25519PublicKey(assetIssuer)) { + await changeTrustline(true, addTokenDispatch); + } else { + await addTokenDispatch(); + } + await emitMetric(METRIC_NAMES.tokenAddedApi); + } catch (e) { + console.error(e); + await emitMetric(METRIC_NAMES.tokenFailedApi); + } + + 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..fb0832918d --- /dev/null +++ b/extension/src/popup/helpers/useTokenLookup.ts @@ -0,0 +1,169 @@ +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, + fetchBalance: true, + }); + } 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, + balance: tokenDetailsResponse.balance, + decimals: tokenDetailsResponse.decimals, + 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..6492b3495a --- /dev/null +++ b/extension/src/popup/views/AddToken/index.tsx @@ -0,0 +1,429 @@ +import { + Asset, + Badge, + Button, + Icon, + 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"; +import { useSelector } from "react-redux"; +import { StellarToml } from "stellar-sdk"; + +import { BlockAidScanAssetResult } from "@shared/api/types"; +import { getIconUrlFromIssuer } from "@shared/api/helpers/getIconUrlFromIssuer"; + +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, + BlockaidAssetWarning, + FirstTimeWarningMessage, +} 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 { AssetNotifcation } from "popup/components/AssetNotification"; +import { useTokenLookup } from "popup/helpers/useTokenLookup"; +import { formatTokenAmount, isContractId } from "popup/helpers/soroban"; +import { isAssetSuspicious, scanAsset } from "popup/helpers/blockaid"; + +import "./styles.scss"; + +export const AddToken = () => { + const location = useLocation(); + const params = parsedSearchParam(location.search) as TokenToAdd; + const { + url, + contractId, + networkPassphrase: entryNetworkPassphrase, + isDomainListedAllowed, + } = params; + + const { t } = useTranslation(); + const isNonSSLEnabled = useSelector(isNonSSLEnabledSelector); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const { networkName, networkPassphrase } = networkDetails; + + 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] = + useState(false); + const [blockaidData, setBlockaidData] = useState< + BlockAidScanAssetResult | undefined + >(undefined); + const [errorMessage, setErrorMessage] = useState(""); + + 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; + + const { + isConfirming, + isPasswordRequired, + setIsPasswordRequired, + verifyPasswordThenAddToken, + handleApprove, + rejectAndClose, + } = useSetupAddTokenFlow({ + rejectToken, + addToken, + assetCode, + assetIssuer, + }); + + const { handleTokenLookup } = useTokenLookup({ + setAssetRows, + setIsSearching, + setIsVerifiedToken, + setIsVerificationInfoShowing, + }); + + useEffect(() => { + if (!isContractId(contractId)) { + setErrorMessage( + t( + "This is not a valid contract id. Please try again with a different value.", + ), + ); + return; + } + + handleTokenLookup(contractId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contractId, handleTokenLookup]); + + useEffect(() => { + if (!assetCode || !assetIssuer || blockaidData) { + return; + } + + const getBlockaidData = async () => { + const scannedAsset = await scanAsset( + `${assetCode}-${assetIssuer}`, + networkDetails, + ); + + // "Benign" | "Warning" | "Malicious" | "Spam" + // scannedAsset.result_type = "Warning"; + + if (isAssetSuspicious(scannedAsset)) { + setBlockaidData(scannedAsset); + } + }; + + 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]); + + 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 ( + 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} + /> + ); + } + + if (errorMessage) { + return ( + + +
+ + {errorMessage} + +
+
+
+ ); + } + + if (isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + return ( + + +
+
+
+ {assetIcon && ( +
+ +
+ )} + + {!assetIcon && assetCode && ( +
+ + {assetCode.slice(0, 2)} + +
+ )} + + {assetCurrency && ( + + {assetName || assetCode} + + )} + {assetDomain && ( + + {assetDomain} + + )} +
+ } + iconPosition="left" + > + {t("Approve Token")} + +
+
+ + {!isDomainListedAllowed && } + + {assetCurrency && isVerificationInfoShowing && ( + + )} + + {blockaidData && ( + + )} + +
+ + {t("Asset info")} + + + {assetCode && ( +
+
+ +
+ + {t("Symbol")} + + + {assetCode} + +
+ )} + + {assetName && assetName !== assetCode && ( +
+
+ +
+ + {t("Name")} + + + {assetName} + +
+ )} +
+ + {hasBalance && ( +
+ + {t("Balance Info")} + +
+
+ +
+ + {t("Amount")} + + + {assetBalance} + +
+
+ )} + +
+ + +
+
+
+
+
+ ); +}; diff --git a/extension/src/popup/views/AddToken/styles.scss b/extension/src/popup/views/AddToken/styles.scss new file mode 100644 index 0000000000..00de9bb63f --- /dev/null +++ b/extension/src/popup/views/AddToken/styles.scss @@ -0,0 +1,105 @@ +@use "../../styles/utils.scss" as *; + +.AddToken { + position: relative; + flex-direction: column; + display: flex; + height: 100%; + justify-content: flex-end; + + &__loader, + &__error { + 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; + } + + &__icon-logo { + margin-bottom: pxToRem(8px); + align-items: center; + } + + &__code-logo { + height: pxToRem(40px); + width: pxToRem(40px); + border-radius: pxToRem(40px); + margin-bottom: pxToRem(8px); + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--sds-clr-gray-06); + color: var(--sds-clr-gray-09); + } + + &__badge { + margin-top: pxToRem(14px); + margin-bottom: pxToRem(24px); + } + + &--logo-label, + &--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; + } + } +}