diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 2e91ad488..2a4f4c4bf 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -518,13 +518,15 @@ export const getSorobanTokenBalance = async ( export const getAccountBalancesStandalone = async ({ publicKey, networkDetails, + isMainnet, }: { publicKey: string; networkDetails: NetworkDetails; -}): Promise => { + isMainnet: boolean; +}) => { const { network, networkUrl, networkPassphrase } = networkDetails; - let balances: any = null; + let balances = null; let isFunded = null; let subentryCount = 0; @@ -532,7 +534,10 @@ export const getAccountBalancesStandalone = async ({ const server = stellarSdkServer(networkUrl, networkPassphrase); const accountSummary = await server.accounts().accountId(publicKey).call(); - const displayableBalances = makeDisplayableBalances(accountSummary); + const displayableBalances = await makeDisplayableBalances( + accountSummary, + isMainnet, + ); const sponsor = accountSummary.sponsor ? { sponsor: accountSummary.sponsor } : {}; @@ -556,20 +561,19 @@ export const getAccountBalancesStandalone = async ({ // eslint-disable-next-line no-plusplus for (let i = 0; i < Object.keys(resp.balances).length; i++) { const k = Object.keys(resp.balances)[i]; - const v: any = resp.balances[k]; - if (v.liquidity_pool_id) { + const v = resp.balances[k]; + if (v.liquidityPoolId) { const server = stellarSdkServer(networkUrl, networkPassphrase); // eslint-disable-next-line no-await-in-loop const lp = await server .liquidityPools() - .liquidityPoolId(v.liquidity_pool_id) + .liquidityPoolId(v.liquidityPoolId) .call(); balances[k] = { ...balances[k], - liquidityPoolId: v.liquidity_pool_id, + liquidityPoolId: v.liquidityPoolId, reserves: lp.reserves, }; - delete balances[k].liquidity_pool_id; } } isFunded = true; @@ -1172,14 +1176,10 @@ export const saveAllowList = async ({ export const saveSettings = async ({ isDataSharingAllowed, isMemoValidationEnabled, - isSafetyValidationEnabled, - isValidatingSafeAssetsEnabled, isNonSSLEnabled, }: { isDataSharingAllowed: boolean; isMemoValidationEnabled: boolean; - isSafetyValidationEnabled: boolean; - isValidatingSafeAssetsEnabled: boolean; isNonSSLEnabled: boolean; }): Promise => { let response = { @@ -1188,13 +1188,12 @@ export const saveSettings = async ({ networkDetails: MAINNET_NETWORK_DETAILS, networksList: DEFAULT_NETWORKS, isMemoValidationEnabled: true, - isSafetyValidationEnabled: true, - isValidatingSafeAssetsEnabled: true, isRpcHealthy: false, userNotification: { enabled: false, message: "" }, settingsState: SettingsState.IDLE, isSorobanPublicEnabled: false, isNonSSLEnabled: false, + isBlockaidAnnounced: false, error: "", }; @@ -1202,8 +1201,6 @@ export const saveSettings = async ({ response = await sendMessageToBackground({ isDataSharingAllowed, isMemoValidationEnabled, - isSafetyValidationEnabled, - isValidatingSafeAssetsEnabled, isNonSSLEnabled, type: SERVICE_TYPES.SAVE_SETTINGS, }); @@ -1351,16 +1348,9 @@ export const loadSettings = (): Promise< type: SERVICE_TYPES.LOAD_SETTINGS, }); -export const getBlockedDomains = async () => { - const resp = await sendMessageToBackground({ - type: SERVICE_TYPES.GET_BLOCKED_DOMAINS, - }); - return resp; -}; - -export const getBlockedAccounts = async () => { +export const getMemoRequiredAccounts = async () => { const resp = await sendMessageToBackground({ - type: SERVICE_TYPES.GET_BLOCKED_ACCOUNTS, + type: SERVICE_TYPES.GET_MEMO_REQUIRED_ACCOUNTS, }); return resp; }; @@ -1460,3 +1450,63 @@ export const modifyAssetsList = async ({ return { assetsLists: response.assetsLists, error: response.error }; }; + +export const simulateTokenTransfer = async (args: { + address: string; + publicKey: string; + memo: string; + params: { + publicKey: string; + destination: string; + amount: number; + }; + networkDetails: NetworkDetails; + transactionFee: string; +}) => { + const { address, publicKey, memo, params, networkDetails } = args; + const options = { + method: "POST", + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + "Content-Type": "application/json", + }, + body: JSON.stringify({ + address, + // eslint-disable-next-line @typescript-eslint/naming-convention + pub_key: publicKey, + memo, + params, + // eslint-disable-next-line @typescript-eslint/naming-convention + network_url: networkDetails.sorobanRpcUrl, + // eslint-disable-next-line @typescript-eslint/naming-convention + network_passphrase: networkDetails.networkPassphrase, + }), + }; + const res = await fetch(`${INDEXER_URL}/simulate-token-transfer`, options); + const response = await res.json(); + return { + ok: res.ok, + response, + }; +}; + +export const saveIsBlockaidAnnounced = async ({ + isBlockaidAnnounced, +}: { + isBlockaidAnnounced: boolean; +}) => { + let response = { + error: "", + isBlockaidAnnounced: false, + }; + + response = await sendMessageToBackground({ + type: SERVICE_TYPES.SAVE_IS_BLOCKAID_ANNOUNCED, + isBlockaidAnnounced, + }); + + return { + isBlockaidAnnounced: response.isBlockaidAnnounced, + error: response.error, + }; +}; diff --git a/@shared/api/package.json b/@shared/api/package.json index 811a34b31..a81a415f0 100644 --- a/@shared/api/package.json +++ b/@shared/api/package.json @@ -4,6 +4,7 @@ "version": "1.0.0", "private": true, "dependencies": { + "@blockaid/client": "^0.25.0", "@stellar/js-xdr": "^3.1.1", "bignumber.js": "^9.1.1", "prettier": "^2.0.5", diff --git a/@shared/api/types.ts b/@shared/api/types.ts index ec4b219b8..405371f3e 100644 --- a/@shared/api/types.ts +++ b/@shared/api/types.ts @@ -1,5 +1,6 @@ import BigNumber from "bignumber.js"; import { AssetType as SdkAssetType, Horizon } from "stellar-sdk"; +import Blockaid from "@blockaid/client"; import { SERVICE_TYPES, EXTERNAL_SERVICE_TYPES } from "../constants/services"; import { APPLICATION_STATE } from "../constants/applicationState"; @@ -75,8 +76,7 @@ export interface Response { recentAddresses: string[]; hardwareWalletType: WalletType; bipPath: string; - blockedDomains: BlockedDomains; - blockedAccounts: BlockedAccount[]; + memoRequiredAccounts: MemoRequiredAccount[]; assetDomain: string; contractId: string; tokenId: string; @@ -90,13 +90,10 @@ export interface Response { isMergeSelected: boolean; recommendedFee: string; isNonSSLEnabled: boolean; + isBlockaidAnnounced: boolean; } -export interface BlockedDomains { - [key: string]: boolean; -} - -export interface BlockedAccount { +export interface MemoRequiredAccount { address: string; name: string; domain: string | null; @@ -144,10 +141,9 @@ export enum AccountType { export interface Preferences { isDataSharingAllowed: boolean; isMemoValidationEnabled: boolean; - isSafetyValidationEnabled: boolean; - isValidatingSafeAssetsEnabled: boolean; networksList: NetworkDetails[]; isNonSSLEnabled: boolean; + isBlockaidAnnounced: boolean; error: string; } @@ -237,9 +233,27 @@ export interface Balance { total: BigNumber; buyingLiabilities: string; sellingLiabilities: string; + liquidityPoolId?: string; + reserves?: Horizon.HorizonApi.Reserve[]; + contractId?: string; + blockaidData: BlockAidScanAssetResult; +} + +export type BlockAidScanAssetResult = Blockaid.TokenScanResponse; +export interface BlockAidScanAssetResultIndex { + [key: string]: Blockaid.TokenScanResponse.AttackTypes & + Blockaid.TokenScanResponse.Fees & + Blockaid.TokenScanResponse.BasicMetadataToken & + Blockaid.TokenScanResponse.FinancialStats & + Blockaid.TokenScanResponse.TradingLimits; } +export type BlockAidScanSiteResult = Blockaid.SiteScanResponse; +export type BlockAidScanTxResult = Blockaid.StellarTransactionScanResponse; +export type BlockAidBulkScanAssetResult = Blockaid.TokenBulkScanResponse; + export interface AssetBalance extends Balance { + limit: BigNumber; token: AssetToken; sponsor?: string; } @@ -250,8 +264,10 @@ export interface NativeBalance extends Balance { } export interface TokenBalance extends AssetBalance { - decimals: number; name: string; + symbol: string; + decimals: number; + total: BigNumber; } export interface BalanceMap { diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 1672668c7..ddade5db5 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -33,8 +33,7 @@ export enum SERVICE_TYPES { CACHE_ASSET_ICON = "CACHE_ASSET_ICON", GET_CACHED_ASSET_DOMAIN = "GET_CACHED_ASSET_DOMAIN", CACHE_ASSET_DOMAIN = "CACHE_ASSET_DOMAIN", - GET_BLOCKED_ACCOUNTS = "GET_BLOCKED_ACCOUNTS", - GET_BLOCKED_DOMAINS = "GET_BLOCKED_DOMAINS", + GET_MEMO_REQUIRED_ACCOUNTS = "GET_MEMO_REQUIRED_ACCOUNTS", ADD_CUSTOM_NETWORK = "ADD_CUSTOM_NETWORK", CHANGE_NETWORK = "CHANGE_NETWORK", REMOVE_CUSTOM_NETWORK = "REMOVE_CUSTOM_NETWORK", @@ -48,6 +47,7 @@ export enum SERVICE_TYPES { MIGRATE_ACCOUNTS = "MIGRATE_ACCOUNTS", ADD_ASSETS_LIST = "ADD_ASSETS_LIST", MODIFY_ASSETS_LIST = "MODIFY_ASSETS_LIST", + SAVE_IS_BLOCKAID_ANNOUNCED = "SAVE_IS_BLOCKAID_ANNOUNCED", } export enum EXTERNAL_SERVICE_TYPES { diff --git a/@shared/helpers/stellar.ts b/@shared/helpers/stellar.ts index 9c9824110..fcce5f435 100644 --- a/@shared/helpers/stellar.ts +++ b/@shared/helpers/stellar.ts @@ -2,12 +2,17 @@ import BigNumber from "bignumber.js"; import * as StellarSdk from "stellar-sdk"; import * as StellarSdkNext from "stellar-sdk-next"; -import { BalanceMap } from "@shared/api/types"; +import { + BalanceMap, + AssetBalance, + BlockAidScanAssetResult, +} from "@shared/api/types"; import { BASE_RESERVE, BASE_RESERVE_MIN_COUNT, NetworkDetails, } from "@shared/constants/stellar"; +import { INDEXER_URL } from "@shared/constants/mercury"; export const CUSTOM_NETWORK = "STANDALONE"; @@ -42,93 +47,131 @@ export function getBalanceIdentifier( } } -export function makeDisplayableBalances( +export const defaultBlockaidScanAssetResult: BlockAidScanAssetResult = { + /* eslint-disable @typescript-eslint/naming-convention */ + address: "", + chain: "stellar", + attack_types: {}, + fees: {}, + malicious_score: "0.0", + metadata: {}, + financial_stats: {}, + trading_limits: {}, + result_type: "Benign", + features: [{ description: "", feature_id: "METADATA", type: "Benign" }], + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +export const makeDisplayableBalances = async ( accountDetails: StellarSdk.Horizon.ServerApi.AccountRecord, -): BalanceMap { + isMainnet: boolean, +) => { const { balances, subentry_count, num_sponsored, num_sponsoring } = accountDetails; - const displayableBalances = Object.values(balances).reduce( - (memo, balance) => { - const identifier = getBalanceIdentifier(balance); - const total = new BigNumber(balance.balance); - - let sellingLiabilities = new BigNumber(0); - let buyingLiabilities = new BigNumber(0); - let available; - - if ("selling_liabilities" in balance) { - sellingLiabilities = new BigNumber(balance.selling_liabilities); - available = total.minus(sellingLiabilities); - } - - if ("buying_liabilities" in balance) { - buyingLiabilities = new BigNumber(balance.buying_liabilities); - } + const displayableBalances = {} as BalanceMap; - if (identifier === "native") { - // define the native balance line later - return { - ...memo, - native: { - token: { - type: "native", - code: "XLM", - }, - total, - available, - sellingLiabilities, - buyingLiabilities, - minimumBalance: new BigNumber(BASE_RESERVE_MIN_COUNT) - .plus(subentry_count) - .plus(num_sponsoring) - .minus(num_sponsored) - .times(BASE_RESERVE) - .plus(sellingLiabilities), - }, - }; - } + let blockaidScanResults: { [key: string]: BlockAidScanAssetResult } = {}; - const liquidityPoolBalance = - balance as StellarSdk.Horizon.HorizonApi.BalanceLineLiquidityPool; - if (identifier.includes(":lp")) { - return { - ...memo, - [identifier]: { - liquidity_pool_id: liquidityPoolBalance.liquidity_pool_id, - total, - limit: new BigNumber(liquidityPoolBalance.limit), - }, - }; + if (isMainnet) { + const url = new URL(`${INDEXER_URL}/scan-asset-bulk`); + for (const balance of balances) { + const balanceId = getBalanceIdentifier(balance); + if (balanceId !== "native" && !balanceId.includes(":lp")) { + url.searchParams.append("asset_ids", balanceId.replace(":", "-")); } + } + + try { + const response = await fetch(url.href); + const data = await response.json(); + blockaidScanResults = data.data.results; + } catch (e) { + console.error(e); + } + } - const assetBalance = - balance as StellarSdk.Horizon.HorizonApi.BalanceLineAsset; - const assetSponsor = assetBalance.sponsor - ? { sponsor: assetBalance.sponsor } - : {}; - - return { - ...memo, - [identifier]: { - token: { - type: assetBalance.asset_type, - code: assetBalance.asset_code, - issuer: { - key: assetBalance.asset_issuer, - }, - }, - sellingLiabilities, - buyingLiabilities, - total, - limit: new BigNumber(assetBalance.limit), - available: total.minus(sellingLiabilities), - ...assetSponsor, + for (let i = 0; i < balances.length; i++) { + const balance = balances[i]; + const identifier = getBalanceIdentifier(balance); + const total = new BigNumber(balance.balance); + + let sellingLiabilities = "0"; + let buyingLiabilities = "0"; + let available = new BigNumber("0"); + + if ("selling_liabilities" in balance) { + sellingLiabilities = new BigNumber( + balance.selling_liabilities, + ).toString(); + available = total.minus(sellingLiabilities); + } + + if ("buying_liabilities" in balance) { + buyingLiabilities = new BigNumber(balance.buying_liabilities).toString(); + } + + if (identifier === "native") { + // define the native balance line later + + displayableBalances.native = { + token: { + type: "native", + code: "XLM", }, + total, + available, + sellingLiabilities, + buyingLiabilities, + minimumBalance: new BigNumber(BASE_RESERVE_MIN_COUNT) + .plus(subentry_count) + .plus(num_sponsoring) + .minus(num_sponsored) + .times(BASE_RESERVE) + .plus(sellingLiabilities), + blockaidData: defaultBlockaidScanAssetResult, }; - }, - {}, - ); + continue; + } + + const liquidityPoolBalance = + balance as StellarSdk.Horizon.HorizonApi.BalanceLineLiquidityPool; + if (identifier.includes(":lp")) { + displayableBalances[identifier] = { + liquidityPoolId: liquidityPoolBalance.liquidity_pool_id, + total, + limit: new BigNumber(liquidityPoolBalance.limit), + } as AssetBalance; + continue; + } + + const assetBalance = + balance as StellarSdk.Horizon.HorizonApi.BalanceLineAsset; + const assetSponsor = assetBalance.sponsor + ? { sponsor: assetBalance.sponsor } + : {}; + + displayableBalances[identifier] = { + token: { + type: assetBalance.asset_type, + code: assetBalance.asset_code, + issuer: { + key: assetBalance.asset_issuer, + }, + }, + sellingLiabilities, + buyingLiabilities, + total, + limit: new BigNumber(assetBalance.limit), + available: total.minus(sellingLiabilities), + blockaidData: + blockaidScanResults[identifier.replace(":", "-")] || + defaultBlockaidScanAssetResult, + ...assetSponsor, + }; + + continue; + } - return displayableBalances as BalanceMap; -} + return displayableBalances; +}; diff --git a/docs/docs/guide/account.md b/docs/docs/guide/account.md new file mode 100644 index 000000000..b334e2c96 --- /dev/null +++ b/docs/docs/guide/account.md @@ -0,0 +1,21 @@ +--- +id: account +title: Account & Network Settings +slug: /account +--- + +## Adding a new account + +In order to use another account that is derived from your seed phrase to Freighter, you can do the following: + +- From the account balances page, you can click on the "accounts" dropdown from the identicon in the upper left corner of the screen in order to open the account options. +- From the options page, you can "Import a Stellar secret key" if you already have your secret key or you can "Create new Stellar wallet" if you want to generate one. Please note - accounts imported by secret key may not be derived from your seed phrase. +- After importing or generating a new account, you will land on the "account balances" screen where you should see an unfunded account unless you have used the account previously. + +## Custom RPC + +Freighter configures it's own RPCs for the base networks, but you can configure a "custom network" in order to bring your own [Horizon instance](https://developers.stellar.org/docs/data/horizon) and/or [RPC instance](https://github.com/stellar/soroban-rpc) into Freighter as a data source. + +You can click on the on current network using the tab in the upper right corner of the screen, and select "Add custom network" from the dropdown. +At this point you can configure a custom network to be used in Freighter. +You should get your network settings(passphrase, friendbot URL, etc) from your Horizon/RPC provider. diff --git a/docs/docs/guide/addAsset.md b/docs/docs/guide/addAsset.md new file mode 100644 index 000000000..405ffe5b6 --- /dev/null +++ b/docs/docs/guide/addAsset.md @@ -0,0 +1,14 @@ +--- +id: addAsset +title: Adding an Asset +slug: /add-asset +--- + +## 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. + +If you don't find your asset by name, you can also enter the asset details in manually by providing the issuer's public key or the asset contract's ID in the case of [SEP-41 tokens.](https://developers.stellar.org/docs/tokens/stellar-asset-contract) + +Once you've found and confirmed your asset, you can click "Add Asset" to add it to your account balances screen. diff --git a/docs/docs/guide/advancedSettings.md b/docs/docs/guide/advancedSettings.md new file mode 100644 index 000000000..e1da3dd3e --- /dev/null +++ b/docs/docs/guide/advancedSettings.md @@ -0,0 +1,24 @@ +--- +id: advancedSettings +title: Advanced Settings +slug: /adavnced-settings +--- + +## Navigating to advanced settings + +In order to enable experimental mode, you can do the following: + +- Open Freighter, and click on "Settings"(the cog) on our bottom navigation bar. +- Click on the "Security" menu item +- Click on "Advanced Settings", and read the disclaimer. If you want to proceed, then click "I understand, continue" + +## Settings + +**Use Futurenet** +This setting enables access to the Futurenet network and disables access to Pubnet. + +**Enable Blind Signing on Ledger** +This can be used to sign arbitrary transaction hashes without having to decode them first. Ledger will not display the transaction details in the device display prior to signing so make sure you only interact with applications you know and trust. + +**Connect to domain without SSL certificate** +Allow Freighter to connect to domains that do not have an SSL certificate on Mainnet. SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain. Use caution when connecting to domains without an SSL certificate. diff --git a/docs/docs/guide/introduction.md b/docs/docs/guide/introduction.md index e1c43524c..6ae673964 100644 --- a/docs/docs/guide/introduction.md +++ b/docs/docs/guide/introduction.md @@ -5,9 +5,12 @@ title: Introduction ## Welcome to Freighter -The User Guide will cover: +The Guide will cover: - Installing the extension and Freighter-API - Integrating with Freighter +- Creating/importing accounts & network settings +- Adding assets to your wallet and making payments +- Signing transaction XDR Once you've installed the extension and the API, and you've familiarized yourself with how to integrate, head down to the Playground to test your connection with the extension. diff --git a/docs/docs/guide/makePayment.md b/docs/docs/guide/makePayment.md new file mode 100644 index 000000000..2b48b2bef --- /dev/null +++ b/docs/docs/guide/makePayment.md @@ -0,0 +1,16 @@ +--- +id: makePayment +title: Make a payment +slug: /make-payment +--- + +## Making a payment + +You can make a payment from your account in Freighter to another account by clicking on the "send" button from the homescreen or by clicking into any balance in your account, and clicking "send" on the balance detail screen. + +To complete a payment, follow these steps - + +- Enter a recipient address, or select a "recent address" if you have a history of payments. +- Choose your payment asset and amount. You can select any asset in your balances to make a payment. Please note that if your recipient does not have a trustline open to the asset, the transaction will be rejected. You can also enter in an amount to send to the recipient, with an optional "set max" option which will send them your entire balance of that asset (unless it is XLM, in which case you need to maintain a base reserve). +- Set your desired transaction fee, timeout, and memo. Freighter will provide you with an estimate for an appropriate fee but you can choose to change it. Some exchanges(like [Binance](https://support.binance.us/hc/en-us/articles/360052205274-Memos-on-Binance-US)) require the use of memos in order to properly interact with keys that they control. You can set a memo at this step. +- Confirm the transaction details and observe any potential warnings about your transaction before submitting to the network. diff --git a/docs/docs/guide/signXdr.md b/docs/docs/guide/signXdr.md new file mode 100644 index 000000000..bd71b0c18 --- /dev/null +++ b/docs/docs/guide/signXdr.md @@ -0,0 +1,20 @@ +--- +id: signXdr +title: Sign XDR +slug: /sign-xdr +--- + +## Signing XDR + +You trigger an "xdr signing" workflow by utilizing the [signTransaction API](https://docs.freighter.app/docs/playground/signTransaction). +The API takes an xdr string and network options as input and triggers a modal where you can review transaction/operation details and sign the transaction. Freighter will return the signed transaction to the application that called the API after user confirmation. + +You can serialize an assembled transaction to a base64 encoded xdr string using [the stellar-sdk](https://stellar.github.io/js-stellar-sdk/AssembledTransaction.html#toXDR). + +### Signing details + +During the transaction/operation review, you can review signing details at different fidelities. + +- Summary: The first tab is the summary tab which lays out high level trasnsction/operation details. +- Operation Details: The second tab exposes information about the operations in the transaction and optionally walks through the invocation chain and highlights authorizations. +- Raw XDR: The last tab lets you copy the raw XDR to be used outside of Freighter. diff --git a/docs/docs/playground/components/SignMessageDemo.tsx b/docs/docs/playground/components/SignMessageDemo.tsx index d4806e6f1..597cf1f20 100644 --- a/docs/docs/playground/components/SignMessageDemo.tsx +++ b/docs/docs/playground/components/SignMessageDemo.tsx @@ -30,7 +30,6 @@ export const SignMessageDemo = () => { if (signedMessageObj.error) { setResult(JSON.stringify(signedMessageObj.error)); } else { - console.log(signedMessageObj); setResult(JSON.stringify(signedMessageObj.signedMessage)); setSignerAddressResult(signedMessageObj.signerAddress); } diff --git a/docs/sidebars.js b/docs/sidebars.js index eaf81369f..7dc46a21f 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -14,19 +14,24 @@ const playgroundPaths = [ ]; const GUIDE_BASE_PATH = "guide"; -const guidePaths = [ - "introduction", - "gettingStarted", - "usingFreighterWebApp", - "usingFreighterBrowser", +const introPaths = ["introduction", "gettingStarted"]; +const userGuidePaths = [ + "account", + "advancedSettings", + "addAsset", + "makePayment", + "signXdr", ]; +const techGuidePaths = ["usingFreighterWebApp", "usingFreighterBrowser"]; const constructPaths = (paths, basePath) => paths.map((path) => `${basePath}/${path}`); module.exports = { someSidebar: { - "User Guide": constructPaths(guidePaths, GUIDE_BASE_PATH), + Introduction: constructPaths(introPaths, GUIDE_BASE_PATH), + "User Guide": constructPaths(userGuidePaths, GUIDE_BASE_PATH), + "Technical Guide": constructPaths(techGuidePaths, GUIDE_BASE_PATH), Playground: constructPaths(playgroundPaths, PLAYGROUND_BASE_PATH), }, }; diff --git a/extension/e2e-tests/addAsset.test.ts b/extension/e2e-tests/addAsset.test.ts index fdca9ddb3..7e8a37d95 100644 --- a/extension/e2e-tests/addAsset.test.ts +++ b/extension/e2e-tests/addAsset.test.ts @@ -1,5 +1,6 @@ import { test, expect, expectPageToHaveScreenshot } from "./test-fixtures"; import { loginToTestAccount, PASSWORD } from "./helpers/login"; +import { TEST_TOKEN_ADDRESS } from "./helpers/test-token"; test("Adding unverified Soroban token", async ({ page, extensionId }) => { test.slow(); @@ -15,13 +16,11 @@ test("Adding unverified Soroban token", async ({ page, extensionId }) => { await expect(page.getByText("Your assets")).toBeVisible(); await page.getByText("Add an asset").click({ force: true }); await page.getByText("Add manually").click({ force: true }); - await page - .getByTestId("search-token-input") - .fill("CAHX2LUNQ4YKNJTDEFW2LSFOXDAL4QI4736RV52ZUGCIRJK5U7MWQWW6"); + await page.getByTestId("search-token-input").fill(TEST_TOKEN_ADDRESS); await expect(page.getByTestId("asset-notification")).toHaveText( "Not on your listsFreighter uses asset lists to check assets you interact with. You can define your own assets lists in Settings.", ); - await expect(page.getByTestId("ManageAssetCode")).toHaveText("E2E Token"); + await expect(page.getByTestId("ManageAssetCode")).toHaveText("E2E"); await expect(page.getByTestId("ManageAssetRowButton")).toHaveText("Add"); await page.getByTestId("ManageAssetRowButton").click({ force: true }); diff --git a/extension/e2e-tests/helpers/login.ts b/extension/e2e-tests/helpers/login.ts index 214b61384..ea528b888 100644 --- a/extension/e2e-tests/helpers/login.ts +++ b/extension/e2e-tests/helpers/login.ts @@ -22,6 +22,7 @@ export const loginAndFund = async ({ page, extensionId }) => { }); await page.goto(`chrome-extension://${extensionId}/index.html#/account`); + // await page.getByTestId("BlockaidAnnouncement__accept").click(); await expect(page.getByTestId("network-selector-open")).toBeVisible({ timeout: 10000, }); @@ -75,6 +76,7 @@ export const loginToTestAccount = async ({ page, extensionId }) => { }); await page.goto(`chrome-extension://${extensionId}/index.html#/account`); + // await page.getByTestId("BlockaidAnnouncement__accept").click(); await expect(page.getByTestId("network-selector-open")).toBeVisible({ timeout: 50000, }); diff --git a/extension/e2e-tests/helpers/test-token.ts b/extension/e2e-tests/helpers/test-token.ts new file mode 100644 index 000000000..ce274d84f --- /dev/null +++ b/extension/e2e-tests/helpers/test-token.ts @@ -0,0 +1,2 @@ +export const TEST_TOKEN_ADDRESS = + "CA5F3Q3KQWGG5J4U6OBCJEFVG4B5JVMHFRGQGMLNZXTEMO7YEO6UYMMD"; diff --git a/extension/e2e-tests/loadAccount.test.ts b/extension/e2e-tests/loadAccount.test.ts new file mode 100644 index 000000000..f989b05a2 --- /dev/null +++ b/extension/e2e-tests/loadAccount.test.ts @@ -0,0 +1,29 @@ +import { test, expect, expectPageToHaveScreenshot } from "./test-fixtures"; +import { loginToTestAccount } from "./helpers/login"; + +test("Load accounts on standalone network", async ({ page, extensionId }) => { + test.slow(); + await loginToTestAccount({ page, extensionId }); + await page.getByTestId("network-selector-open").click(); + await page.getByText("Add custom network").click(); + await expect(page.getByText("Add Custom Network")).toBeVisible(); + await expectPageToHaveScreenshot({ + page, + screenshot: "network-form-page.png", + }); + await page.getByTestId("NetworkForm__networkName").fill("test standalone"); + await page + .getByTestId("NetworkForm__networkUrl") + .fill("https://horizon-testnet.stellar.org"); + await page + .getByTestId("NetworkForm__sorobanRpcUrl") + .fill("https://soroban-testnet.stellar.org/"); + await page + .getByTestId("NetworkForm__networkPassphrase") + .fill("Test SDF Network ; September 2015"); + await page.getByTestId("NetworkForm__add").click(); + await expect(page.getByTestId("account-view")).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByTestId("account-assets")).toContainText("XLM"); +}); diff --git a/extension/e2e-tests/loadAccount.test.ts-snapshots/network-form-page-chromium-darwin.png b/extension/e2e-tests/loadAccount.test.ts-snapshots/network-form-page-chromium-darwin.png new file mode 100644 index 000000000..52e9279bf Binary files /dev/null and b/extension/e2e-tests/loadAccount.test.ts-snapshots/network-form-page-chromium-darwin.png differ diff --git a/extension/e2e-tests/sendPayment.test.ts b/extension/e2e-tests/sendPayment.test.ts index 058e5f5d8..bc59800ed 100644 --- a/extension/e2e-tests/sendPayment.test.ts +++ b/extension/e2e-tests/sendPayment.test.ts @@ -1,5 +1,6 @@ import { test, expect, expectPageToHaveScreenshot } from "./test-fixtures"; import { loginAndFund, loginToTestAccount, PASSWORD } from "./helpers/login"; +import { TEST_TOKEN_ADDRESS } from "./helpers/test-token"; test("Send XLM payment to G address", async ({ page, extensionId }) => { test.slow(); @@ -78,9 +79,7 @@ test("Send XLM payment to C address", async ({ page, extensionId }) => { // send XLM to C address await page.getByTitle("Send Payment").click({ force: true }); await expect(page.getByText("Send To")).toBeVisible(); - await page - .getByTestId("send-to-input") - .fill("CAHX2LUNQ4YKNJTDEFW2LSFOXDAL4QI4736RV52ZUGCIRJK5U7MWQWW6"); + await page.getByTestId("send-to-input").fill(TEST_TOKEN_ADDRESS); await page.getByText("Continue").click({ force: true }); await expect(page.getByText("Send XLM")).toBeVisible(); @@ -172,9 +171,7 @@ test("Send SAC to C address", async ({ page, extensionId }) => { // send SAC to C address await page.getByTitle("Send Payment").click({ force: true }); - await page - .getByTestId("send-to-input") - .fill("CAHX2LUNQ4YKNJTDEFW2LSFOXDAL4QI4736RV52ZUGCIRJK5U7MWQWW6"); + await page.getByTestId("send-to-input").fill(TEST_TOKEN_ADDRESS); await page.getByText("Continue").click({ force: true }); await page.getByTestId("send-amount-asset-select").click({ force: true }); @@ -227,17 +224,13 @@ test("Send token payment to C address", async ({ page, extensionId }) => { await expect(page.getByText("Your assets")).toBeVisible(); await page.getByText("Add an asset").click({ force: true }); await page.getByText("Add manually").click({ force: true }); - await page - .getByTestId("search-token-input") - .fill("CAHX2LUNQ4YKNJTDEFW2LSFOXDAL4QI4736RV52ZUGCIRJK5U7MWQWW6"); + await page.getByTestId("search-token-input").fill(TEST_TOKEN_ADDRESS); await page.getByTestId("ManageAssetRowButton").click({ force: true }); await page.getByTestId("add-asset").dispatchEvent("click"); // send E2E token to C address await page.getByTitle("Send Payment").click({ force: true }); - await page - .getByTestId("send-to-input") - .fill("CAHX2LUNQ4YKNJTDEFW2LSFOXDAL4QI4736RV52ZUGCIRJK5U7MWQWW6"); + await page.getByTestId("send-to-input").fill(TEST_TOKEN_ADDRESS); await page.getByText("Continue").click({ force: true }); await page.getByTestId("send-amount-asset-select").click({ force: true }); diff --git a/extension/src/background/constants/apiUrls.ts b/extension/src/background/constants/apiUrls.ts index 3ae431ac2..53ba60a57 100644 --- a/extension/src/background/constants/apiUrls.ts +++ b/extension/src/background/constants/apiUrls.ts @@ -1,5 +1,2 @@ -export const STELLAR_EXPERT_BLOCKED_ACCOUNTS_URL = - "https://api.stellar.expert/explorer/directory?limit=20000000&tag[]=malicious&tag[]=unsafe&tag[]=memo-required"; - -export const STELLAR_EXPERT_BLOCKED_DOMAINS_URL = - "https://api.stellar.expert/explorer/directory/blocked-domains?limit=1000"; +export const STELLAR_EXPERT_MEMO_REQUIRED_ACCOUNTS_URL = + "https://api.stellar.expert/explorer/directory?limit=20000000&tag[]=memo-required"; diff --git a/extension/src/background/helpers/account.ts b/extension/src/background/helpers/account.ts index 158925f9f..62cc32682 100644 --- a/extension/src/background/helpers/account.ts +++ b/extension/src/background/helpers/account.ts @@ -4,8 +4,6 @@ import { KEY_ID_LIST, KEY_ID, IS_VALIDATING_MEMO_ID, - IS_VALIDATING_SAFETY_ID, - IS_VALIDATING_SAFE_ASSETS_ID, NETWORK_ID, NETWORKS_LIST_ID, IS_EXPERIMENTAL_MODE_ID, @@ -13,6 +11,7 @@ import { ASSETS_LISTS_ID, IS_HASH_SIGNING_ENABLED_ID, IS_NON_SSL_ENABLED_ID, + IS_BLOCKAID_ANNOUNCED_ID, } from "constants/localStorageTypes"; import { DEFAULT_NETWORKS, NetworkDetails } from "@shared/constants/stellar"; import { DEFAULT_ASSETS_LISTS } from "@shared/constants/soroban/token"; @@ -92,12 +91,6 @@ export const getAllowList = async () => { export const getIsMemoValidationEnabled = async () => (await localStore.getItem(IS_VALIDATING_MEMO_ID)) ?? true; -export const getIsSafetyValidationEnabled = async () => - (await localStore.getItem(IS_VALIDATING_SAFETY_ID)) ?? true; - -export const getIsValidatingSafeAssetsEnabled = async () => - (await localStore.getItem(IS_VALIDATING_SAFE_ASSETS_ID)) ?? true; - export const getIsExperimentalModeEnabled = async () => (await localStore.getItem(IS_EXPERIMENTAL_MODE_ID)) ?? false; @@ -161,6 +154,16 @@ export const getIsNonSSLEnabled = async () => { return isNonSSLEnabled; }; +export const getIsBlockaidAnnounced = async () => { + if (!(await localStore.getItem(IS_BLOCKAID_ANNOUNCED_ID))) { + await localStore.setItem(IS_BLOCKAID_ANNOUNCED_ID, false); + } + const isBlockaidAnnounced = + (await localStore.getItem(IS_BLOCKAID_ANNOUNCED_ID)) ?? false; + + return isBlockaidAnnounced; +}; + export const getIsRpcHealthy = async (networkDetails: NetworkDetails) => { let rpcHealth = { status: "" }; if (isCustomNetwork(networkDetails)) { diff --git a/extension/src/background/helpers/dataStorage.ts b/extension/src/background/helpers/dataStorage.ts index cddc8d1b6..0f3f5e55e 100644 --- a/extension/src/background/helpers/dataStorage.ts +++ b/extension/src/background/helpers/dataStorage.ts @@ -10,6 +10,7 @@ import { ASSETS_LISTS_ID, IS_HASH_SIGNING_ENABLED_ID, IS_NON_SSL_ENABLED_ID, + // IS_BLOCKAID_ANNOUNCED_ID, } from "constants/localStorageTypes"; import { DEFAULT_NETWORKS, @@ -251,6 +252,21 @@ export const addIsNonSSLEnabled = async () => { } }; +export const removeStellarExpertData = async () => { + const localStore = dataStorageAccess(browserLocalStorage); + const storageVersion = (await localStore.getItem(STORAGE_VERSION)) as string; + + if (!storageVersion || semver.lt(storageVersion, "4.3.0")) { + await localStore.remove([ + "cachedBlockedAccountsId", + "cachedBlockedAccountsId_date", + "cachedBlockedDomainsId", + "cachedBlockedDomainsId_date", + ]); + await migrateDataStorageVersion("4.3.0"); + } +}; + export const versionedMigration = async () => { // sequentially call migrations in order to enforce smooth schema upgrades @@ -263,6 +279,7 @@ export const versionedMigration = async () => { await addAssetsLists(); await addIsHashSigningEnabled(); await addIsNonSSLEnabled(); + await removeStellarExpertData(); }; // Updates storage version diff --git a/extension/src/background/messageListener/freighterApiMessageListener.ts b/extension/src/background/messageListener/freighterApiMessageListener.ts index 44a281982..d679d0932 100644 --- a/extension/src/background/messageListener/freighterApiMessageListener.ts +++ b/extension/src/background/messageListener/freighterApiMessageListener.ts @@ -23,18 +23,17 @@ import { MAINNET_NETWORK_DETAILS, NetworkDetails, } from "@shared/constants/stellar"; -import { STELLAR_EXPERT_BLOCKED_ACCOUNTS_URL } from "background/constants/apiUrls"; +import { STELLAR_EXPERT_MEMO_REQUIRED_ACCOUNTS_URL } from "background/constants/apiUrls"; import { POPUP_HEIGHT, POPUP_WIDTH } from "constants/dimensions"; import { ALLOWLIST_ID, - CACHED_BLOCKED_ACCOUNTS_ID, + CACHED_MEMO_REQUIRED_ACCOUNTS_ID, } from "constants/localStorageTypes"; import { TRANSACTION_WARNING } from "constants/transaction"; import { getIsMainnet, getIsMemoValidationEnabled, - getIsSafetyValidationEnabled, getNetworkDetails, } from "background/helpers/account"; import { isSenderAllowed } from "background/helpers/allowListAuthorization"; @@ -164,8 +163,8 @@ export const freighterApiMessageListener = ( ); const directoryLookupJson = await cachedFetch( - STELLAR_EXPERT_BLOCKED_ACCOUNTS_URL, - CACHED_BLOCKED_ACCOUNTS_ID, + STELLAR_EXPERT_MEMO_REQUIRED_ACCOUNTS_URL, + CACHED_MEMO_REQUIRED_ACCOUNTS_ID, ); const accountData = directoryLookupJson?._embedded?.records || []; @@ -183,10 +182,8 @@ export const freighterApiMessageListener = ( const isValidatingMemo = (await getIsMemoValidationEnabled()) && isMainnet; - const isValidatingSafety = - (await getIsSafetyValidationEnabled()) && isMainnet; - if (isValidatingMemo || isValidatingSafety) { + if (isValidatingMemo) { _operations.forEach((operation: StellarSdk.Operation) => { accountData.forEach( ({ address, tags }: { address: string; tags: string[] }) => { @@ -198,16 +195,8 @@ export const freighterApiMessageListener = ( /* if the user has opted out of validation, remove applicable tags */ if (!isValidatingMemo) { - collectedTags.filter( - (tag) => tag !== TRANSACTION_WARNING.memoRequired, - ); - } - if (!isValidatingSafety) { collectedTags = collectedTags.filter( - (tag) => tag !== TRANSACTION_WARNING.unsafe, - ); - collectedTags = collectedTags.filter( - (tag) => tag !== TRANSACTION_WARNING.malicious, + (tag) => tag !== TRANSACTION_WARNING.memoRequired, ); } flaggedKeys[operation.destination] = { diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index 247439d8b..f3550856e 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -26,8 +26,7 @@ import { calculateSenderMinBalance } from "@shared/helpers/migration"; import { Account, Response as Request, - BlockedDomains, - BlockedAccount, + MemoRequiredAccount, MigratableAccount, } from "@shared/api/types"; import { MessageResponder } from "background/types"; @@ -41,20 +40,18 @@ import { CACHED_ASSET_DOMAINS_ID, DATA_SHARING_ID, IS_VALIDATING_MEMO_ID, - IS_VALIDATING_SAFETY_ID, - IS_VALIDATING_SAFE_ASSETS_ID, IS_EXPERIMENTAL_MODE_ID, KEY_DERIVATION_NUMBER_ID, KEY_ID, KEY_ID_LIST, RECENT_ADDRESSES, - CACHED_BLOCKED_DOMAINS_ID, - CACHED_BLOCKED_ACCOUNTS_ID, + CACHED_MEMO_REQUIRED_ACCOUNTS_ID, NETWORK_ID, NETWORKS_LIST_ID, TOKEN_ID_LIST, IS_HASH_SIGNING_ENABLED_ID, IS_NON_SSL_ENABLED_ID, + IS_BLOCKAID_ANNOUNCED_ID, } from "constants/localStorageTypes"; import { FUTURENET_NETWORK_DETAILS, @@ -71,8 +68,6 @@ import { getAllowList, getKeyIdList, getIsMemoValidationEnabled, - getIsSafetyValidationEnabled, - getIsValidatingSafeAssetsEnabled, getIsExperimentalModeEnabled, getIsHashSigningEnabled, getIsHardwareWalletActive, @@ -83,6 +78,7 @@ import { getNetworksList, getAssetsLists, getIsNonSSLEnabled, + getIsBlockaidAnnounced, HW_PREFIX, getBipPath, subscribeTokenBalance, @@ -117,10 +113,7 @@ import { passwordSelector, setMigratedMnemonicPhrase, } from "background/ducks/session"; -import { - STELLAR_EXPERT_BLOCKED_DOMAINS_URL, - STELLAR_EXPERT_BLOCKED_ACCOUNTS_URL, -} from "background/constants/apiUrls"; +import { STELLAR_EXPERT_MEMO_REQUIRED_ACCOUNTS_URL } from "background/constants/apiUrls"; import { AssetsListKey, DEFAULT_ASSETS_LISTS, @@ -1214,24 +1207,11 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { }; const saveSettings = async () => { - const { - isDataSharingAllowed, - isMemoValidationEnabled, - isSafetyValidationEnabled, - isValidatingSafeAssetsEnabled, - isNonSSLEnabled, - } = request; + const { isDataSharingAllowed, isMemoValidationEnabled, isNonSSLEnabled } = + request; await localStore.setItem(DATA_SHARING_ID, isDataSharingAllowed); await localStore.setItem(IS_VALIDATING_MEMO_ID, isMemoValidationEnabled); - await localStore.setItem( - IS_VALIDATING_SAFETY_ID, - isSafetyValidationEnabled, - ); - await localStore.setItem( - IS_VALIDATING_SAFE_ASSETS_ID, - isValidatingSafeAssetsEnabled, - ); await localStore.setItem(IS_NON_SSL_ENABLED_ID, isNonSSLEnabled); const networkDetails = await getNetworkDetails(); @@ -1242,8 +1222,6 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { allowList: await getAllowList(), isDataSharingAllowed, isMemoValidationEnabled: await getIsMemoValidationEnabled(), - isSafetyValidationEnabled: await getIsSafetyValidationEnabled(), - isValidatingSafeAssetsEnabled: await getIsValidatingSafeAssetsEnabled(), networkDetails, networksList: await getNetworksList(), isRpcHealthy, @@ -1303,13 +1281,12 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { const isHashSigningEnabled = await getIsHashSigningEnabled(); const assetsLists = await getAssetsLists(); const isNonSSLEnabled = await getIsNonSSLEnabled(); + const isBlockaidAnnounced = await getIsBlockaidAnnounced(); return { allowList: await getAllowList(), isDataSharingAllowed, isMemoValidationEnabled: await getIsMemoValidationEnabled(), - isSafetyValidationEnabled: await getIsSafetyValidationEnabled(), - isValidatingSafeAssetsEnabled: await getIsValidatingSafeAssetsEnabled(), isExperimentalModeEnabled: await getIsExperimentalModeEnabled(), isHashSigningEnabled, networkDetails: await getNetworkDetails(), @@ -1319,6 +1296,7 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { userNotification, assetsLists, isNonSSLEnabled, + isBlockaidAnnounced, }; }; @@ -1373,35 +1351,15 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { await localStore.setItem(CACHED_ASSET_DOMAINS_ID, assetDomainCache); }; - const getBlockedDomains = async () => { + const getMemoRequiredAccounts = async () => { try { const resp = await cachedFetch( - STELLAR_EXPERT_BLOCKED_DOMAINS_URL, - CACHED_BLOCKED_DOMAINS_ID, + STELLAR_EXPERT_MEMO_REQUIRED_ACCOUNTS_URL, + CACHED_MEMO_REQUIRED_ACCOUNTS_ID, ); - const blockedDomains = (resp?._embedded?.records || []).reduce( - (bd: BlockedDomains, obj: { domain: string }) => { - const map = bd; - map[obj.domain] = true; - return map; - }, - {}, - ); - return { blockedDomains }; - } catch (e) { - console.error(e); - return new Error("Error getting blocked domains"); - } - }; - - const getBlockedAccounts = async () => { - try { - const resp = await cachedFetch( - STELLAR_EXPERT_BLOCKED_ACCOUNTS_URL, - CACHED_BLOCKED_ACCOUNTS_ID, - ); - const blockedAccounts: BlockedAccount[] = resp?._embedded?.records || []; - return { blockedAccounts }; + const memoRequiredAccounts: MemoRequiredAccount[] = + resp?._embedded?.records || []; + return { memoRequiredAccounts }; } catch (e) { console.error(e); return new Error("Error getting blocked accounts"); @@ -1774,6 +1732,14 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { return { assetsLists: await getAssetsLists() }; }; + const saveIsBlockaidAnnounced = async () => { + const { isBlockaidAnnounced } = request; + + await localStore.setItem(IS_BLOCKAID_ANNOUNCED_ID, isBlockaidAnnounced); + + return { isBlockaidAnnounced: await getIsBlockaidAnnounced() }; + }; + const messageResponder: MessageResponder = { [SERVICE_TYPES.CREATE_ACCOUNT]: createAccount, [SERVICE_TYPES.FUND_ACCOUNT]: fundAccount, @@ -1815,17 +1781,17 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { [SERVICE_TYPES.CACHE_ASSET_ICON]: cacheAssetIcon, [SERVICE_TYPES.GET_CACHED_ASSET_DOMAIN]: getCachedAssetDomain, [SERVICE_TYPES.CACHE_ASSET_DOMAIN]: cacheAssetDomain, - [SERVICE_TYPES.GET_BLOCKED_DOMAINS]: getBlockedDomains, [SERVICE_TYPES.RESET_EXP_DATA]: resetExperimentalData, [SERVICE_TYPES.ADD_TOKEN_ID]: addTokenId, [SERVICE_TYPES.GET_TOKEN_IDS]: getTokenIds, [SERVICE_TYPES.REMOVE_TOKEN_ID]: removeTokenId, - [SERVICE_TYPES.GET_BLOCKED_ACCOUNTS]: getBlockedAccounts, + [SERVICE_TYPES.GET_MEMO_REQUIRED_ACCOUNTS]: getMemoRequiredAccounts, [SERVICE_TYPES.GET_MIGRATABLE_ACCOUNTS]: getMigratableAccounts, [SERVICE_TYPES.GET_MIGRATED_MNEMONIC_PHRASE]: getMigratedMnemonicPhrase, [SERVICE_TYPES.MIGRATE_ACCOUNTS]: migrateAccounts, [SERVICE_TYPES.ADD_ASSETS_LIST]: addAssetsList, [SERVICE_TYPES.MODIFY_ASSETS_LIST]: modifyAssetsList, + [SERVICE_TYPES.SAVE_IS_BLOCKAID_ANNOUNCED]: saveIsBlockaidAnnounced, }; return messageResponder[request.type](); diff --git a/extension/src/constants/localStorageTypes.ts b/extension/src/constants/localStorageTypes.ts index 757fe3f53..75f3494c6 100644 --- a/extension/src/constants/localStorageTypes.ts +++ b/extension/src/constants/localStorageTypes.ts @@ -5,13 +5,10 @@ export const APPLICATION_ID = "applicationState"; export const DATA_SHARING_ID = "dataSharingStatus"; export const KEY_DERIVATION_NUMBER_ID = "keyDerivationNumber"; export const ACCOUNT_NAME_LIST_ID = "accountNameList"; -export const CACHED_BLOCKED_ACCOUNTS_ID = "cachedBlockedAccountsId"; -export const CACHED_BLOCKED_DOMAINS_ID = "cachedBlockedDomainsId"; +export const CACHED_MEMO_REQUIRED_ACCOUNTS_ID = "cachedMemoRequiredAccountsId"; export const CACHED_ASSET_ICONS_ID = "cachedAssetIconsId"; export const CACHED_ASSET_DOMAINS_ID = "cachedAssetDomainsId"; export const IS_VALIDATING_MEMO_ID = "isValidatingMemo"; -export const IS_VALIDATING_SAFETY_ID = "isValidatingSafety"; -export const IS_VALIDATING_SAFE_ASSETS_ID = "isValidatingSafeAssets"; export const IS_EXPERIMENTAL_MODE_ID = "isExperimentalMode"; export const RECENT_ADDRESSES = "recentAddresses"; export const NETWORK_ID = "network"; @@ -23,3 +20,4 @@ export const HAS_ACCOUNT_SUBSCRIPTION = "hasAccountSubscription"; export const ASSETS_LISTS_ID = "assetsLists"; export const IS_HASH_SIGNING_ENABLED_ID = "isHashSigningEnabled"; export const IS_NON_SSL_ENABLED_ID = "isNonSSLEnabled"; +export const IS_BLOCKAID_ANNOUNCED_ID = "isBlockaidAnnounced"; diff --git a/extension/src/constants/transaction.ts b/extension/src/constants/transaction.ts index 7db5044b9..cab5560c1 100644 --- a/extension/src/constants/transaction.ts +++ b/extension/src/constants/transaction.ts @@ -36,8 +36,6 @@ export enum OPERATION_TYPES { } export enum TRANSACTION_WARNING { - malicious = "malicious", - unsafe = "unsafe", memoRequired = "memo-required", } diff --git a/extension/src/popup/__testHelpers__/index.tsx b/extension/src/popup/__testHelpers__/index.tsx index 593074759..f2e545075 100644 --- a/extension/src/popup/__testHelpers__/index.tsx +++ b/extension/src/popup/__testHelpers__/index.tsx @@ -12,6 +12,7 @@ import { Balances } from "@shared/api/types"; import { reducer as auth } from "popup/ducks/accountServices"; import { reducer as settings } from "popup/ducks/settings"; +import { defaultBlockaidScanAssetResult } from "@shared/helpers/stellar"; import { reducer as transactionSubmission, initialState as transactionSubmissionInitialState, @@ -82,6 +83,7 @@ export const mockBalances = { decimals: 7, total: new BigNumber("1000000000"), available: new BigNumber("1000000000"), + blockaidData: defaultBlockaidScanAssetResult, }, ["USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"]: { token: { @@ -92,11 +94,16 @@ export const mockBalances = { }, total: new BigNumber("100"), available: new BigNumber("100"), + blockaidData: { + result_type: "Spam", + features: [{ description: "" }], + }, }, native: { token: { type: "native", code: "XLM" }, total: new BigNumber("50"), available: new BigNumber("50"), + blockaidData: defaultBlockaidScanAssetResult, }, } as any as Balances, isFunded: true, diff --git a/extension/src/popup/assets/blockaid-logo.svg b/extension/src/popup/assets/blockaid-logo.svg new file mode 100644 index 000000000..97832a487 --- /dev/null +++ b/extension/src/popup/assets/blockaid-logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/extension/src/popup/assets/icon-invalid.svg b/extension/src/popup/assets/icon-invalid.svg deleted file mode 100644 index db6e74a9f..000000000 --- a/extension/src/popup/assets/icon-invalid.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/extension/src/popup/assets/icon-shield-blockaid.svg b/extension/src/popup/assets/icon-shield-blockaid.svg new file mode 100644 index 000000000..2735e94a8 --- /dev/null +++ b/extension/src/popup/assets/icon-shield-blockaid.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/extension/src/popup/assets/icon-shield-plus.svg b/extension/src/popup/assets/icon-shield-plus.svg new file mode 100644 index 000000000..8f134b025 --- /dev/null +++ b/extension/src/popup/assets/icon-shield-plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/src/popup/assets/icon-warning-asset-blockaid.svg b/extension/src/popup/assets/icon-warning-asset-blockaid.svg new file mode 100644 index 000000000..cbde7c008 --- /dev/null +++ b/extension/src/popup/assets/icon-warning-asset-blockaid.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extension/src/popup/assets/icon-warning-blockaid-yellow.svg b/extension/src/popup/assets/icon-warning-blockaid-yellow.svg new file mode 100644 index 000000000..0d53f312b --- /dev/null +++ b/extension/src/popup/assets/icon-warning-blockaid-yellow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/extension/src/popup/assets/icon-warning-blockaid.svg b/extension/src/popup/assets/icon-warning-blockaid.svg new file mode 100644 index 000000000..4f329c349 --- /dev/null +++ b/extension/src/popup/assets/icon-warning-blockaid.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/extension/src/popup/components/ModalInfo/index.tsx b/extension/src/popup/components/ModalInfo/index.tsx index 46230bf5d..aad6e3c83 100644 --- a/extension/src/popup/components/ModalInfo/index.tsx +++ b/extension/src/popup/components/ModalInfo/index.tsx @@ -1,44 +1,131 @@ import React from "react"; import classNames from "classnames"; import { Card, Icon } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; import { PunycodedDomain } from "popup/components/PunycodedDomain"; -import { MaliciousDomainWarning } from "../WarningMessages"; +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { transactionSubmissionSelector } from "popup/ducks/transactionSubmission"; +import IconShieldPlus from "popup/assets/icon-shield-plus.svg"; +import { BlockAidSiteScanLabel } from "../WarningMessages"; import "./styles.scss"; +export type PillType = "Connection" | "Trustline" | "Transaction"; + +interface PillyCopyProps { + pillType: PillType; +} + +const PillCopy = ({ pillType }: PillyCopyProps) => { + const { t } = useTranslation(); + + if (pillType === "Transaction") { + return ( + <> + +
{t("Transaction Request")}
+ + ); + } + + if (pillType === "Trustline") { + return ( + <> + Add trustline icon +
{t("Add Asset trustline")}
+ + ); + } + + return ( + <> + +
{t("Connection Request")}
+ + ); +}; + interface ModalInfoProps { children: React.ReactNode; + code: string; + issuer: string; + image: string; + pillType: PillType; domain: string; - subject: string; + asset: string; variant?: "default" | "malicious"; } export const ModalInfo = ({ children, + code, + issuer, + image, + pillType, domain, - subject, + asset, variant = "default", }: ModalInfoProps) => { const cardClasses = classNames("ModalInfo--card", { Malicious: variant === "malicious", }); + const { assetIcons } = useSelector(transactionSubmissionSelector); + return (
- +
+ +
+
{asset}
+
{domain}
- -

Connection Request

+
- {variant === "malicious" && ( - - )} -
{subject}
{children}
); }; + +interface DomainScanModalInfoProps { + children: React.ReactNode; + domain: string; + subject: string; + isMalicious: boolean; + scanStatus: "hit" | "miss"; +} + +export const DomainScanModalInfo = ({ + children, + domain, + subject, + isMalicious, + scanStatus, +}: DomainScanModalInfoProps) => ( +
+ + +
+
+ +

Connection Request

+
+
+ +
{subject}
+ {children} +
+
+); diff --git a/extension/src/popup/components/ModalInfo/styles.scss b/extension/src/popup/components/ModalInfo/styles.scss index 34ec65861..ad2bee8f1 100644 --- a/extension/src/popup/components/ModalInfo/styles.scss +++ b/extension/src/popup/components/ModalInfo/styles.scss @@ -1,23 +1,26 @@ .ModalInfo { - &--card.Malicious { - .Card { - .PunycodedDomain { - .PunycodedDomain__domain { - color: var(--color-red-50); - } - } - } - } - &--connection-request { display: flex; justify-content: center; margin-bottom: 0.75rem; } + &--subject { + font-size: 0.75rem; + color: #a0a0a0; + background-color: #1c1c1c; + border-radius: 6px; + line-height: 1rem; + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + padding: 0.5rem; + } + &--connection-request-pill { display: flex; align-items: center; + font-size: 0.75rem; + gap: 0.5rem; padding: 0 0.5rem; background-color: var(--color-gray-20); color: var(--color-gray-70); @@ -32,15 +35,30 @@ } } - &--subject { - font-size: 0.75rem; - color: #a0a0a0; - background-color: #1c1c1c; - border-radius: 6px; - line-height: 1rem; - margin-bottom: 1.5rem; - padding-bottom: 1.5rem; - padding: 0.5rem; + &__icon { + display: flex; + justify-content: center; + margin-bottom: 0.5rem; + } + + &__asset { + align-items: center; + color: var(--color-red-70); + display: flex; + font-size: 0.875rem; + font-weight: var(--font-weight-semi-bold); + justify-content: center; + line-height: 1.25rem; text-align: center; } + + &__domain { + align-items: center; + color: var(--color-gray-70); + display: flex; + font-size: 0.75rem; + justify-content: center; + line-height: 1.125rem; + margin-bottom: 0.5rem; + } } diff --git a/extension/src/popup/components/WarningMessages/index.tsx b/extension/src/popup/components/WarningMessages/index.tsx index 5d2391ca8..296be4fcb 100644 --- a/extension/src/popup/components/WarningMessages/index.tsx +++ b/extension/src/popup/components/WarningMessages/index.tsx @@ -13,7 +13,11 @@ import { } from "stellar-sdk"; import { captureException } from "@sentry/browser"; -import { ActionStatus } from "@shared/api/types"; +import { + ActionStatus, + BlockAidScanAssetResult, + BlockAidScanTxResult, +} from "@shared/api/types"; import { getTokenDetails } from "@shared/api/internal"; import { TokenArgsDisplay } from "@shared/api/helpers/soroban"; @@ -30,6 +34,7 @@ import { settingsSelector, settingsNetworkDetailsSelector, } from "popup/ducks/settings"; +import { ModalInfo } from "popup/components/ModalInfo"; import { ManageAssetRow, NewAssetFlags, @@ -49,30 +54,49 @@ import { getManageAssetXDR } from "popup/helpers/getManageAssetXDR"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { emitMetric } from "helpers/metrics"; import IconShieldCross from "popup/assets/icon-shield-cross.svg"; -import IconInvalid from "popup/assets/icon-invalid.svg"; import IconWarning from "popup/assets/icon-warning.svg"; import IconUnverified from "popup/assets/icon-unverified.svg"; import IconNewAsset from "popup/assets/icon-new-asset.svg"; +import IconShieldBlockaid from "popup/assets/icon-shield-blockaid.svg"; +import IconWarningBlockaid from "popup/assets/icon-warning-blockaid.svg"; +import IconWarningBlockaidYellow from "popup/assets/icon-warning-blockaid-yellow.svg"; import { getVerifiedTokens } from "popup/helpers/searchAsset"; +import { isAssetSuspicious, isBlockaidWarning } from "popup/helpers/blockaid"; import { CopyValue } from "../CopyValue"; import "./styles.scss"; -const DirectoryLink = () => { - const { t } = useTranslation(); - return ( - - stellar.expert's {t("directory")} - - ); -}; - export enum WarningMessageVariant { default = "", highAlert = "high-alert", warning = "warning", } +interface WarningMessageHeaderProps { + header: string; + icon: React.ReactNode; + variant: WarningMessageVariant; + children?: React.ReactNode; +} + +const WarningMessageHeader = ({ + header, + icon, + variant, + children, +}: WarningMessageHeaderProps) => ( +
+
+ {icon} +
{header}
+ {children} +
+
+); + interface WarningMessageProps { header: string; children: React.ReactNode; @@ -96,20 +120,19 @@ export const WarningMessage = ({ }: { children?: React.ReactNode; }) => ( -
-
- {variant ? ( + ) : ( - )} -
{header}
- {headerChildren} -
-
+ ) + } + variant={variant} + > + {headerChildren} + ); return isWarningActive ? ( @@ -145,47 +168,7 @@ export const WarningMessage = ({ ); }; -const DangerousAccountWarning = ({ - isUnsafe, - isMalicious, -}: { - isUnsafe: boolean; - isMalicious: boolean; -}) => { - const { t } = useTranslation(); - - if (isMalicious) { - return ( - -

- {t("An account you’re interacting with is tagged as malicious on")}{" "} - . -

-

{t("For your safety, signing this transaction is disabled")}

-
- ); - } - if (isUnsafe) { - return ( - -

- {t("An account you’re interacting with is tagged as unsafe on")}{" "} - . {t("Please proceed with caution.")} -

-
- ); - } - - return null; -}; - -const MemoWarningMessage = ({ +export const MemoWarningMessage = ({ isMemoRequired, }: { isMemoRequired: boolean; @@ -213,18 +196,20 @@ const MemoWarningMessage = ({ }; interface FlaggedWarningMessageProps { - isUnsafe: boolean; isMemoRequired: boolean; - isMalicious: boolean; + isSuspicious: boolean; + blockaidData: BlockAidScanAssetResult; } export const FlaggedWarningMessage = ({ - isUnsafe, isMemoRequired, - isMalicious, + isSuspicious, + blockaidData, }: FlaggedWarningMessageProps) => ( <> - + {isSuspicious ? ( + + ) : null} ); @@ -272,7 +257,63 @@ export const BackupPhraseWarningMessage = () => { ); }; +const BlockaidByLine = () => { + const { t } = useTranslation(); + return ( +
+ icon shield blockaid + {t("Powered by ")} + + Blockaid + +
+ ); +}; + +interface BlockaidAssetWarningProps { + blockaidData: BlockAidScanAssetResult; +} + +export const BlockaidAssetWarning = ({ + blockaidData, +}: BlockaidAssetWarningProps) => { + const { t } = useTranslation(); + const isWarning = isBlockaidWarning(blockaidData.result_type); + + return ( +
+
+ icon warning blockaid +
+
+
+ {t( + `This token was flagged as ${blockaidData.result_type} by Blockaid. Interacting with this token may result in loss of funds and is not recommended for the following reasons`, + )} + : +
    + {blockaidData.features && + blockaidData.features.map((f) => ( +
  • {f.description}
  • + ))} +
+
+
+
+ ); +}; + export const ScamAssetWarning = ({ + pillType, isSendWarning = false, domain, code, @@ -281,7 +322,9 @@ export const ScamAssetWarning = ({ onClose, // eslint-disable-next-line onContinue = () => {}, + blockaidData, }: { + pillType: "Connection" | "Trustline" | "Transaction"; isSendWarning?: boolean; domain: string; code: string; @@ -289,11 +332,11 @@ export const ScamAssetWarning = ({ image: string; onClose: () => void; onContinue?: () => void; + blockaidData: BlockAidScanAssetResult; }) => { const { t } = useTranslation(); const dispatch: AppDispatch = useDispatch(); const warningRef = useRef(null); - const { isValidatingSafeAssetsEnabled } = useSelector(settingsSelector); const { recommendedFee } = useNetworkFees(); const networkDetails = useSelector(settingsNetworkDetailsSelector); const publicKey = useSelector(publicKeySelector); @@ -380,63 +423,37 @@ export const ScamAssetWarning = ({ document.querySelector("#modal-root")!, ) ) : ( -
+
-
-
Warning
-
- {t( - "This asset was tagged as fraudulent by stellar.expert, a reliable community-maintained directory.", - )} -
-
- -
-
+ +
- {isSendWarning ? ( - +
+
+ {!isSendWarning && ( + )} -
-
{isSendWarning && ( - )}
{" "}
-
+
); @@ -507,7 +511,7 @@ export const NewAssetWarning = ({ const isHardwareWallet = !!useSelector(hardwareWalletTypeSelector); const [isTrustlineErrorShowing, setIsTrustlineErrorShowing] = useState(false); - const { isRevocable, isNewAsset, isInvalidDomain } = newAssetFlags; + const { isRevocable, isInvalidDomain } = newAssetFlags; useEffect( () => () => { @@ -631,23 +635,6 @@ export const NewAssetWarning = ({
)} -
- {isNewAsset && ( -
-
- new asset -
-
-
- {t("New Asset")} -
-
- {t("This is a relatively new asset.")} -
-
-
- )} -
{isInvalidDomain && (
@@ -1077,30 +1064,300 @@ export const SSLWarningMessage = ({ url }: { url: string }) => { ); }; -export const MaliciousDomainWarning = ({ message }: { message: string }) => ( -
-
- +export const BlockAidMaliciousLabel = () => { + const { t } = useTranslation(); + return ( +
+
+ +
+

{t("This site was flagged as malicious")}

-

{message}

-
-); + ); +}; -export const BlockAidMissWarning = () => { +export const BlockAidBenignLabel = () => { const { t } = useTranslation(); + return ( +
+
+ +
+

{t("This site has been scanned and verified")}

+
+ ); +}; +export const BlockAidMissLabel = () => { + const { t } = useTranslation(); return ( - +
+ +
+

+ {t("Unable to scan site for malicious behavior")} +

+
+ ); +}; + +export const BlockAidSiteScanLabel = ({ + status, + isMalicious, +}: { + status: "hit" | "miss"; + isMalicious: boolean; +}) => { + if (status === "miss") { + return ; + } + + if (isMalicious) { + return ; + } + + // benign case should not show anything for now + return ; +}; + +export const BlockaidTxScanLabel = ({ + scanResult, +}: { + scanResult: BlockAidScanTxResult; +}) => { + const { t } = useTranslation(); + const { simulation, validation } = scanResult; + + if (simulation && "error" in simulation) { + return ( + +
+

{t(simulation.error)}

+
+
+ ); + } + + let message = null; + if (validation && "result_type" in validation) { + switch (validation.result_type) { + case "Malicious": { + message = { + header: "This transaction was flagged as malicious", + variant: WarningMessageVariant.highAlert, + message: validation.description, + }; + return ( + +
+

{t(message.message)}

+
+
+ ); + } + + case "Warning": { + message = { + header: "This transaction was flagged as suspicious", + variant: WarningMessageVariant.warning, + message: validation.description, + }; + return ( + +
+

{t(message.message)}

+
+
+ ); + } + + case "Benign": + default: + } + } + return <>; +}; + +export const BlockaidAssetScanLabel = ({ + blockaidData, +}: { + blockaidData: BlockAidScanAssetResult; +}) => { + const isWarning = isBlockaidWarning(blockaidData.result_type); + + return ( + f.description) || []} + isWarning={isWarning} + isAsset + /> + ); +}; + +interface BlockaidWarningModalProps { + header: string; + description: string[]; + handleCloseClick?: () => void; + isActive?: boolean; + isWarning: boolean; + isAsset?: boolean; +} + +export const BlockaidWarningModal = ({ + handleCloseClick, + header, + description, + isActive = false, + isWarning, + isAsset = false, +}: BlockaidWarningModalProps) => { + const { t } = useTranslation(); + const [isModalActive, setIsModalActive] = useState(isActive); + const variant = isWarning + ? WarningMessageVariant.warning + : WarningMessageVariant.highAlert; + + const WarningInfoBlock = () => ( + + } + variant={variant} + > +
+ +
+
+ ); + + const truncatedDescription = (desc: string) => { + const arr = desc.split(" "); + + return arr.map((word) => { + if (word.length > 30) { + return ( + <> + {" "} + + ); + } + + return {word} ; + }); + }; + + return isModalActive ? ( + <> + + {createPortal( +
+ +
+
+ icon warning blockaid +
+ +
{header}
+
+ {t( + `${header} by Blockaid. Interacting with this ${ + isAsset ? "token" : "transaction" + } may result in loss of funds and is not recommended for the following reasons`, + )} + : +
    + {description.map((d) => ( +
  • {truncatedDescription(d)}
  • + ))} +
+
+
+ +
+ + +
+
, + document.querySelector("#modal-root")!, + )} + + ) : ( +
setIsModalActive(true)} + data-testid="BlockaidWarningModal__button" > + +
+ ); +}; + +export const BlockaidMaliciousTxInternalWarning = ({ + description, +}: { + description: string; +}) => { + const { t } = useTranslation(); + + return ( +
+
+ icon warning blockaid +
-

+

{t( - "Proceed with caution. Blockaid is unable to provide a risk assesment for this domain at this time.", + "This transaction was flagged by Blockaid for the following reasons", )} -

+ :
{description}
+
+
+ icon shield blockaid + {t("Powered by ")} + + Blockaid + +
- +
); }; diff --git a/extension/src/popup/components/WarningMessages/styles.scss b/extension/src/popup/components/WarningMessages/styles.scss index 93681a2a0..a8a3312f9 100644 --- a/extension/src/popup/components/WarningMessages/styles.scss +++ b/extension/src/popup/components/WarningMessages/styles.scss @@ -41,6 +41,7 @@ padding: 0.5rem; &--high-alert { + border-color: #671e22; background: rgba(241, 50, 50, 0.32); .WarningMessage__icon { @@ -93,6 +94,7 @@ &__children-wrapper { flex-grow: 1; + word-break: break-word; a { color: var(--color-white); @@ -109,17 +111,38 @@ bottom: 0; left: 0; + .Button { + white-space: nowrap !important; + } + .View__inset { padding-bottom: 1rem; } + &__box { + display: flex; + background-color: var(--color-red-20); + border: 1px solid #671e22; + border-radius: 6px; + margin-bottom: 1.5rem; + padding: 0.5rem; + + &__icon { + margin: 0.125rem 0.375rem 0 0; + } + + &--isWarning { + background-color: var(--color-yellow-20); + border: 1px solid #573300; + } + } + &__wrapper { z-index: calc(var(--z-index--scam-warning) + 1); height: 100%; bottom: 0; display: flex; flex-direction: column; - background: var(--color-gray-00); transition: bottom var(--dropdown-animation); border-top-right-radius: 1rem; border-top-left-radius: 1rem; @@ -134,13 +157,35 @@ } &__description { - font-size: 0.875rem; - line-height: 1.375rem; - margin: 0.5rem 0 2rem 0; + font-size: 0.75rem; + line-height: 1.125rem; + margin-bottom: 0.375rem; + word-break: break-word; } - &__bottom-content { - margin-top: auto; + &__list { + font-size: 0.75rem !important; + line-height: 1.125rem !important; + margin-left: 0.5rem !important; + + .CopyValue { + svg { + width: 0.5rem; + height: 0.5rem; + } + + .Value { + margin-left: 0.125rem; + } + } + } + + &__footer { + color: var(--color-gray-70); + display: flex; + font-size: 0.75rem; + gap: 0.25rem; + line-height: 1.125rem; } &__row { @@ -193,13 +238,13 @@ .View__inset { padding-bottom: 1rem; + background: var(--color-gray-00); } &__wrapper { z-index: calc(var(--z-index--scam-warning) + 1); display: flex; flex-direction: column; - background: var(--color-gray-00); transition: bottom var(--dropdown-animation); border-top-right-radius: 1rem; border-top-left-radius: 1rem; @@ -417,20 +462,16 @@ overflow-wrap: break-word; } -.MaliciousDomainWarning { +.ScanLabel { display: flex; - background-color: var(--color-red-20); - border: 1px solid #671e22; border-radius: 6px; margin-bottom: 10px; padding: 0.5rem; padding-left: 0.25rem; .Icon { + height: 16px; margin-right: 5px; - .WarningMessage__icon { - color: var(--color-red-60); - } } .Message { @@ -438,3 +479,88 @@ line-height: 1rem; } } + +.ScanMalicious { + background-color: var(--color-red-20); + border: 1px solid #671e22; + + .Icon { + .WarningMessage__icon { + color: var(--color-red-60); + } + } +} + +.ScanBenign { + background-color: var(--color-green-40); + border: 1px solid var(--color-green-50); + + .Icon { + .WarningMessage__icon { + color: var(--color-green-50); + } + } +} + +.ScanMiss { + border: 1px solid var(--color-grey-40); + + .Icon { + .WarningMessage__icon { + color: var(--color-grey-40); + } + } +} + +.BlockaidWarningModal { + height: 100vh; + width: 100vw; + position: absolute; + display: flex; + align-items: center; + justify-content: center; + + &__modal { + background: var(--color-gray-10); + border-radius: 0.5rem; + border: 1px solid var(--color-gray-40); + padding: 1rem; + width: 312px; + z-index: 2; + + &__icon { + background-color: rgba(241, 50, 50, 0.32); + border-radius: 100px; + display: flex; + width: 2rem; + height: 2rem; + padding: 0.375rem; + margin-bottom: 0.5rem; + + &--isWarning { + background-color: rgba(94, 58, 4, 0.32); + } + } + + &__image { + height: 1.25rem; + width: 1.25rem; + } + + &__title { + display: flex; + line-height: 1.5rem; + margin-bottom: 1rem; + } + + &__description { + font-size: 0.75rem; + line-height: 1.125rem; + margin-bottom: 1rem; + } + + &__byline { + margin-bottom: 1rem; + } + } +} diff --git a/extension/src/popup/components/__tests__/HistoryItem.test.tsx b/extension/src/popup/components/__tests__/HistoryItem.test.tsx new file mode 100644 index 000000000..a905c4b5d --- /dev/null +++ b/extension/src/popup/components/__tests__/HistoryItem.test.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { render, waitFor, screen } from "@testing-library/react"; + +import { HistoryItem } from "popup/components/accountHistory/HistoryItem"; +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import * as sorobanHelpers from "popup/helpers/soroban"; +import * as internalApi from "@shared/api/internal"; + +describe("HistoryItem", () => { + afterAll(() => { + jest.clearAllMocks(); + }); + jest + .spyOn(sorobanHelpers, "getAttrsFromSorobanHorizonOp") + .mockImplementation(() => { + return { + to: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + from: "GCGORBD5DB4JDIKVIA536CJE3EWMWZ6KBUBWZWRQM7Y3NHFRCLOKYVAL", + contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + fnName: "transfer", + amount: "100000000", + }; + }); + jest.spyOn(internalApi, "getTokenDetails").mockImplementation(() => { + return Promise.resolve({ + decimals: 7, + name: "native", + symbol: "XLM", + }); + }); + it("renders SAC transfers as payments", async () => { + const props = { + accountBalances: { + balances: { + native: { + token: { + code: "XLM", + }, + decimals: 7, + }, + } as any, + isFunded: true, + subentryCount: 0, + }, + operation: { + account: "GCGORBD5DB4JDIKVIA536CJE3EWMWZ6KBUBWZWRQM7Y3NHFRCLOKYVAL", + amount: "10", + asset_code: "XLM", + created_at: Date.now(), + id: "op-id", + to: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + from: "GCGORBD5DB4JDIKVIA536CJE3EWMWZ6KBUBWZWRQM7Y3NHFRCLOKYVAL", + starting_balance: "10", + type: "invokeHostFunction", + type_i: 24, + transaction_attr: { + operation_count: 1, + }, + isCreateExternalAccount: false, + isPayment: false, + isSwap: false, + } as any, + publicKey: "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF", + url: "example.com", + networkDetails: TESTNET_NETWORK_DETAILS, + setDetailViewProps: () => null, + setIsDetailViewShowing: () => null, + }; + render(); + await waitFor(() => screen.getByTestId("history-item")); + expect(screen.getByTestId("history-item")).toBeDefined(); + expect(screen.getByTestId("history-item-body-component")).toHaveTextContent( + "+10 XLM", + ); + }); +}); diff --git a/extension/src/popup/components/account/AccountAssets/index.tsx b/extension/src/popup/components/account/AccountAssets/index.tsx index cf446e955..71ff7876f 100644 --- a/extension/src/popup/components/account/AccountAssets/index.tsx +++ b/extension/src/popup/components/account/AccountAssets/index.tsx @@ -1,15 +1,17 @@ import React, { useEffect, useState } from "react"; import { useSelector } from "react-redux"; -import { BigNumber } from "bignumber.js"; import isEmpty from "lodash/isEmpty"; import { Asset, Horizon } from "stellar-sdk"; -import { AssetIcons } from "@shared/api/types"; +import { AssetIcons, AssetType } from "@shared/api/types"; import { retryAssetIcon } from "@shared/api/internal"; import { getCanonicalFromAsset } from "helpers/stellar"; import { isSorobanIssuer } from "popup/helpers/account"; import { formatTokenAmount } from "popup/helpers/soroban"; +import { isAssetSuspicious } from "popup/helpers/blockaid"; +import { formatAmount } from "popup/helpers/formatters"; + import StellarLogo from "popup/assets/stellar-logo.png"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { transactionSubmissionSelector } from "popup/ducks/transactionSubmission"; @@ -18,7 +20,6 @@ import ImageMissingIcon from "popup/assets/image-missing.svg?react"; import IconSoroban from "popup/assets/icon-soroban.svg?react"; import "./styles.scss"; -import { formatAmount } from "popup/helpers/formatters"; const getIsXlm = (code: string) => code === "XLM"; @@ -40,6 +41,8 @@ export const AssetIcon = ({ isLPShare = false, isSorobanToken = false, icon, + isSuspicious = false, + isModal = false, }: { assetIcons: AssetIcons; code: string; @@ -48,6 +51,8 @@ export const AssetIcon = ({ isLPShare?: boolean; isSorobanToken?: boolean; icon?: string; + isSuspicious?: boolean; + isModal?: boolean; }) => { /* We load asset icons in 2 ways: @@ -103,16 +108,24 @@ export const AssetIcon = ({ // If we're waiting on the icon lookup (Method 1), just return the loader until this re-renders with `assetIcons`. We can't do anything until we have it. if (isFetchingAssetIcons) { return ( -
+
+ +
); } // if we have an asset path, start loading the path in an `` return canonicalAsset || isXlm || imgSrc ? (
{`${code} +
) : ( // the image path wasn't found, show a default broken image icon -
+
+
); }; interface AccountAssetsProps { assetIcons: AssetIcons; - sortedBalances: any[]; + sortedBalances: AssetType[]; setSelectedAsset?: (selectedAsset: string) => void; } @@ -152,9 +171,6 @@ export const AccountAssets = ({ const [assetIcons, setAssetIcons] = useState(inputAssetIcons); const networkDetails = useSelector(settingsNetworkDetailsSelector); const [hasIconFetchRetried, setHasIconFetchRetried] = useState(false); - const { assetDomains, blockedDomains } = useSelector( - transactionSubmissionSelector, - ); useEffect(() => { setAssetIcons(inputAssetIcons); @@ -211,39 +227,39 @@ export const AccountAssets = ({ return ( <> - {sortedBalances.map((rb: any) => { - let issuer; + {sortedBalances.map((rb) => { + let isLP = false; + let issuer = { + key: "", + }; let code = ""; let amountUnit; if (rb.liquidityPoolId) { - issuer = "lp"; + isLP = true; code = getLPShareCode(rb.reserves as Horizon.HorizonApi.Reserve[]); amountUnit = "shares"; - } else if (rb.contractId) { + } else if (rb.contractId && "symbol" in rb) { issuer = { key: rb.contractId, }; code = rb.symbol; amountUnit = rb.symbol; } else { - issuer = rb.token.issuer; + if ("issuer" in rb.token && rb.token) { + issuer = rb.token.issuer; + } code = rb.token.code; amountUnit = rb.token.code; } - const isLP = issuer === "lp"; - const canonicalAsset = getCanonicalFromAsset( - code, - issuer?.key as string, - ); + const canonicalAsset = getCanonicalFromAsset(code, issuer?.key); - const assetDomain = assetDomains[canonicalAsset]; - const isScamAsset = !!blockedDomains.domains[assetDomain]; + const isSuspicious = isAssetSuspicious(rb.blockaidData); - const bigTotal = new BigNumber(rb.total as string); - const amountVal = rb.contractId - ? formatTokenAmount(bigTotal, rb.decimals as number) - : bigTotal.toFixed(); + const amountVal = + rb.contractId && "decimals" in rb + ? formatTokenAmount(rb.total, rb.decimals) + : rb.total.toFixed(); return (
{code} -
diff --git a/extension/src/popup/components/account/AccountAssets/styles.scss b/extension/src/popup/components/account/AccountAssets/styles.scss index 3f286e706..ad9479189 100644 --- a/extension/src/popup/components/account/AccountAssets/styles.scss +++ b/extension/src/popup/components/account/AccountAssets/styles.scss @@ -17,6 +17,7 @@ $loader-light-color: #444961; margin-right: 1rem; max-width: 2rem; max-height: 2rem; + position: relative; img { width: 100%; @@ -62,6 +63,11 @@ $loader-light-color: #444961; height: 1rem; width: 1rem; } + + .ScamAssetIcon img { + height: 100%; + width: 100%; + } } &--loading { @@ -83,7 +89,8 @@ $loader-light-color: #444961; } } - &--no-margin { + &--no-margin, + &--modal { margin: 0; } } diff --git a/extension/src/popup/components/account/AssetDetail/index.tsx b/extension/src/popup/components/account/AssetDetail/index.tsx index f65235bc1..8355c4aab 100644 --- a/extension/src/popup/components/account/AssetDetail/index.tsx +++ b/extension/src/popup/components/account/AssetDetail/index.tsx @@ -2,10 +2,11 @@ import React, { useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { BigNumber } from "bignumber.js"; import { useTranslation } from "react-i18next"; -import { IconButton, Icon, Notification } from "@stellar/design-system"; +import { IconButton, Icon } from "@stellar/design-system"; import { HorizonOperation, AssetType } from "@shared/api/types"; import { NetworkDetails } from "@shared/constants/stellar"; +import { defaultBlockaidScanAssetResult } from "@shared/helpers/stellar"; import { getAvailableBalance, getIsPayment, @@ -43,10 +44,11 @@ import { transactionSubmissionSelector, } from "popup/ducks/transactionSubmission"; import { AppDispatch } from "popup/App"; -import { useIsOwnedScamAsset } from "popup/helpers/useIsOwnedScamAsset"; import StellarLogo from "popup/assets/stellar-logo.png"; import { formatAmount } from "popup/helpers/formatters"; +import { isAssetSuspicious } from "popup/helpers/blockaid"; import { Loading } from "popup/components/Loading"; +import { BlockaidAssetWarning } from "popup/components/WarningMessages"; import "./styles.scss"; @@ -74,14 +76,13 @@ export const AssetDetail = ({ const canonical = getAssetFromCanonical(selectedAsset); const isSorobanAsset = canonical.issuer && isSorobanIssuer(canonical.issuer); - const isOwnedScamAsset = useIsOwnedScamAsset( - canonical.code, - canonical.issuer, - ); const { accountBalances: balances } = useSelector( transactionSubmissionSelector, ); + const isSuspicious = isAssetSuspicious( + balances.balances?.[selectedAsset].blockaidData, + ); const balance = getRawBalance(accountBalances, selectedAsset)!; @@ -163,7 +164,9 @@ export const AssetDetail = ({ )}
{displayTotal} @@ -184,59 +187,55 @@ export const AssetDetail = ({ />
-
- {balance?.total && new BigNumber(balance?.total).toNumber() > 0 ? ( - <> - { - dispatch(saveAsset(selectedAsset)); - if (isContract) { - dispatch(saveIsToken(true)); - } else { - dispatch(saveIsToken(false)); - } - navigateTo(ROUTES.sendPayment); - }} - > - {t("SEND")} - - {!isSorobanAsset && ( + {isSuspicious ? null : ( +
+ {balance?.total && + new BigNumber(balance?.total).toNumber() > 0 ? ( + <> { dispatch(saveAsset(selectedAsset)); - navigateTo(ROUTES.swap); + if (isContract) { + dispatch(saveIsToken(true)); + } else { + dispatch(saveIsToken(false)); + } + navigateTo(ROUTES.sendPayment); }} > - {t("SWAP")} + {t("SEND")} - )} - - ) : ( - { - dispatch(saveDestinationAsset(selectedAsset)); - navigateTo(ROUTES.swap); - }} - > - {t("SWAP")} - - )} -
+ {!isSorobanAsset && ( + { + dispatch(saveAsset(selectedAsset)); + navigateTo(ROUTES.swap); + }} + > + {t("SWAP")} + + )} + + ) : ( + { + dispatch(saveDestinationAsset(selectedAsset)); + navigateTo(ROUTES.swap); + }} + > + {t("SWAP")} + + )} +
+ )}
- {isOwnedScamAsset && ( - -
-

- This asset was tagged as fraudulent by stellar.expert, a - reliable community-maintained directory. -

-

- Trading or sending this asset is not recommended. Projects - related to this asset may be fraudulent even if the creators - say otherwise. -

-
-
+ {isSuspicious && ( + )}
diff --git a/extension/src/popup/components/account/AssetDetail/styles.scss b/extension/src/popup/components/account/AssetDetail/styles.scss index aba05a1e7..35fedcd0e 100644 --- a/extension/src/popup/components/account/AssetDetail/styles.scss +++ b/extension/src/popup/components/account/AssetDetail/styles.scss @@ -44,15 +44,21 @@ } &__total { + margin-bottom: 1.5rem; + &__copy { align-items: center; display: flex; font-size: 2rem; - font-weight: var(--font-weight-light); + font-weight: var(--font-weight-medium); justify-content: center; line-height: 150%; - margin: 2.5rem 0 0.5rem 0; + margin: -0.25rem 0 0.5rem 0; text-align: center; + + &--isSuspicious { + color: var(--color-red-70); + } } } @@ -60,7 +66,7 @@ display: flex; gap: 0.5rem; justify-content: center; - margin: 2rem 0 2.5rem 0; + margin: 0.5rem 0 2.5rem 0; } &__empty { diff --git a/extension/src/popup/components/account/BlockaidAnnouncement/index.tsx b/extension/src/popup/components/account/BlockaidAnnouncement/index.tsx new file mode 100644 index 000000000..8f2360260 --- /dev/null +++ b/extension/src/popup/components/account/BlockaidAnnouncement/index.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { Button, Icon, Paragraph } from "@stellar/design-system"; + +import { AppDispatch } from "popup/App"; + +import { LoadingBackground } from "popup/basics/LoadingBackground"; +import { + settingsSelector, + saveIsBlockaidAnnounced, +} from "popup/ducks/settings"; +import { openTab } from "popup/helpers/navigate"; + +import BlockaidLogo from "popup/assets/blockaid-logo.svg"; + +import "./styles.scss"; + +export const BlockaidAnnouncement = () => { + const { t } = useTranslation(); + const dispatch: AppDispatch = useDispatch(); + const { isBlockaidAnnounced } = useSelector(settingsSelector); + + const handleLearnMore = () => { + openTab("https://blockaid.io"); + }; + + const handleCloseModal = () => { + dispatch(saveIsBlockaidAnnounced({ isBlockaidAnnounced: true })); + }; + + return isBlockaidAnnounced ? null : ( + <> + +
+
+
+
{t("Freighter is adding a new layer of protection")}
+
+ +
+
+
+ Blockaid Logo +
+
+ + {t("Freighter now uses Blockaid to keep your accounts safer.")} + + {t("By default it will verify")}: +
+
+
    +
  • {t("The domains you interact with")}
  • +
  • {t("The assets you interact with")}
  • +
  • {t("The accounts you interact with")}
  • +
  • + {t( + "The operations and functions executed in transactions and smart contracts", + )} +
  • +
+
+ +
+ + +
+
+
+ + ); +}; diff --git a/extension/src/popup/components/account/BlockaidAnnouncement/styles.scss b/extension/src/popup/components/account/BlockaidAnnouncement/styles.scss new file mode 100644 index 000000000..887092b10 --- /dev/null +++ b/extension/src/popup/components/account/BlockaidAnnouncement/styles.scss @@ -0,0 +1,50 @@ +.BlockaidAnnouncement { + height: 100%; + width: 100%; + position: absolute; + display: flex; + align-items: center; + justify-content: center; + + &__modal { + background: var(--color-gray-10); + border-radius: 0.5rem; + border: 1px solid var(--color-gray-40); + padding: 1rem; + width: 312px; + z-index: 2; + + &__title { + display: flex; + line-height: 1.5rem; + margin-bottom: 1.5rem; + } + + &__close { + cursor: pointer; + } + + &__image { + margin-bottom: 0.5rem; + } + + &__description { + color: var(--color-gray-70); + } + + &__list { + color: var(--color-gray-70); + + ul { + font-size: 0.875rem !important; + margin-left: 1rem !important; + } + } + + &__buttons { + display: flex; + column-gap: 1rem; + margin-top: 1rem; + } + } +} diff --git a/extension/src/popup/components/account/ScamAssetIcon/index.tsx b/extension/src/popup/components/account/ScamAssetIcon/index.tsx index 57b9392aa..8622efef9 100644 --- a/extension/src/popup/components/account/ScamAssetIcon/index.tsx +++ b/extension/src/popup/components/account/ScamAssetIcon/index.tsx @@ -1,10 +1,10 @@ import React from "react"; -import IconWarning from "popup/assets/icon-warning-red.svg"; +import IconWarning from "popup/assets/icon-warning-asset-blockaid.svg"; import "./styles.scss"; export const ScamAssetIcon = ({ isScamAsset }: { isScamAsset: boolean }) => isScamAsset ? ( - + {isScamAsset && warning} ) : null; diff --git a/extension/src/popup/components/account/ScamAssetIcon/styles.scss b/extension/src/popup/components/account/ScamAssetIcon/styles.scss index 7d48d55f3..eba06e844 100644 --- a/extension/src/popup/components/account/ScamAssetIcon/styles.scss +++ b/extension/src/popup/components/account/ScamAssetIcon/styles.scss @@ -1,5 +1,13 @@ .ScamAssetIcon { - margin-left: 0.25rem; display: flex; align-items: center; + background: var(--color-red-40); + border-radius: 1rem; + position: absolute; + bottom: 0; + right: 0; + padding: 0.25rem; + width: 50%; + height: 50%; + background-size: auto; } diff --git a/extension/src/popup/components/accountHistory/AssetNetworkInfo/index.tsx b/extension/src/popup/components/accountHistory/AssetNetworkInfo/index.tsx index 542e29375..fdaad5def 100644 --- a/extension/src/popup/components/accountHistory/AssetNetworkInfo/index.tsx +++ b/extension/src/popup/components/accountHistory/AssetNetworkInfo/index.tsx @@ -4,8 +4,6 @@ import { useSelector } from "react-redux"; import { getIconUrlFromIssuer } from "@shared/api/helpers/getIconUrlFromIssuer"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; -import { transactionSubmissionSelector } from "popup/ducks/transactionSubmission"; -import { ScamAssetIcon } from "popup/components/account/ScamAssetIcon"; import { CopyValue } from "popup/components/CopyValue"; import StellarLogo from "popup/assets/stellar-logo.png"; import { displaySorobanId, isSorobanIssuer } from "popup/helpers/account"; @@ -28,9 +26,7 @@ export const AssetNetworkInfo = ({ contractId, }: AssetNetworkInfoProps) => { const networkDetails = useSelector(settingsNetworkDetailsSelector); - const { blockedDomains } = useSelector(transactionSubmissionSelector); const [networkIconUrl, setNetworkIconUrl] = useState(""); - const isBlockedDomain = blockedDomains.domains[assetDomain]; useEffect(() => { const fetchIconUrl = async () => { @@ -55,9 +51,6 @@ export const AssetNetworkInfo = ({ }, [assetCode, assetIssuer, networkDetails]); const decideNetworkIcon = () => { - if (isBlockedDomain) { - return ; - } if (networkIconUrl || assetType === "native") { return Network icon; } diff --git a/extension/src/popup/components/accountHistory/AssetNetworkInfo/styles.scss b/extension/src/popup/components/accountHistory/AssetNetworkInfo/styles.scss index 870d92f72..ee8ca2d7d 100644 --- a/extension/src/popup/components/accountHistory/AssetNetworkInfo/styles.scss +++ b/extension/src/popup/components/accountHistory/AssetNetworkInfo/styles.scss @@ -1,5 +1,6 @@ .AssetNetworkInfo { &__network { + color: var(--color-gray-70); align-items: center; display: flex; justify-content: center; diff --git a/extension/src/popup/components/accountHistory/HistoryItem/index.tsx b/extension/src/popup/components/accountHistory/HistoryItem/index.tsx index 646a85dd9..2e4f9d792 100644 --- a/extension/src/popup/components/accountHistory/HistoryItem/index.tsx +++ b/extension/src/popup/components/accountHistory/HistoryItem/index.tsx @@ -1,12 +1,13 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ // In order to allow that rule we need to refactor this to use the correct Horizon types and narrow operation types -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { captureException } from "@sentry/browser"; import camelCase from "lodash/camelCase"; import { Icon, Loader } from "@stellar/design-system"; import { BigNumber } from "bignumber.js"; import { useTranslation } from "react-i18next"; +import { Asset } from "stellar-sdk"; import { OPERATION_TYPES } from "constants/transaction"; import { SorobanTokenInterface } from "@shared/constants/soroban/token"; @@ -16,6 +17,7 @@ import { emitMetric } from "helpers/metrics"; import { formatTokenAmount, getAttrsFromSorobanHorizonOp, + isContractId, } from "popup/helpers/soroban"; import { formatAmount } from "popup/helpers/formatters"; @@ -136,6 +138,8 @@ export const HistoryItem = ({ const renderBodyComponent = () => BodyComponent; const renderIcon = () => IconComponent; + /* eslint-disable react-hooks/exhaustive-deps */ + const translations = useCallback(t, []); useEffect(() => { const buildHistoryItem = async () => { @@ -146,17 +150,20 @@ export const HistoryItem = ({ , ); setRowText( - t(`{{srcAssetCode}} for {{destAssetCode}}`, { + translations(`{{srcAssetCode}} for {{destAssetCode}}`, { srcAssetCode, destAssetCode, }), ); setTxDetails((_state) => ({ ..._state, - headerTitle: t(`Swapped {{srcAssetCode}} for {{destAssetCode}}`, { - srcAssetCode, - destAssetCode, - }), + headerTitle: translations( + `Swapped {{srcAssetCode}} for {{destAssetCode}}`, + { + srcAssetCode, + destAssetCode, + }, + ), operationText: `+${new BigNumber(amount)} ${destAssetCode}`, })); } else if (isPayment) { @@ -166,7 +173,7 @@ export const HistoryItem = ({ setBodyComponent( <> {paymentDifference} - {formatAmount(new BigNumber(amount).toFixed(2, 1))} {destAssetCode} + {formatAmount(new BigNumber(amount).toString())} {destAssetCode} , ); setIconComponent( @@ -179,13 +186,15 @@ export const HistoryItem = ({ setRowText(destAssetCode); setDateText( (_dateText) => - `${_isRecipient ? t("Received") : t("Sent")} \u2022 ${date}`, + `${ + _isRecipient ? translations("Received") : translations("Sent") + } \u2022 ${date}`, ); setTxDetails((_state) => ({ ..._state, isRecipient: _isRecipient, headerTitle: `${ - _isRecipient ? t("Received") : t("Sent") + _isRecipient ? translations("Received") : translations("Sent") } ${destAssetCode}`, operationText: `${paymentDifference}${new BigNumber( amount, @@ -197,10 +206,10 @@ export const HistoryItem = ({ ); setIconComponent(); setRowText("XLM"); - setDateText((_dateText) => `${t("Sent")} \u2022 ${date}`); + setDateText((_dateText) => `${translations("Sent")} \u2022 ${date}`); setTxDetails((_state) => ({ ..._state, - headerTitle: t("Create Account"), + headerTitle: translations("Create Account"), isPayment: true, operation: { ...operation, @@ -214,15 +223,28 @@ export const HistoryItem = ({ const attrs = getAttrsFromSorobanHorizonOp(operation, networkDetails); const balances = accountBalances.balances || ({} as NonNullable); - const tokenKey = Object.keys(balances).find( - (balanceKey) => attrs?.contractId === balanceKey.split(":")[1], - ); + + const tokenKey = Object.keys(balances).find((balanceKey) => { + const [code, issuer] = + balanceKey === "native" ? ["XLM"] : balanceKey.split(":"); + const matchesIssuer = attrs?.contractId === issuer; + + // if issuer if a G address or xlm, check for a SAC match + if ((issuer && !isContractId(issuer)) || code === "XLM") { + const sacAddress = new Asset(code, issuer).contractId( + networkDetails.networkPassphrase, + ); + const matchesSac = attrs?.contractId === sacAddress; + return matchesSac; + } + return matchesIssuer; + }); if (!attrs) { setRowText(operationString); setTxDetails((_state) => ({ ..._state, - headerTitle: t("Transaction"), + headerTitle: translations("Transaction"), operationText: operationString, })); } else if (attrs.fnName === SorobanTokenInterface.mint) { @@ -253,7 +275,7 @@ export const HistoryItem = ({ setRowText(operationString); setTxDetails((_state) => ({ ..._state, - headerTitle: t("Transaction"), + headerTitle: translations("Transaction"), operationText: operationString, })); } else { @@ -279,10 +301,12 @@ export const HistoryItem = ({ setDateText( (_dateText) => `${ - isRecieving ? t("Received") : t("Minted") + isRecieving + ? translations("Received") + : translations("Minted") } \u2022 ${date}`, ); - setRowText(t(capitalize(attrs.fnName))); + setRowText(translations(capitalize(attrs.fnName))); setTxDetails((_state) => ({ ..._state, operation: { @@ -290,7 +314,7 @@ export const HistoryItem = ({ from: attrs.from, to: attrs.to, }, - headerTitle: `${t(capitalize(attrs.fnName))} ${ + headerTitle: `${translations(capitalize(attrs.fnName))} ${ _token.symbol }`, isPayment: false, @@ -302,7 +326,7 @@ export const HistoryItem = ({ } catch (error) { console.error(error); captureException(`Error fetching token details: ${error}`); - setRowText(t(capitalize(attrs.fnName))); + setRowText(translations(capitalize(attrs.fnName))); setBodyComponent( <> {isRecieving && "+ "} @@ -311,7 +335,11 @@ export const HistoryItem = ({ ); setDateText( (_dateText) => - `${isRecieving ? t("Received") : t("Minted")} \u2022 ${date}`, + `${ + isRecieving + ? translations("Received") + : translations("Minted") + } \u2022 ${date}`, ); setTxDetails((_state) => ({ ..._state, @@ -320,7 +348,7 @@ export const HistoryItem = ({ from: attrs.from, to: attrs.to, }, - headerTitle: t(capitalize(attrs.fnName)), + headerTitle: translations(capitalize(attrs.fnName)), // manually set `isPayment` now that we've passed the above `isPayment` conditional isPayment: false, isRecipient: isRecieving, @@ -343,9 +371,13 @@ export const HistoryItem = ({ setDateText( (_dateText) => - `${isRecieving ? t("Received") : t("Minted")} \u2022 ${date}`, + `${ + isRecieving + ? translations("Received") + : translations("Minted") + } \u2022 ${date}`, ); - setRowText(t(capitalize(attrs.fnName))); + setRowText(translations(capitalize(attrs.fnName))); setTxDetails((_state) => ({ ..._state, operation: { @@ -353,7 +385,9 @@ export const HistoryItem = ({ from: attrs.from, to: attrs.to, }, - headerTitle: `${t(capitalize(attrs.fnName))} ${token.code}`, + headerTitle: `${translations(capitalize(attrs.fnName))} ${ + token.code + }`, isPayment: false, isRecipient: isRecieving, operationText: `${formattedTokenAmount} ${token.code}`, @@ -363,47 +397,77 @@ export const HistoryItem = ({ setIconComponent( , ); + setIsLoading(true); - if (!tokenKey) { - // TODO: attempt to fetch token details, not stored - setRowText(operationString); - setTxDetails((_state) => ({ - ..._state, - headerTitle: t("Transaction"), - operationText: operationString, - })); - } else { - const { token, decimals } = balances[tokenKey] as TokenBalance; + try { + const tokenDetailsResponse = await getTokenDetails({ + contractId: attrs.contractId, + publicKey, + networkDetails, + }); + + if (!tokenDetailsResponse) { + setRowText(operationString); + setTxDetails((_state) => ({ + ..._state, + headerTitle: translations("Transaction"), + operationText: operationString, + })); + } + + const { symbol, decimals } = tokenDetailsResponse!; + const code = symbol === "native" ? "XLM" : symbol; const formattedTokenAmount = formatTokenAmount( new BigNumber(attrs.amount), decimals, ); + const _isRecipient = + attrs.to === publicKey && attrs.from !== publicKey; + const paymentDifference = _isRecipient ? "+" : "-"; setBodyComponent( <> - - {formattedTokenAmount} {token.code} + {paymentDifference} + {formattedTokenAmount} {code} , ); - - setDateText((_dateText) => `${t("Sent")} \u2022 ${date}`); - setRowText(t(capitalize(attrs.fnName))); + setIconComponent( + _isRecipient ? ( + + ) : ( + + ), + ); + setRowText(code); + setDateText( + (_dateText) => + `${ + _isRecipient ? translations("Received") : translations("Sent") + } \u2022 ${date}`, + ); setTxDetails((_state) => ({ ..._state, - operation: { - ..._state.operation, - from: attrs.from, - to: attrs.to, - }, - headerTitle: `${t(capitalize(attrs.fnName))} ${token.code}`, - isPayment: false, - isRecipient: false, - operationText: `${formattedTokenAmount} ${token.code}`, + isRecipient: _isRecipient, + headerTitle: `${ + _isRecipient ? translations("Received") : translations("Sent") + } ${code}`, + operationText: `${paymentDifference}${formattedTokenAmount} ${code}`, + })); + } catch (error) { + // falls back to only showing contract ID + setRowText(operationString); + setTxDetails((_state) => ({ + ..._state, + headerTitle: translations("Transaction"), + operationText: operationString, })); + } finally { + setIsLoading(false); } } else { setRowText(operationString); setTxDetails((_state) => ({ ..._state, - headerTitle: t("Transaction"), + headerTitle: translations("Transaction"), operationText: operationString, })); } @@ -411,7 +475,7 @@ export const HistoryItem = ({ setRowText(operationString); setTxDetails((_state) => ({ ..._state, - headerTitle: t("Transaction"), + headerTitle: translations("Transaction"), operationText: operationString, })); } @@ -434,13 +498,14 @@ export const HistoryItem = ({ publicKey, srcAssetCode, startingBalance, - t, + translations, to, accountBalances.balances, ]); return (
{ emitMetric(METRIC_NAMES.historyOpenItem); @@ -461,7 +526,12 @@ export const HistoryItem = ({
{dateText}
-
{renderBodyComponent()}
+
+ {renderBodyComponent()} +
)}
diff --git a/extension/src/popup/components/accountHistory/TransactionDetail/index.tsx b/extension/src/popup/components/accountHistory/TransactionDetail/index.tsx index 0461b766b..b9e5e9472 100644 --- a/extension/src/popup/components/accountHistory/TransactionDetail/index.tsx +++ b/extension/src/popup/components/accountHistory/TransactionDetail/index.tsx @@ -13,6 +13,7 @@ import { emitMetric } from "helpers/metrics"; import { openTab } from "popup/helpers/navigate"; import { stroopToXlm } from "helpers/stellar"; import { useAssetDomain } from "popup/helpers/useAssetDomain"; +import { useScanAsset } from "popup/helpers/blockaid"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { METRIC_NAMES } from "popup/constants/metricsNames"; @@ -78,8 +79,10 @@ export const TransactionDetail = ({ const { assetDomain, error: assetError } = useAssetDomain({ assetIssuer, }); + const { scannedAsset } = useScanAsset(`${assetCode}-${assetIssuer}`); const networkDetails = useSelector(settingsNetworkDetailsSelector); const showContent = assetIssuer && !assetDomain && !assetError; + const isMalicious = scannedAsset.result_type === "Malicious"; return showContent ? ( @@ -92,7 +95,11 @@ export const TransactionDetail = ({
{isPayment ? ( -
+
{operationText} { 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: isSacContract - ? tokenDetailsResponse.name.split(":")[1] || "" - : contractId, // get the issuer name, if applicable , + issuer, domain: "", name: tokenDetailsResponse.name, + isSuspicious: isAssetSuspicious(scannedAsset), }, ]); } @@ -175,6 +186,8 @@ export const AddAsset = () => { const acct = await server.loadAccount(issuer); const homeDomain = acct.home_domain || ""; + setIsSearching(true); + try { assetDomainToml = await StellarToml.Resolver.resolve(homeDomain); } catch (e) { @@ -190,13 +203,26 @@ export const AddAsset = () => { const tomlNetworkPassphrase = assetDomainToml.NETWORK_PASSPHRASE || Networks.PUBLIC; + type AssetRecord = StellarToml.Api.Currency & { + domain: string; + }; + if (tomlNetworkPassphrase === networkPassphrase) { - setAssetRows( - assetDomainToml.CURRENCIES.map((currency) => ({ - ...currency, - domain: homeDomain, - })), - ); + const assetsToScan: string[] = []; + const assetRecords: AssetRecord[] = []; + assetDomainToml.CURRENCIES.forEach((currency) => { + assetRecords.push({ ...currency, domain: homeDomain }); + assetsToScan.push(`${currency.code}-${currency.issuer}`); + }); + const scannedAssets = await scanAssetBulk(assetsToScan, networkDetails); + const scannedAssetRows = assetRecords.map((record: AssetRecord) => ({ + ...record, + isSuspicious: isAssetSuspicious( + scannedAssets.results[`${record.code}-${record.issuer}`], + ), + })); + + setAssetRows(scannedAssetRows); // no need for verification on classic assets setIsVerificationInfoShowing(false); } else { diff --git a/extension/src/popup/components/manageAssets/ChooseAsset/index.tsx b/extension/src/popup/components/manageAssets/ChooseAsset/index.tsx index a8f44bd57..6798b5c53 100644 --- a/extension/src/popup/components/manageAssets/ChooseAsset/index.tsx +++ b/extension/src/popup/components/manageAssets/ChooseAsset/index.tsx @@ -20,6 +20,7 @@ import { View } from "popup/basics/layout/View"; import { getCanonicalFromAsset } from "helpers/stellar"; import { getAssetDomain } from "popup/helpers/getAssetDomain"; import { getNativeContractDetails } from "popup/helpers/searchAsset"; +import { isAssetSuspicious } from "popup/helpers/blockaid"; import { Balances } from "@shared/api/types"; @@ -63,10 +64,16 @@ export const ChooseAsset = ({ balances }: ChooseAssetProps) => { continue; } - const { - token: { code, issuer }, - contractId, - } = sortedBalances[i]; + const { token, contractId, blockaidData } = sortedBalances[i]; + + const code = token.code || ""; + let issuer = { + key: "", + }; + + if ("issuer" in token) { + issuer = token.issuer; + } // If we are in the swap flow and the asset has decimals (is a token), we skip it if Soroswap is not enabled if ("decimals" in sortedBalances[i] && isSwap && !isSoroswapEnabled) { @@ -77,11 +84,11 @@ export const ChooseAsset = ({ balances }: ChooseAssetProps) => { if (code !== "XLM") { let domain = ""; - if (issuer?.key) { + if (issuer.key) { try { // eslint-disable-next-line no-await-in-loop domain = await getAssetDomain( - issuer.key as string, + issuer.key, networkDetails.networkUrl, networkDetails.networkPassphrase, ); @@ -92,13 +99,11 @@ export const ChooseAsset = ({ balances }: ChooseAssetProps) => { collection.push({ code, - issuer: issuer?.key || "", - image: - assetIcons[ - getCanonicalFromAsset(code as string, issuer?.key as string) - ], + issuer: issuer.key, + image: assetIcons[getCanonicalFromAsset(code, issuer.key)], domain, contract: contractId, + isSuspicious: isAssetSuspicious(blockaidData), }); // include native asset for asset dropdown selection } else if (!isManagingAssets) { @@ -107,6 +112,7 @@ export const ChooseAsset = ({ balances }: ChooseAssetProps) => { issuer: "", image: "", domain: "", + isSuspicious: false, }); } } diff --git a/extension/src/popup/components/manageAssets/ManageAssetRowButton/index.tsx b/extension/src/popup/components/manageAssets/ManageAssetRowButton/index.tsx index b86fc2e07..c746afd78 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRowButton/index.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRowButton/index.tsx @@ -12,7 +12,7 @@ import { emitMetric } from "helpers/metrics"; import { getCanonicalFromAsset } from "helpers/stellar"; import { getManageAssetXDR } from "popup/helpers/getManageAssetXDR"; import { checkForSuspiciousAsset } from "popup/helpers/checkForSuspiciousAsset"; -import { scanAsset } from "popup/helpers/blockaid"; +import { isAssetSuspicious, scanAsset } from "popup/helpers/blockaid"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { publicKeySelector, @@ -91,9 +91,7 @@ export const ManageAssetRowButton = ({ const [isTrustlineErrorShowing, setIsTrustlineErrorShowing] = useState(false); const [isSigningWithHardwareWallet, setIsSigningWithHardwareWallet] = useState(false); - const { blockedDomains, submitStatus } = useSelector( - transactionSubmissionSelector, - ); + const { submitStatus } = useSelector(transactionSubmissionSelector); const walletType = useSelector(hardwareWalletTypeSelector); const networkDetails = useSelector(settingsNetworkDetailsSelector); const publicKey = useSelector(publicKeySelector); @@ -105,8 +103,6 @@ export const ManageAssetRowButton = ({ networkDetails.networkPassphrase, ); - const isBlockedDomain = (d: string) => blockedDomains.domains[d]; - const handleBackgroundClick = () => { setRowButtonShowing(""); }; @@ -133,9 +129,6 @@ export const ManageAssetRowButton = ({ }), ); - setAssetSubmitting(""); - setRowButtonShowing(""); - if (submitFreighterTransaction.fulfilled.match(submitResp)) { dispatch( getAccountBalances({ @@ -153,6 +146,9 @@ export const ManageAssetRowButton = ({ if (submitFreighterTransaction.rejected.match(submitResp)) { setIsTrustlineErrorShowing(true); } + + setAssetSubmitting(""); + setRowButtonShowing(""); } }; @@ -203,6 +199,7 @@ export const ManageAssetRowButton = ({ image: "", }, ) => { + setAssetSubmitting(canonicalAsset); const resp = await checkForSuspiciousAsset({ code: assetRowData.code, issuer: assetRowData.issuer, @@ -211,21 +208,26 @@ export const ManageAssetRowButton = ({ networkDetails, }); - await scanAsset( + const scannedAsset = await scanAsset( `${assetRowData.code}-${assetRowData.issuer}`, networkDetails, ); - if (isBlockedDomain(assetRowData.domain) && !isTrustlineActive) { + if (isAssetSuspicious(scannedAsset) && !isTrustlineActive) { setShowBlockedDomainWarning(true); - setSuspiciousAssetData(assetRowData); + setSuspiciousAssetData({ + ...assetRowData, + blockaidData: scannedAsset, + }); + setAssetSubmitting(""); } else if ( !isTrustlineActive && - (resp.isInvalidDomain || resp.isRevocable || resp.isNewAsset) + (resp.isInvalidDomain || resp.isRevocable) ) { setShowNewAssetWarning(true); setNewAssetFlags(resp); setSuspiciousAssetData(assetRowData); + setAssetSubmitting(""); } else { changeTrustline(!isTrustlineActive, () => Promise.resolve(navigateTo(ROUTES.account)), @@ -302,19 +304,21 @@ export const ManageAssetRowButton = ({ } }, [submitStatus, isSigningWithHardwareWallet]); + const isLoading = + (isActionPending && assetSubmitting === canonicalAsset) || + assetSubmitting === canonicalAsset; + return (
{isTrustlineActive ? (
{ - if (!isActionPending) { + if (!isLoading) { setRowButtonShowing( rowButtonShowing === canonicalAsset ? "" : canonicalAsset, ); @@ -344,9 +348,7 @@ export const ManageAssetRowButton = ({ size="md" variant="secondary" disabled={isActionPending} - isLoading={ - isActionPending && assetSubmitting === canonicalAsset - } + isLoading={isLoading} onClick={() => { if (isContract) { handleTokenRowClick({ @@ -366,8 +368,7 @@ export const ManageAssetRowButton = ({
{t("Remove asset")}
- {isActionPending && - assetSubmitting === canonicalAsset ? null : ( + {isLoading ? null : ( icon remove )} @@ -387,7 +388,7 @@ export const ManageAssetRowButton = ({ size="md" variant="secondary" disabled={isActionPending} - isLoading={isActionPending && assetSubmitting === canonicalAsset} + isLoading={isLoading} onClick={() => { setAssetSubmitting(canonicalAsset || contract); if (isContract) { diff --git a/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx b/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx index 145a2c49a..59b870ada 100644 --- a/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx +++ b/extension/src/popup/components/manageAssets/ManageAssetRows/index.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { StellarToml } from "stellar-sdk"; import { useDispatch, useSelector } from "react-redux"; -import { ActionStatus } from "@shared/api/types"; +import { ActionStatus, BlockAidScanAssetResult } from "@shared/api/types"; import { AppDispatch } from "popup/App"; @@ -13,6 +13,7 @@ import { } from "helpers/stellar"; import { isContractId } from "popup/helpers/soroban"; import { useNetworkFees } from "popup/helpers/useNetworkFees"; +import { defaultBlockaidScanAssetResult } from "@shared/helpers/stellar"; import { LoadingBackground } from "popup/basics/LoadingBackground"; import { ROUTES } from "popup/constants/routes"; @@ -30,7 +31,6 @@ import { NewAssetWarning, TokenWarning, } from "popup/components/WarningMessages"; -import { ScamAssetIcon } from "popup/components/account/ScamAssetIcon"; import { ManageAssetRowButton } from "../ManageAssetRowButton"; @@ -40,12 +40,12 @@ export type ManageAssetCurrency = StellarToml.Api.Currency & { domain: string; contract?: string; icon?: string; + isSuspicious?: boolean; }; export interface NewAssetFlags { isInvalidDomain: boolean; isRevocable: boolean; - isNewAsset: boolean; } interface ManageAssetRowsProps { @@ -63,6 +63,7 @@ interface SuspiciousAssetData { issuer: string; image: string; isVerifiedToken?: boolean; + blockaidData: BlockAidScanAssetResult; } export const ManageAssetRows = ({ @@ -89,7 +90,6 @@ export const ManageAssetRows = ({ const [showNewAssetWarning, setShowNewAssetWarning] = useState(false); const [showUnverifiedWarning, setShowUnverifiedWarning] = useState(false); const [newAssetFlags, setNewAssetFlags] = useState({ - isNewAsset: false, isInvalidDomain: false, isRevocable: false, }); @@ -99,6 +99,7 @@ export const ManageAssetRows = ({ issuer: "", image: "", isVerifiedToken: false, + blockaidData: defaultBlockaidScanAssetResult, } as SuspiciousAssetData); const [handleAddToken, setHandleAddToken] = useState( null as null | (() => () => Promise), @@ -126,10 +127,12 @@ export const ManageAssetRows = ({ )} {showBlockedDomainWarning && ( { setShowBlockedDomainWarning(false); }} @@ -170,6 +173,7 @@ export const ManageAssetRows = ({ issuer = "", name = "", contract = "", + isSuspicious, }) => { if (!accountBalances.balances) { return null; @@ -194,6 +198,7 @@ export const ManageAssetRows = ({ image={image} domain={domain} name={name} + isSuspicious={isSuspicious} /> { - const { blockedDomains } = useSelector(transactionSubmissionSelector); const canonicalAsset = getCanonicalFromAsset(code, issuer); - const isScamAsset = !!blockedDomains.domains[domain]; const assetCode = name || code; const truncatedAssetCode = assetCode.length > 20 ? truncateString(assetCode) : assetCode; @@ -260,11 +265,11 @@ export const ManageAssetRow = ({ assetIcons={code !== "XLM" ? { [canonicalAsset]: image } : {}} code={code} issuerKey={issuer} + isSuspicious={isSuspicious} />
{truncatedAssetCode} -
{ const { t } = useTranslation(); @@ -85,22 +91,54 @@ export const SearchAsset = () => { }, }); + const assetRecords = resJson._embedded.records; + + let blockaidScanResults: { [key: string]: BlockAidScanAssetResult } = {}; + + if (isMainnet(networkDetails)) { + // scan the first few assets to see if they are suspicious + // due to the length of time it takes to scan, we'll do it in consecutive chunks + const url = new URL(`${INDEXER_URL}/scan-asset-bulk`); + const firstSectionAssets = assetRecords.slice(0, MAX_ASSETS_TO_SCAN); + firstSectionAssets.forEach((record: AssetRecord) => { + const assetSplit = record.asset.split("-"); + if (assetSplit[0] && assetSplit[1]) { + url.searchParams.append( + "asset_ids", + `${assetSplit[0]}-${assetSplit[1]}`, + ); + } + }); + + try { + const response = await fetch(url.href); + const data = await response.json(); + blockaidScanResults = data.data.results; + } catch (e) { + console.error(e); + } + } + setIsSearching(false); setAssetRows( // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - resJson._embedded.records + assetRecords // only show records that have a domain and domains that don't have just whitespace .filter( (record: AssetRecord) => record.domain && /\S/.test(record.domain), ) .map((record: AssetRecord) => { const assetSplit = record.asset.split("-"); + const assetId = `${assetSplit[0]}-${assetSplit[1]}`; return { code: assetSplit[0], issuer: assetSplit[1], image: record?.tomlInfo?.image, domain: record.domain, + isSuspicious: blockaidScanResults[assetId] + ? isAssetSuspicious(blockaidScanResults[assetId]) + : null, }; }), ); @@ -112,6 +150,71 @@ export const SearchAsset = () => { setHasNoResults(!assetRows.length); }, [assetRows]); + useEffect(() => { + const firstNullSuspiciousIndex = assetRows.findIndex( + (r) => r.isSuspicious === null, + ); + + const fetchBlockaidResults = async (url: URL) => { + let blockaidScanResults: { [key: string]: BlockAidScanAssetResult } = {}; + try { + const response = await fetch(url.href); + const data = await response.json(); + blockaidScanResults = data.data.results; + } catch (e) { + console.error(e); + } + + // take our scanned assets and update the assetRows with the new isSuspicious values + const assetRowsAddendum = assetRows + .slice( + firstNullSuspiciousIndex, + firstNullSuspiciousIndex + MAX_ASSETS_TO_SCAN, + ) + .map((row) => { + const assetId = `${row.code}-${row.issuer}`; + return { + ...row, + isSuspicious: blockaidScanResults[assetId] + ? isAssetSuspicious(blockaidScanResults[assetId]) + : row.isSuspicious, + }; + }); + + // insert our newly scanned rows into the existing data + setAssetRows([ + ...assetRows.slice(0, firstNullSuspiciousIndex), + ...assetRowsAddendum, + ...assetRows.slice(firstNullSuspiciousIndex + MAX_ASSETS_TO_SCAN), + ]); + + return blockaidScanResults; + }; + + // if there are any assets with "null" (meaning we haven't scanned some assets yet), scan the next batch + if ( + assetRows.length && + isMainnet(networkDetails) && + firstNullSuspiciousIndex !== -1 + ) { + const url = new URL(`${INDEXER_URL}/scan-asset-bulk`); + + // grab the next section of assets to scan + assetRows + .slice( + firstNullSuspiciousIndex, + firstNullSuspiciousIndex + MAX_ASSETS_TO_SCAN, + ) + .forEach((row) => { + if (row.code && row.issuer && row.isSuspicious === null) { + url.searchParams.append("asset_ids", `${row.code}-${row.issuer}`); + } + }); + + fetchBlockaidResults(url); + } + }, [assetRows, networkDetails]); + if (isCustomNetwork(networkDetails)) { return ; } diff --git a/extension/src/popup/components/manageAssets/SelectAssetRows/index.tsx b/extension/src/popup/components/manageAssets/SelectAssetRows/index.tsx index 81585170d..939e33365 100644 --- a/extension/src/popup/components/manageAssets/SelectAssetRows/index.tsx +++ b/extension/src/popup/components/manageAssets/SelectAssetRows/index.tsx @@ -20,7 +20,6 @@ import { getAssetFromCanonical, } from "helpers/stellar"; import { getTokenBalance, isContractId } from "popup/helpers/soroban"; -import { ScamAssetIcon } from "popup/components/account/ScamAssetIcon"; import { Balance, Balances, SorobanBalance } from "@shared/api/types"; import { formatAmount } from "popup/helpers/formatters"; import { useIsSoroswapEnabled, useIsSwap } from "popup/helpers/useIsSwap"; @@ -35,7 +34,6 @@ export const SelectAssetRows = ({ assetRows }: SelectAssetRowsProps) => { const { accountBalances: { balances = {} }, assetSelect, - blockedDomains, soroswapTokens, transactionData, } = useSelector(transactionSubmissionSelector); @@ -75,8 +73,15 @@ export const SelectAssetRows = ({ assetRows }: SelectAssetRowsProps) => {
{assetRows.map( - ({ code = "", domain, image = "", issuer = "", icon }) => { - const isScamAsset = !!blockedDomains.domains[domain]; + ({ + code = "", + domain, + image = "", + issuer = "", + icon, + isSuspicious, + }) => { + const isScamAsset = isSuspicious || false; const isContract = isContractId(issuer); const canonical = getCanonicalFromAsset(code, issuer); let isSoroswap = false; @@ -118,11 +123,11 @@ export const SelectAssetRows = ({ assetRows }: SelectAssetRowsProps) => { code={code} issuerKey={issuer} icon={icon} + isSuspicious={isScamAsset} />
{code} -
{formatDomain(domain)} diff --git a/extension/src/popup/components/manageNetwork/NetworkForm/index.tsx b/extension/src/popup/components/manageNetwork/NetworkForm/index.tsx index e3d55a381..52f7091dc 100644 --- a/extension/src/popup/components/manageNetwork/NetworkForm/index.tsx +++ b/extension/src/popup/components/manageNetwork/NetworkForm/index.tsx @@ -334,6 +334,7 @@ export const NetworkForm = ({ isEditing }: NetworkFormProps) => { ) : null} { placeholder={t("Enter network name")} /> { {!isEditingDefaultNetworks || networkDetailsToEdit.network !== NETWORKS.PUBLIC ? ( { /> ) : null} { ) : (
@@ -102,17 +101,18 @@ export const PathPayAssetSelect = ({ issuerKey, balance, icon, + isSuspicious, }: { source: boolean; assetCode: string; issuerKey: string; balance: string; icon: string; + isSuspicious: boolean; }) => { const dispatch = useDispatch(); const { assetIcons } = useSelector(transactionSubmissionSelector); const isSwap = useIsSwap(); - const isOwnedScamAsset = useIsOwnedScamAsset(assetCode, issuerKey); const handleSelectAsset = () => { dispatch( @@ -150,6 +150,7 @@ export const PathPayAssetSelect = ({ code={assetCode} issuerKey={issuerKey} icon={icon} + isSuspicious={isSuspicious} /> {truncateLongAssetCode(assetCode)} {" "} -
diff --git a/extension/src/popup/components/sendPayment/SendAmount/index.tsx b/extension/src/popup/components/sendPayment/SendAmount/index.tsx index f0f4e7846..92e8b1ac1 100644 --- a/extension/src/popup/components/sendPayment/SendAmount/index.tsx +++ b/extension/src/popup/components/sendPayment/SendAmount/index.tsx @@ -22,6 +22,7 @@ import { navigateTo } from "popup/helpers/navigate"; import { useNetworkFees } from "popup/helpers/useNetworkFees"; import { useIsSwap, useIsSoroswapEnabled } from "popup/helpers/useIsSwap"; import { LP_IDENTIFIER } from "popup/helpers/account"; +import { isAssetSuspicious } from "popup/helpers/blockaid"; import { emitMetric } from "helpers/metrics"; import { useRunAfterUpdate } from "popup/helpers/useRunAfterUpdate"; import { getAssetDecimals, getTokenBalance } from "popup/helpers/soroban"; @@ -51,6 +52,7 @@ import { import { ScamAssetWarning } from "popup/components/WarningMessages"; import { TX_SEND_MAX } from "popup/constants/transaction"; import { BASE_RESERVE } from "@shared/constants/stellar"; +import { defaultBlockaidScanAssetResult } from "@shared/helpers/stellar"; import { BalanceMap, SorobanBalance } from "@shared/api/types"; import "../styles.scss"; @@ -122,7 +124,6 @@ export const SendAmount = ({ destinationBalances, transactionData, assetDomains, - blockedDomains, assetIcons, soroswapTokens, } = useSelector(transactionSubmissionSelector); @@ -147,6 +148,7 @@ export const SendAmount = ({ code: "", issuer: "", image: "", + blockaidData: defaultBlockaidScanAssetResult, }); /* eslint-disable react-hooks/exhaustive-deps */ @@ -205,25 +207,40 @@ export const SendAmount = ({ }) => { dispatch(saveAmount(cleanAmount(values.amount))); dispatch(saveAsset(values.asset)); + // eslint-disable-next-line @typescript-eslint/naming-convention + let isDestAssetScam = false; + if (values.destinationAsset) { dispatch(saveDestinationAsset(values.destinationAsset)); + isDestAssetScam = isAssetSuspicious( + accountBalances.balances?.[destinationAsset].blockaidData, + ); } // check for scam asset - if (blockedDomains.domains[assetDomains[values.asset]]) { + const isSourceAssetScam = isAssetSuspicious( + accountBalances.balances?.[asset].blockaidData, + ); + if (isSourceAssetScam) { setShowBlockedDomainWarning(true); setSuspiciousAssetData({ code: getAssetFromCanonical(values.asset).code, issuer: getAssetFromCanonical(values.asset).issuer, domain: assetDomains[values.asset], image: assetIcons[values.asset], + blockaidData: + accountBalances.balances?.[asset].blockaidData || + defaultBlockaidScanAssetResult, }); - } else if (blockedDomains.domains[assetDomains[values.destinationAsset]]) { + } else if (isDestAssetScam) { setShowBlockedDomainWarning(true); setSuspiciousAssetData({ code: getAssetFromCanonical(values.destinationAsset).code, issuer: getAssetFromCanonical(values.destinationAsset).issuer, domain: assetDomains[values.destinationAsset], image: assetIcons[values.destinationAsset], + blockaidData: + accountBalances.balances?.[destinationAsset].blockaidData || + defaultBlockaidScanAssetResult, }); } else { navigateTo(next); @@ -425,12 +442,14 @@ export const SendAmount = ({ {showBlockedDomainWarning && ( setShowBlockedDomainWarning(false)} onContinue={() => navigateTo(next)} + blockaidData={suspiciousAssetData.blockaidData} /> )} @@ -568,6 +587,9 @@ export const SendAmount = ({ )} {showSourceAndDestAsset && ( @@ -578,6 +600,9 @@ export const SendAmount = ({ issuerKey={parsedSourceAsset.issuer} balance={formik.values.amount} icon="" + isSuspicious={isAssetSuspicious( + accountBalances.balances?.[asset].blockaidData, + )} /> )} diff --git a/extension/src/popup/components/sendPayment/SendConfirm/SubmitResult/index.tsx b/extension/src/popup/components/sendPayment/SendConfirm/SubmitResult/index.tsx index 368b013e3..e40f2dc17 100644 --- a/extension/src/popup/components/sendPayment/SendConfirm/SubmitResult/index.tsx +++ b/extension/src/popup/components/sendPayment/SendConfirm/SubmitResult/index.tsx @@ -32,29 +32,36 @@ import { View } from "popup/basics/layout/View"; import { AssetIcon } from "popup/components/account/AccountAssets"; import { TrustlineError } from "popup/components/manageAssets/TrustlineError"; import IconFail from "popup/assets/icon-fail.svg"; - -import "./styles.scss"; import { emitMetric } from "helpers/metrics"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { formatAmount } from "popup/helpers/formatters"; +import { isAssetSuspicious } from "popup/helpers/blockaid"; + +import "./styles.scss"; const SwapAssetsIcon = ({ sourceCanon, destCanon, assetIcons, + isSourceSuspicious, + isDestSuspicious, }: { sourceCanon: string; destCanon: string; assetIcons: AssetIcons; + isSourceSuspicious: boolean; + isDestSuspicious: boolean; }) => { const source = getAssetFromCanonical(sourceCanon); const dest = getAssetFromCanonical(destCanon); + return (
{source.code} @@ -62,6 +69,7 @@ const SwapAssetsIcon = ({ assetIcons={assetIcons} code={dest.code} issuerKey={dest.issuer} + isSuspicious={isDestSuspicious} /> {dest.code}
@@ -96,6 +104,12 @@ export const SubmitSuccess = ({ viewDetails }: { viewDetails: () => void }) => { networkDetails.networkPassphrase, ); const isHardwareWallet = !!useSelector(hardwareWalletTypeSelector); + const isSourceAssetSuspicious = isAssetSuspicious( + accountBalances.balances?.[asset]?.blockaidData, + ); + const isDestAssetSuspicious = isAssetSuspicious( + accountBalances.balances?.[destinationAsset]?.blockaidData, + ); const removeTrustline = async (assetCode: string, assetIssuer: string) => { const changeParams = { limit: "0" }; @@ -213,6 +227,8 @@ export const SubmitSuccess = ({ viewDetails }: { viewDetails: () => void }) => { sourceCanon={asset} destCanon={destinationAsset} assetIcons={assetIcons} + isSourceSuspicious={isSourceAssetSuspicious} + isDestSuspicious={isDestAssetSuspicious} /> ) : ( { const sourceAsset = getAssetFromCanonical(sourceCanon); const destAsset = getAssetFromCanonical(destCanon); - const isSourceAssetScam = useIsOwnedScamAsset( - sourceAsset.code, - sourceAsset.issuer, - ); - const isDestAssetScam = useIsOwnedScamAsset(destAsset.code, destAsset.issuer); - return (
@@ -103,9 +115,9 @@ const TwoAssetCard = ({ assetIcons={sourceAssetIcons} code={sourceAsset.code} issuerKey={sourceAsset.issuer} + isSuspicious={isSourceAssetSuspicious} /> {sourceAsset.code} -
{destAsset.code} -
void }) => { +const getBuiltTx = async ( + publicKey: string, + opData: { + sourceAsset: Asset | { code: string; issuer: string }; + destAsset: Asset | { code: string; issuer: string }; + amount: string; + destinationAmount: string; + destination: string; + allowedSlippage: string; + path: string[]; + isPathPayment: boolean; + isSwap: boolean; + isFunded: boolean; + }, + fee: string, + transactionTimeout: number, + networkDetails: NetworkDetails, +) => { + const { + sourceAsset, + destAsset, + amount, + destinationAmount, + destination, + allowedSlippage, + path, + isPathPayment, + isSwap, + isFunded, + } = opData; + const server = stellarSdkServer( + networkDetails.networkUrl, + networkDetails.networkPassphrase, + ); + const sourceAccount: Account = await server.loadAccount(publicKey); + const operation = getOperation( + sourceAsset, + destAsset, + amount, + destinationAmount, + destination, + allowedSlippage, + path, + isPathPayment, + isSwap, + isFunded, + publicKey, + ); + return new TransactionBuilder(sourceAccount, { + fee: xlmToStroop(fee).toFixed(), + networkPassphrase: networkDetails.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(transactionTimeout); +}; + +export const TransactionDetails = ({ + goBack, + shouldScanTx, +}: { + goBack: () => void; + shouldScanTx: boolean; +}) => { const dispatch: AppDispatch = useDispatch(); const submission = useSelector(transactionSubmissionSelector); const { + accountBalances, destinationBalances, transactionData: { destination, @@ -212,15 +287,15 @@ export const TransactionDetails = ({ goBack }: { goBack: () => void }) => { }, assetIcons, hardwareWalletData: { status: hwStatus }, - blockedAccounts, + memoRequiredAccounts, transactionSimulation, } = submission; const transactionHash = submission.response?.hash; const isPathPayment = useSelector(isPathPaymentSelector); - const { isMemoValidationEnabled, isSafetyValidationEnabled } = - useSelector(settingsSelector); + const { isMemoValidationEnabled } = useSelector(settingsSelector); const isSwap = useIsSwap(); + const { scanTx, data: scanResult, isLoading, setLoading } = useScanTx(); const { t } = useTranslation(); @@ -235,9 +310,8 @@ export const TransactionDetails = ({ goBack }: { goBack: () => void }) => { const _isMainnet = isMainnet(networkDetails); const isValidatingMemo = isMemoValidationEnabled && _isMainnet; - const isValidatingSafety = isSafetyValidationEnabled && _isMainnet; - const matchingBlockedTags = blockedAccounts + const matchingBlockedTags = memoRequiredAccounts .filter(({ address }) => address === destination) .flatMap(({ tags }) => tags); const isMemoRequired = @@ -245,13 +319,19 @@ export const TransactionDetails = ({ goBack }: { goBack: () => void }) => { matchingBlockedTags.some( (tag) => tag === TRANSACTION_WARNING.memoRequired && !memo, ); - const isMalicious = - isValidatingSafety && - matchingBlockedTags.some((tag) => tag === TRANSACTION_WARNING.malicious); - const isUnsafe = - isValidatingSafety && - matchingBlockedTags.some((tag) => tag === TRANSACTION_WARNING.unsafe); - const isSubmitDisabled = isMemoRequired || isMalicious || isUnsafe; + + const isSourceAssetSuspicious = isAssetSuspicious( + accountBalances.balances?.[asset].blockaidData, + ); + + const isSubmitDisabled = isMemoRequired; + + const destAssetToScan = destinationAsset + ? `${destAsset.code}-${destAsset.issuer}` + : ""; + + const { scannedAsset: scannedDestAsset } = useScanAsset(destAssetToScan); + const isDestAssetSuspicious = isAssetSuspicious(scannedDestAsset); // load destination asset icons useEffect(() => { @@ -268,9 +348,58 @@ export const TransactionDetails = ({ goBack }: { goBack: () => void }) => { }, [destAsset.code, destAsset.issuer, networkDetails]); useEffect(() => { - dispatch(getBlockedAccounts()); + dispatch(getMemoRequiredAccounts()); }, [dispatch]); + useEffect(() => { + const url = "internal"; // blockaid prefers a URL for this endpoint, but this does not originate from a URL + const scanSorobanTx = async () => { + if ( + shouldScanTx && + submission.submitStatus === ActionStatus.IDLE && + transactionSimulation.preparedTransaction + ) { + await scanTx( + transactionSimulation.preparedTransaction, + url, + networkDetails, + ); + } + setLoading(false); + }; + const scanClassicTx = async () => { + if (shouldScanTx) { + const transaction = await getBuiltTx( + publicKey, + { + sourceAsset, + destAsset, + amount, + destinationAmount, + destination, + allowedSlippage, + path, + isPathPayment, + isSwap, + isFunded: destinationBalances.isFunded!, + }, + transactionFee, + transactionTimeout, + networkDetails, + ); + + await scanTx(transaction.build().toXDR(), url, networkDetails); + } + setLoading(false); + }; + if (isToken || isSoroswap) { + scanSorobanTx(); + return; + } + scanClassicTx(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const handleSorobanTransaction = async () => { try { const res = await dispatch( @@ -315,41 +444,33 @@ export const TransactionDetails = ({ goBack }: { goBack: () => void }) => { const handlePaymentTransaction = async () => { try { - const server = stellarSdkServer( - networkDetails.networkUrl, - networkDetails.networkPassphrase, - ); - const sourceAccount: Account = await server.loadAccount(publicKey); - - const operation = getOperation( - sourceAsset, - destAsset, - amount, - destinationAmount, - destination, - allowedSlippage, - path, - isPathPayment, - isSwap, - destinationBalances.isFunded!, + const transaction = await getBuiltTx( publicKey, + { + sourceAsset, + destAsset, + amount, + destinationAmount, + destination, + allowedSlippage, + path, + isPathPayment, + isSwap, + isFunded: destinationBalances.isFunded!, + }, + transactionFee, + transactionTimeout, + networkDetails, ); - const transactionXDR = new TransactionBuilder(sourceAccount, { - fee: xlmToStroop(transactionFee).toFixed(), - networkPassphrase: networkDetails.networkPassphrase, - }) - .addOperation(operation) - .setTimeout(transactionTimeout); - if (memo) { - transactionXDR.addMemo(Memo.text(memo)); + transaction.addMemo(Memo.text(memo)); } if (isHardwareWallet) { dispatch( startHwSign({ - transactionXDR: transactionXDR.build().toXDR(), + transactionXDR: transaction.build().toXDR(), shouldSubmit: true, }), ); @@ -357,7 +478,7 @@ export const TransactionDetails = ({ goBack }: { goBack: () => void }) => { } const res = await dispatch( signFreighterTransaction({ - transactionXDR: transactionXDR.build().toXDR(), + transactionXDR: transaction.build().toXDR(), network: networkDetails.networkPassphrase, }), ); @@ -436,186 +557,219 @@ export const TransactionDetails = ({ goBack }: { goBack: () => void }) => { {hwStatus === ShowOverlayStatus.IN_PROGRESS && hardwareWalletType && ( )} - - {submission.submitStatus === ActionStatus.PENDING && ( -
-
- {" "} - - {t("Processing")} {isSwap ? t("swap") : t("transaction")} - -
-
- {t("Please don’t close this window")} -
-
- )} - - ) : null - } - /> - - {(isPathPayment || isSwap) && - submission.submitStatus !== ActionStatus.SUCCESS && - t("The final amount is approximate and may change")} -
- } - > - {!(isPathPayment || isSwap) && ( -
- - - -
- )} - - {(isPathPayment || isSwap) && ( - - )} - - {!isSwap && ( -
-
{t("Sending to")}
-
-
- -
+ {isLoading ? ( +
+ +
+ ) : ( + + {submission.submitStatus === ActionStatus.PENDING && ( +
+
+ {" "} + + {t("Processing")} {isSwap ? t("swap") : t("transaction")} +
-
- )} - {showMemo && ( -
-
{t("Memo")}
-
- {memo || t("None")} +
+ {t("Please don’t close this window")}
)} - - {(isPathPayment || isSwap) && ( -
-
{t("Conversion rate")}
-
- 1 {sourceAsset.code} /{" "} - {getConversionRate(amount, destinationAmount).toFixed(2)}{" "} - {destAsset.code} + + ) : null + } + /> + + {!(isPathPayment || isSwap) && ( +
+ + +
-
- )} -
-
{t("Transaction fee")}
-
- {transactionFee} XLM -
-
- {transactionSimulation.response && ( - <> + )} + + {(isPathPayment || isSwap) && ( + + )} + + {!isSwap && (
-
{t("Resource cost")}
+
{t("Sending to")}
-
- {transactionSimulation.response.cost.cpuInsns} CPU -
-
- {transactionSimulation.response.cost.memBytes} Bytes +
+
+ )} + {showMemo && (
-
{t("Minimum resource fee")}
+
{t("Memo")}
- {transactionSimulation.response.minResourceFee} XLM + {memo || t("None")}
- - )} - {isSwap && ( + )} + + {(isPathPayment || isSwap) && ( +
+
{t("Conversion rate")}
+
+ 1 {sourceAsset.code} /{" "} + {getConversionRate(amount, destinationAmount).toFixed(2)}{" "} + {destAsset.code} +
+
+ )}
-
{t("Minimum Received")}
+
{t("Transaction fee")}
- {computeDestMinWithSlippage( - allowedSlippage, - destinationAmount, - ).toFixed()}{" "} - {destAsset.code} + {transactionFee} XLM
- )} - {submission.submitStatus === ActionStatus.IDLE && ( - - )} - - - {submission.submitStatus === ActionStatus.SUCCESS ? ( - - ) : ( - <> - - - - )} - - + {transactionSimulation.response && ( + <> +
+
{t("Resource cost")}
+
+
+ {transactionSimulation.response.cost.cpuInsns} CPU +
+
+ {transactionSimulation.response.cost.memBytes} Bytes +
+
+
+
+
{t("Minimum resource fee")}
+
+ {transactionSimulation.response.minResourceFee} XLM +
+
+ + )} + {isSwap && ( +
+
{t("Minimum Received")}
+
+ {computeDestMinWithSlippage( + allowedSlippage, + destinationAmount, + ).toFixed()}{" "} + {destAsset.code} +
+
+ )} +
+ {scanResult?.validation && + "result_type" in scanResult.validation && ( + + )} + {submission.submitStatus === ActionStatus.IDLE && ( + + )} +
+ +
+ {(isPathPayment || isSwap) && + submission.submitStatus !== ActionStatus.SUCCESS && + t("The final amount is approximate and may change")} +
+ + {submission.submitStatus === ActionStatus.SUCCESS ? ( + + ) : ( + <> + + + + )} + + + )} ); }; diff --git a/extension/src/popup/components/sendPayment/SendConfirm/TransactionDetails/styles.scss b/extension/src/popup/components/sendPayment/SendConfirm/TransactionDetails/styles.scss index 7cfad0088..de9f50d09 100644 --- a/extension/src/popup/components/sendPayment/SendConfirm/TransactionDetails/styles.scss +++ b/extension/src/popup/components/sendPayment/SendConfirm/TransactionDetails/styles.scss @@ -3,6 +3,15 @@ flex-direction: column; text-align: center; + &__loader { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + &__cards { margin-bottom: 1.5rem; row-gap: 1rem; @@ -113,6 +122,15 @@ align-items: center; text-align: center; justify-content: center; + padding-top: 1rem; + } + } + + &__warnings { + margin-top: 1.5rem; + + .WarningMessage__infoBlock { + margin-bottom: 0.375rem; } } } @@ -123,7 +141,7 @@ border-radius: 0.5rem; border: 1px solid var(--color-gray-10); position: relative; - margin-bottom: 1.5rem; + margin: -1.5rem 0 1.5rem 0; &__row:first-child { border-bottom: 1px solid var(--color-gray-10); diff --git a/extension/src/popup/components/sendPayment/SendConfirm/index.tsx b/extension/src/popup/components/sendPayment/SendConfirm/index.tsx index 02ec3a732..fcfc5c988 100644 --- a/extension/src/popup/components/sendPayment/SendConfirm/index.tsx +++ b/extension/src/popup/components/sendPayment/SendConfirm/index.tsx @@ -24,6 +24,7 @@ export const SendConfirm = ({ previous }: { previous: ROUTES }) => { if (isSendComplete) { return ( { dispatch(resetSubmission()); navigateTo(ROUTES.accountHistory); @@ -33,15 +34,30 @@ export const SendConfirm = ({ previous }: { previous: ROUTES }) => { } switch (submission.submitStatus) { case ActionStatus.IDLE: - return navigateTo(previous)} />; + return ( + navigateTo(previous)} + /> + ); case ActionStatus.PENDING: - return navigateTo(previous)} />; + return ( + navigateTo(previous)} + /> + ); case ActionStatus.SUCCESS: return setIsSendComplete(true)} />; case ActionStatus.ERROR: return ; default: - return navigateTo(previous)} />; + return ( + navigateTo(previous)} + /> + ); } }; diff --git a/extension/src/popup/components/sendPayment/SendSettings/Settings/index.tsx b/extension/src/popup/components/sendPayment/SendSettings/Settings/index.tsx index 5c6b87cf5..1954f389e 100644 --- a/extension/src/popup/components/sendPayment/SendSettings/Settings/index.tsx +++ b/extension/src/popup/components/sendPayment/SendSettings/Settings/index.tsx @@ -1,15 +1,20 @@ -import React, { useEffect } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useSelector, useDispatch } from "react-redux"; import { Formik, Form, Field, FieldProps } from "formik"; -import { Icon, Textarea, Link, Button } from "@stellar/design-system"; +import { Icon, Textarea, Link, Button, Loader } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; -import { Asset } from "stellar-sdk"; +import { Asset, BASE_FEE } from "stellar-sdk"; +import BigNumber from "bignumber.js"; import { navigateTo } from "popup/helpers/navigate"; import { useNetworkFees } from "popup/helpers/useNetworkFees"; import { useIsSwap } from "popup/helpers/useIsSwap"; import { getNativeContractDetails } from "popup/helpers/searchAsset"; -import { isMuxedAccount, getAssetFromCanonical } from "helpers/stellar"; +import { + isMuxedAccount, + getAssetFromCanonical, + stroopToXlm, +} from "helpers/stellar"; import { ROUTES } from "popup/constants/routes"; import { SubviewHeader } from "popup/components/SubviewHeader"; import { FormRows } from "popup/basics/Forms"; @@ -23,12 +28,21 @@ import { transactionSubmissionSelector, saveIsToken, } from "popup/ducks/transactionSubmission"; -import { simulateTokenPayment, simulateSwap } from "popup/ducks/token-payment"; +import { + simulateTokenPayment, + simulateSwap, + tokenSimulationSelector, +} from "popup/ducks/token-payment"; import { InfoTooltip } from "popup/basics/InfoTooltip"; import { publicKeySelector } from "popup/ducks/accountServices"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; -import { parseTokenAmount, isContractId } from "popup/helpers/soroban"; +import { + parseTokenAmount, + isContractId, + formatTokenAmount, + CLASSIC_ASSET_DECIMALS, +} from "popup/helpers/soroban"; import { Balances, TokenBalance } from "@shared/api/types"; import { AppDispatch } from "popup/App"; @@ -62,36 +76,15 @@ export const Settings = ({ const isPathPayment = useSelector(isPathPaymentSelector); const publicKey = useSelector(publicKeySelector); const { accountBalances } = useSelector(transactionSubmissionSelector); + const { simulation } = useSelector(tokenSimulationSelector); const isSwap = useIsSwap(); const { recommendedFee } = useNetworkFees(); + const [isLoadingSimulation, setLoadingSimulation] = useState(true); - // use default transaction fee if unset - useEffect(() => { - if (!transactionFee) { - dispatch(saveTransactionFee(recommendedFee)); - } - }, [dispatch, recommendedFee, transactionFee]); - - const handleTxFeeNav = () => - navigateTo(isSwap ? ROUTES.swapSettingsFee : ROUTES.sendPaymentSettingsFee); - - const handleSlippageNav = () => - navigateTo( - isSwap ? ROUTES.swapSettingsSlippage : ROUTES.sendPaymentSettingsSlippage, - ); - - const handleTimeoutNav = () => - navigateTo( - isSwap ? ROUTES.swapSettingsTimeout : ROUTES.sendPaymentSettingsTimeout, - ); - - // dont show memo for regular sends to Muxed, or for swaps - const showMemo = !isSwap && !isMuxedAccount(destination); - const showSlippage = (isPathPayment || isSwap) && !isSoroswap; const isSendSacToContract = isContractId(destination) && !isContractId(getAssetFromCanonical(asset).issuer); - const getSacContractAddress = () => { + const getSacContractAddress = useCallback(() => { if (asset === "native") { return getNativeContractDetails(networkDetails).contract; } @@ -105,74 +98,152 @@ export const Settings = ({ ); return contractAddress; - }; + }, [asset, networkDetails]); - async function goToReview() { - if (isSoroswap) { - const simulatedTx = await dispatch( - simulateSwap({ - networkDetails, - publicKey, - amountIn: amount, - amountInDecimals: decimals || 0, - amountOut: destinationAmount, - amountOutDecimals: destinationDecimals || 0, - memo, - transactionFee, - path, - }), + useEffect(() => { + async function simulateTx() { + // use default transaction fee if unset + const baseFee = new BigNumber( + transactionFee || recommendedFee || stroopToXlm(BASE_FEE), ); - if (simulateSwap.fulfilled.match(simulatedTx)) { - dispatch(saveSimulation(simulatedTx.payload)); - navigateTo(next); - } - return; - } - - if (isToken || isSendSacToContract) { - const assetAddress = isSendSacToContract - ? getSacContractAddress() - : asset.split(":")[1]; - const balances = - accountBalances.balances || ({} as NonNullable); - const assetBalance = balances[asset] as TokenBalance; + if (isSoroswap) { + const simulatedTx = await dispatch( + simulateSwap({ + networkDetails, + publicKey, + amountIn: amount, + amountInDecimals: decimals || 0, + amountOut: destinationAmount, + amountOutDecimals: destinationDecimals || 0, + memo, + transactionFee, + path, + }), + ); - if (!assetBalance) { - throw new Error("Asset Balance not available"); + if (simulateSwap.fulfilled.match(simulatedTx)) { + dispatch(saveSimulation(simulatedTx.payload)); + const minResourceFee = formatTokenAmount( + new BigNumber( + simulatedTx.payload.simulationTransaction.minResourceFee, + ), + CLASSIC_ASSET_DECIMALS, + ); + dispatch( + saveTransactionFee( + baseFee.plus(new BigNumber(minResourceFee)).toString(), + ), + ); + } + return; } - const parsedAmount = isSendSacToContract - ? parseTokenAmount(amount, 7) - : parseTokenAmount(amount, Number(assetBalance.decimals)); + if ( + (isToken || isSendSacToContract) && + !simulation.simulationTransaction?.minResourceFee + ) { + const assetAddress = isSendSacToContract + ? getSacContractAddress() + : asset.split(":")[1]; + const balances = + accountBalances.balances || ({} as NonNullable); + const assetBalance = balances[asset] as TokenBalance; + + if (!assetBalance) { + throw new Error("Asset Balance not available"); + } - const params = { - publicKey, - destination, - amount: parsedAmount.toNumber(), - }; + const parsedAmount = isSendSacToContract + ? parseTokenAmount(amount, CLASSIC_ASSET_DECIMALS) + : parseTokenAmount(amount, Number(assetBalance.decimals)); - const simulation = await dispatch( - simulateTokenPayment({ - address: assetAddress, + const params = { publicKey, - memo, - params, - networkDetails, - transactionFee, - }), - ); + destination, + amount: parsedAmount.toNumber(), + }; + + const simResponse = await dispatch( + simulateTokenPayment({ + address: assetAddress, + publicKey, + memo, + params, + networkDetails, + transactionFee, + }), + ); - if (simulateTokenPayment.fulfilled.match(simulation)) { - dispatch(saveSimulation(simulation.payload)); - dispatch(saveIsToken(true)); - navigateTo(next); + if ( + simulateTokenPayment.fulfilled.match(simResponse) && + recommendedFee + ) { + const minResourceFee = formatTokenAmount( + new BigNumber( + simResponse.payload.simulationTransaction.minResourceFee, + ), + CLASSIC_ASSET_DECIMALS, + ); + dispatch(saveSimulation(simResponse.payload)); + dispatch(saveIsToken(true)); + dispatch( + saveTransactionFee( + baseFee.plus(new BigNumber(minResourceFee)).toString(), + ), + ); + } + return; } - return; + + if (!transactionFee) { + dispatch(saveTransactionFee(baseFee.toString())); + } + } + async function setFee() { + setLoadingSimulation(true); + await simulateTx(); + setLoadingSimulation(false); } + setFee(); + }, [ + dispatch, + recommendedFee, + transactionFee, + accountBalances.balances, + amount, + asset, + decimals, + destination, + destinationAmount, + destinationDecimals, + getSacContractAddress, + isSendSacToContract, + isSoroswap, + isToken, + memo, + networkDetails, + path, + publicKey, + simulation.simulationTransaction?.minResourceFee, + ]); + + const handleTxFeeNav = () => + navigateTo(isSwap ? ROUTES.swapSettingsFee : ROUTES.sendPaymentSettingsFee); + + const handleSlippageNav = () => + navigateTo( + isSwap ? ROUTES.swapSettingsSlippage : ROUTES.sendPaymentSettingsSlippage, + ); + + const handleTimeoutNav = () => + navigateTo( + isSwap ? ROUTES.swapSettingsTimeout : ROUTES.sendPaymentSettingsTimeout, + ); - navigateTo(next); - } + // dont show memo for regular sends to Muxed, or for swaps + const showMemo = !isSwap && !isMuxedAccount(destination); + const showSlippage = (isPathPayment || isSwap) && !isSoroswap; return ( @@ -180,17 +251,21 @@ export const Settings = ({ title={`${isSwap ? t("Swap") : t("Send")} ${t("Settings")}`} customBackAction={() => navigateTo(previous)} /> - { - dispatch(saveMemo(values.memo)); - }} - > - {({ submitForm }) => ( -
- - - {!isToken ? ( + {isLoadingSimulation ? ( +
+ +
+ ) : ( + { + dispatch(saveMemo(values.memo)); + }} + > + {({ submitForm }) => ( + + +
- ) : null} -
-
- - {t( - "Number of seconds that can pass before this transaction can no longer be accepted by the network", - )}{" "} - - } - placement="bottom" - > - { - submitForm(); - handleTimeoutNav(); - }} - > - {t("Transaction timeout")} - - -
-
{ - submitForm(); - handleTimeoutNav(); - }} - > - - {transactionTimeout}(s) - -
- -
-
-
- - {showSlippage && (
{t( - "Allowed downward variation in the destination amount", + "Number of seconds that can pass before this transaction can no longer be accepted by the network", )}{" "} - - {t("Learn more")} - } placement="bottom" @@ -301,10 +327,10 @@ export const Settings = ({ className="SendSettings__row__title SendSettings__clickable" onClick={() => { submitForm(); - handleSlippageNav(); + handleTimeoutNav(); }} > - {t("Allowed slippage")} + {t("Transaction timeout")}
@@ -312,29 +338,30 @@ export const Settings = ({ className="SendSettings__row__right SendSettings__clickable" onClick={() => { submitForm(); - handleSlippageNav(); + handleTimeoutNav(); }} > - - {allowedSlippage}% + + {transactionTimeout}(s)
- )} - {showMemo && ( - <> + + {showSlippage && (
- {t("Include a custom memo to this transaction")}{" "} + {t( + "Allowed downward variation in the destination amount", + )}{" "} @@ -344,45 +371,93 @@ export const Settings = ({ } placement="bottom" > - - {t("Memo")} + { + submitForm(); + handleSlippageNav(); + }} + > + {t("Allowed slippage")}
-
- +
{ + submitForm(); + handleSlippageNav(); + }} + > + + {allowedSlippage}% + +
+ +
- - {({ field }: FieldProps) => ( -