diff --git a/@shared/api/external.ts b/@shared/api/external.ts index 52ebedd38d..1ca5774e3c 100644 --- a/@shared/api/external.ts +++ b/@shared/api/external.ts @@ -60,6 +60,7 @@ export const submitTransaction = async ( }); } catch (e) { console.error(e); + throw e; } const { signedTransaction, error } = response; @@ -86,6 +87,7 @@ export const submitBlob = async ( }); } catch (e) { console.error(e); + throw e; } const { signedBlob, error } = response; @@ -95,6 +97,32 @@ export const submitBlob = async ( return signedBlob; }; +export const submitAuthEntry = async ( + entryXdr: string, + opts?: { + accountToSign?: string; + }, +): Promise => { + let response = { signedAuthEntry: "", error: "" }; + const _opts = opts || {}; + const accountToSign = _opts.accountToSign || ""; + try { + response = await sendMessageToContentScript({ + entryXdr, + accountToSign, + type: EXTERNAL_SERVICE_TYPES.SUBMIT_AUTH_ENTRY, + }); + } catch (e) { + console.error(e); + } + const { signedAuthEntry, error } = response; + + if (error) { + throw error; + } + return signedAuthEntry; +}; + export const requestNetwork = async (): Promise => { let response = { network: "", error: "" }; try { diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 7e4327549b..749fc1613d 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -535,6 +535,16 @@ export const signBlob = async (): Promise => { } }; +export const signAuthEntry = async (): Promise => { + try { + await sendMessageToBackground({ + type: SERVICE_TYPES.SIGN_AUTH_ENTRY, + }); + } catch (e) { + console.error(e); + } +}; + export const signFreighterTransaction = async ({ transactionXDR, network, @@ -626,7 +636,7 @@ export const submitFreighterSorobanTransaction = async ({ console.error(e); } - const server = new SorobanClient.Server(SOROBAN_RPC_URLS.FUTURENET, { + const server = new SorobanClient.Server(SOROBAN_RPC_URLS.FUTURENET!, { allowHttp: true, }); diff --git a/@shared/api/types.ts b/@shared/api/types.ts index db8aaecd09..3652d793df 100644 --- a/@shared/api/types.ts +++ b/@shared/api/types.ts @@ -37,6 +37,7 @@ export interface Response { transactionXDR: string; signedTransaction: string; signedBlob: string; + signedAuthEntry: string; source: string; type: SERVICE_TYPES; url: string; @@ -96,7 +97,14 @@ export interface ExternalRequestBlob extends ExternalRequestBase { blob: string; } -export type ExternalRequest = ExternalRequestTx | ExternalRequestBlob; +export interface ExternalRequestAuthEntry extends ExternalRequestBase { + entryXdr: string; +} + +export type ExternalRequest = + | ExternalRequestTx + | ExternalRequestBlob + | ExternalRequestAuthEntry; export interface Account { publicKey: string; diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 8649889785..21a310d8ff 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -15,6 +15,7 @@ export enum SERVICE_TYPES { GRANT_ACCESS = "GRANT_ACCESS", SIGN_TRANSACTION = "SIGN_TRANSACTION", SIGN_BLOB = "SIGN_BLOB", + SIGN_AUTH_ENTRY = "SIGN_AUTH_ENTRY", HANDLE_SIGNED_HW_TRANSACTION = "HANDLE_SIGNED_HW_TRANSACTION", REJECT_TRANSACTION = "REJECT_TRANSACTION", SIGN_FREIGHTER_TRANSACTION = "SIGN_FREIGHTER_TRANSACTION", @@ -45,6 +46,7 @@ export enum EXTERNAL_SERVICE_TYPES { REQUEST_ACCESS = "REQUEST_ACCESS", SUBMIT_TRANSACTION = "SUBMIT_TRANSACTION", SUBMIT_BLOB = "SUBMIT_BLOB", + SUBMIT_AUTH_ENTRY = "SUBMIT_AUTH_ENTRY", REQUEST_NETWORK = "REQUEST_NETWORK", REQUEST_NETWORK_DETAILS = "REQUEST_NETWORK_DETAILS", REQUEST_CONNECTION_STATUS = "REQUEST_CONNECTION_STATUS", diff --git a/@shared/constants/stellar.ts b/@shared/constants/stellar.ts index ba8dff6c24..ae44cafb73 100644 --- a/@shared/constants/stellar.ts +++ b/@shared/constants/stellar.ts @@ -23,8 +23,8 @@ export enum FRIENDBOT_URLS { FUTURENET = "https://friendbot-futurenet.stellar.org", } -export const SOROBAN_RPC_URLS = { - FUTURENET: "https://rpc-futurenet.stellar.org/", +export const SOROBAN_RPC_URLS: { [key in NETWORKS]?: string } = { + [NETWORKS.FUTURENET]: "https://rpc-futurenet.stellar.org/", }; export interface NetworkDetails { diff --git a/@stellar/freighter-api/src/__tests__/index.test.js b/@stellar/freighter-api/src/__tests__/index.test.js index 5de51117e9..9986bc6813 100644 --- a/@stellar/freighter-api/src/__tests__/index.test.js +++ b/@stellar/freighter-api/src/__tests__/index.test.js @@ -6,5 +6,6 @@ describe("freighter API", () => { expect(typeof FreighterAPI.getPublicKey).toBe("function"); expect(typeof FreighterAPI.signTransaction).toBe("function"); expect(typeof FreighterAPI.signBlob).toBe("function"); + expect(typeof FreighterAPI.signAuthEntry).toBe("function"); }); }); diff --git a/@stellar/freighter-api/src/__tests__/signBlob.test.js b/@stellar/freighter-api/src/__tests__/signBlob.test.js index 55dc0512c6..272b08c105 100644 --- a/@stellar/freighter-api/src/__tests__/signBlob.test.js +++ b/@stellar/freighter-api/src/__tests__/signBlob.test.js @@ -8,6 +8,7 @@ describe("signBlob", () => { const blob = await signBlob(); expect(blob).toBe(TEST_BLOB); }); + it("throws a generic error", () => { const TEST_ERROR = "Error!"; apiExternal.submitBlob = jest.fn().mockImplementation(() => { diff --git a/@stellar/freighter-api/src/index.ts b/@stellar/freighter-api/src/index.ts index 66909f9258..31162a5c33 100644 --- a/@stellar/freighter-api/src/index.ts +++ b/@stellar/freighter-api/src/index.ts @@ -1,6 +1,7 @@ import { getPublicKey } from "./getPublicKey"; import { signTransaction } from "./signTransaction"; import { signBlob } from "./signBlob"; +import { signAuthEntry } from "./signAuthEntry"; import { isConnected } from "./isConnected"; import { getNetwork } from "./getNetwork"; import { getNetworkDetails } from "./getNetworkDetails"; @@ -14,6 +15,7 @@ export { getPublicKey, signTransaction, signBlob, + signAuthEntry, isConnected, getNetwork, getNetworkDetails, @@ -25,6 +27,7 @@ export default { getPublicKey, signTransaction, signBlob, + signAuthEntry, isConnected, getNetwork, getNetworkDetails, diff --git a/@stellar/freighter-api/src/signAuthEntry.ts b/@stellar/freighter-api/src/signAuthEntry.ts new file mode 100644 index 0000000000..dd14cdf8f0 --- /dev/null +++ b/@stellar/freighter-api/src/signAuthEntry.ts @@ -0,0 +1,10 @@ +import { submitAuthEntry } from "@shared/api/external"; +import { isBrowser } from "."; + +export const signAuthEntry = ( + entryXdr: string, + opts?: { + accountToSign?: string; + } +): Promise => + isBrowser ? submitAuthEntry(entryXdr, opts) : Promise.resolve(""); diff --git a/docs/docs/guide/usingFreighterNode.md b/docs/docs/guide/usingFreighterNode.md index 77292bfee6..e6ed686ad0 100644 --- a/docs/docs/guide/usingFreighterNode.md +++ b/docs/docs/guide/usingFreighterNode.md @@ -19,6 +19,7 @@ or import just the modules you require: import { isConnected, getPublicKey, + signAuthEntry, signTransaction, signBlob, } from "@stellar/freighter-api"; @@ -82,6 +83,7 @@ If the user has authorized your application previously, it will be on the extens import { isConnected, getPublicKey, + signAuthEntry, signTransaction, signBlob, } from "@stellar/freighter-api"; @@ -124,6 +126,7 @@ import { isAllowed, setAllowed, getUserInfo, + signAuthEntry, signTransaction, signBlob, } from "@stellar/freighter-api"; @@ -177,6 +180,7 @@ This function is useful for determining what network the user has configured Fre import { isConnected, getNetwork, + signAuthEntry, signTransaction, signBlob, } from "@stellar/freighter-api"; @@ -234,7 +238,7 @@ import { isConnected, getPublicKey, signTransaction, - signBlob + signBlob, } from "@stellar/freighter-api"; if (await isConnected()) { diff --git a/extension/src/background/messageListener/freighterApiMessageListener.ts b/extension/src/background/messageListener/freighterApiMessageListener.ts index 9357cae3de..a554190ca4 100644 --- a/extension/src/background/messageListener/freighterApiMessageListener.ts +++ b/extension/src/background/messageListener/freighterApiMessageListener.ts @@ -4,6 +4,7 @@ import browser from "webextension-polyfill"; import { Store } from "redux"; import { + ExternalRequestAuthEntry, ExternalRequestBlob, ExternalRequestTx, ExternalRequest as Request, @@ -39,6 +40,7 @@ import { import { publicKeySelector } from "background/ducks/session"; import { + authEntryQueue, blobQueue, responseQueue, transactionQueue, @@ -254,9 +256,7 @@ export const freighterApiMessageListener = ( blobQueue.push(blobData); const encodedBlob = encodeObject(blobData); const popup = browser.windows.create({ - url: chrome.runtime.getURL( - `/index.html#/sign-transaction?${encodedBlob}`, - ), + url: chrome.runtime.getURL(`/index.html#/sign-blob?${encodedBlob}`), ...WINDOW_SETTINGS, }); @@ -286,6 +286,59 @@ export const freighterApiMessageListener = ( }); }; + const submitAuthEntry = async () => { + const { entryXdr, accountToSign } = request as ExternalRequestAuthEntry; + + const { tab, url: tabUrl = "" } = sender; + const domain = getUrlHostname(tabUrl); + const punycodedDomain = getPunycodedDomain(domain); + + const allowListStr = (await localStore.getItem(ALLOWLIST_ID)) || ""; + const allowList = allowListStr.split(","); + const isDomainListedAllowed = await isSenderAllowed({ sender }); + + const authEntry = { + entry: entryXdr, + accountToSign, + tab, + url: tabUrl, + }; + + authEntryQueue.push(authEntry); + const encodedAuthEntry = encodeObject(authEntry); + const popup = browser.windows.create({ + url: chrome.runtime.getURL( + `/index.html#/sign-auth-entry?${encodedAuthEntry}`, + ), + ...WINDOW_SETTINGS, + }); + + return new Promise((resolve) => { + if (!popup) { + resolve({ error: "Couldn't open access prompt" }); + } else { + browser.windows.onRemoved.addListener(() => + resolve({ + error: "User declined access", + }), + ); + } + const response = (signedAuthEntry: string) => { + if (signedAuthEntry) { + if (!isDomainListedAllowed) { + allowList.push(punycodedDomain); + localStore.setItem(ALLOWLIST_ID, allowList.join()); + } + resolve({ signedAuthEntry }); + } + + resolve({ error: "User declined access" }); + }; + + responseQueue.push(response); + }); + }; + const requestNetwork = async () => { let network = ""; @@ -378,6 +431,7 @@ export const freighterApiMessageListener = ( [EXTERNAL_SERVICE_TYPES.REQUEST_ACCESS]: requestAccess, [EXTERNAL_SERVICE_TYPES.SUBMIT_TRANSACTION]: submitTransaction, [EXTERNAL_SERVICE_TYPES.SUBMIT_BLOB]: submitBlob, + [EXTERNAL_SERVICE_TYPES.SUBMIT_AUTH_ENTRY]: submitAuthEntry, [EXTERNAL_SERVICE_TYPES.REQUEST_NETWORK]: requestNetwork, [EXTERNAL_SERVICE_TYPES.REQUEST_NETWORK_DETAILS]: requestNetworkDetails, [EXTERNAL_SERVICE_TYPES.REQUEST_CONNECTION_STATUS]: requestConnectionStatus, diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index cebb21e4c9..1bcf828c79 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -104,6 +104,13 @@ export const blobQueue: Array<{ accountToSign: string; }> = []; +export const authEntryQueue: Array<{ + accountToSign: string; + tab: browser.Tabs.Tab | undefined; + entry: string; // xdr.SorobanAuthorizationEntry + url: string; +}> = []; + interface KeyPair { publicKey: string; privateKey: string; @@ -932,6 +939,29 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { return { error: "Session timed out" }; }; + const signAuthEntry = async () => { + const privateKey = privateKeySelector(sessionStore.getState()); + + if (privateKey.length) { + const sourceKeys = SorobanSdk.Keypair.fromSecret(privateKey); + + const authEntry = authEntryQueue.pop(); + + const response = authEntry + ? await sourceKeys.sign(Buffer.from(authEntry.entry)) + : null; + + const entryResponse = responseQueue.pop(); + + if (typeof entryResponse === "function") { + entryResponse(response); + return {}; + } + } + + return { error: "Session timed out" }; + }; + const rejectTransaction = () => { transactionQueue.pop(); const response = responseQueue.pop(); @@ -1224,6 +1254,7 @@ export const popupMessageListener = (request: Request, sessionStore: Store) => { [SERVICE_TYPES.REJECT_ACCESS]: rejectAccess, [SERVICE_TYPES.SIGN_TRANSACTION]: signTransaction, [SERVICE_TYPES.SIGN_BLOB]: signBlob, + [SERVICE_TYPES.SIGN_AUTH_ENTRY]: signAuthEntry, [SERVICE_TYPES.HANDLE_SIGNED_HW_TRANSACTION]: handleSignedHwTransaction, [SERVICE_TYPES.REJECT_TRANSACTION]: rejectTransaction, [SERVICE_TYPES.SIGN_FREIGHTER_TRANSACTION]: signFreighterTransaction, diff --git a/extension/src/helpers/__tests__/stellar.test.ts b/extension/src/helpers/__tests__/stellar.test.ts index 24afd294e4..71258b0863 100644 --- a/extension/src/helpers/__tests__/stellar.test.ts +++ b/extension/src/helpers/__tests__/stellar.test.ts @@ -26,7 +26,7 @@ describe("getTransactionInfo", () => { tab: {}, }); const info = getTransactionInfo("foo"); - if (!("blob" in info)) { + if (!("blob" in info) && !("entry" in info)) { expect(info.isHttpsDomain).toBe(true); } }); @@ -41,7 +41,7 @@ describe("getTransactionInfo", () => { tab: {}, }); const info = getTransactionInfo("foo"); - if (!("blob" in info)) { + if (!("blob" in info) && !("entry" in info)) { expect(info.isHttpsDomain).toBe(false); } }); diff --git a/extension/src/helpers/stellar.ts b/extension/src/helpers/stellar.ts index c714daab6c..ca18c5977d 100644 --- a/extension/src/helpers/stellar.ts +++ b/extension/src/helpers/stellar.ts @@ -10,6 +10,7 @@ import { NetworkDetails, } from "@shared/constants/stellar"; +import { TransactionInfo } from "types/transactions"; import { parsedSearchParam, getUrlHostname } from "./urls"; // .isBigNumber() not catching correctly, so checking .isBigNumber @@ -34,11 +35,7 @@ export const truncatedFedAddress = (addr: string) => { export const truncatedPoolId = (poolId: string) => truncateString(poolId); export const getTransactionInfo = (search: string) => { - const searchParams = parsedSearchParam(search); - - if ("blob" in searchParams) { - return searchParams; - } + const searchParams = parsedSearchParam(search) as TransactionInfo; const { accountToSign, diff --git a/extension/src/helpers/urls.ts b/extension/src/helpers/urls.ts index d3296dd3ea..9a1107af98 100644 --- a/extension/src/helpers/urls.ts +++ b/extension/src/helpers/urls.ts @@ -5,12 +5,21 @@ import { TransactionInfo } from "../types/transactions"; export interface BlobToSign { isDomainListedAllowed: boolean; domain: string; - tab: browser.Tabs.Tab | undefined; + tab?: browser.Tabs.Tab; blob: string; url: string; accountToSign: string; } +export interface EntryToSign { + isDomainListedAllowed: boolean; + domain: string; + tab?: browser.Tabs.Tab; + entry: string; + url: string; + accountToSign: string; +} + export const encodeObject = (obj: {}) => btoa(unescape(encodeURIComponent(JSON.stringify(obj)))); @@ -24,7 +33,7 @@ export const removeQueryParam = (url = "") => url.replace(/\?(.*)/, ""); export const parsedSearchParam = ( param: string, -): TransactionInfo | BlobToSign => { +): TransactionInfo | BlobToSign | EntryToSign => { const decodedSearchParam = decodeString(param.replace("?", "")); return decodedSearchParam ? JSON.parse(decodedSearchParam) : {}; }; diff --git a/extension/src/popup/Router.tsx b/extension/src/popup/Router.tsx index c14598dfe7..7b0a0ef76d 100644 --- a/extension/src/popup/Router.tsx +++ b/extension/src/popup/Router.tsx @@ -46,6 +46,7 @@ import { MnemonicPhrase } from "popup/views/MnemonicPhrase"; import { FullscreenSuccessMessage } from "popup/views/FullscreenSuccessMessage"; import { RecoverAccount } from "popup/views/RecoverAccount"; import { SignTransaction } from "popup/views/SignTransaction"; +import { SignAuthEntry } from "popup/views/SignAuthEntry"; import { UnlockAccount } from "popup/views/UnlockAccount"; import { Welcome } from "popup/views/Welcome"; import { DisplayBackupPhrase } from "popup/views/DisplayBackupPhrase"; @@ -65,6 +66,7 @@ import { PinExtension } from "popup/views/PinExtension"; import "popup/metrics/views"; import { DEV_SERVER } from "@shared/constants/services"; +import { SignBlob } from "./views/SignBlob"; import { SorobanProvider } from "./SorobanContext"; @@ -276,6 +278,12 @@ export const Router = () => { + + + + + + diff --git a/extension/src/popup/SorobanContext.tsx b/extension/src/popup/SorobanContext.tsx index a35e7161ff..d4a16d9a44 100644 --- a/extension/src/popup/SorobanContext.tsx +++ b/extension/src/popup/SorobanContext.tsx @@ -2,16 +2,18 @@ import React from "react"; import { useSelector } from "react-redux"; import * as SorobanClient from "soroban-client"; -import { - SOROBAN_RPC_URLS, - FUTURENET_NETWORK_DETAILS, -} from "@shared/constants/stellar"; +import { NETWORKS, SOROBAN_RPC_URLS } from "@shared/constants/stellar"; import { settingsNetworkDetailsSelector } from "./ducks/settings"; +export const hasSorobanClient = ( + context: SorobanContextInterface, +): context is Required => + context.server !== undefined && context.newTxBuilder !== undefined; + export interface SorobanContextInterface { - server: SorobanClient.Server; - newTxBuilder: (fee?: string) => Promise; + server?: SorobanClient.Server; + newTxBuilder?: (fee?: string) => Promise; } export const SorobanContext = React.createContext( @@ -26,25 +28,24 @@ export const SorobanProvider = ({ pubKey: string; }) => { const networkDetails = useSelector(settingsNetworkDetailsSelector); + const serverUrl = SOROBAN_RPC_URLS[networkDetails.network as NETWORKS]; + + let server: SorobanContextInterface["server"]; + let newTxBuilder: SorobanContextInterface["newTxBuilder"]; - const serverUrl = - networkDetails.networkPassphrase === - "Test SDF Future Network ; October 2022" && - networkDetails.networkUrl === FUTURENET_NETWORK_DETAILS.networkUrl - ? SOROBAN_RPC_URLS.FUTURENET - : networkDetails.networkUrl; - - const server = new SorobanClient.Server(serverUrl, { - allowHttp: networkDetails.networkUrl.startsWith("http://"), - }); - - const newTxBuilder = async (fee = SorobanClient.BASE_FEE) => { - const sourceAccount = await server.getAccount(pubKey); - return new SorobanClient.TransactionBuilder(sourceAccount, { - fee, - networkPassphrase: networkDetails.networkPassphrase, + if (serverUrl) { + server = new SorobanClient.Server(serverUrl, { + allowHttp: networkDetails.networkUrl.startsWith("http://"), }); - }; + + newTxBuilder = async (fee = SorobanClient.BASE_FEE) => { + const sourceAccount = await server!.getAccount(pubKey); + return new SorobanClient.TransactionBuilder(sourceAccount, { + fee, + networkPassphrase: networkDetails.networkPassphrase, + }); + }; + } return ( diff --git a/extension/src/popup/components/accountHistory/HistoryItem/index.tsx b/extension/src/popup/components/accountHistory/HistoryItem/index.tsx index 07369ca2b0..492be9aa1e 100644 --- a/extension/src/popup/components/accountHistory/HistoryItem/index.tsx +++ b/extension/src/popup/components/accountHistory/HistoryItem/index.tsx @@ -1,7 +1,7 @@ import React, { useContext, useState, useEffect } from "react"; import { captureException } from "@sentry/browser"; import camelCase from "lodash/camelCase"; -import { Icon } from "@stellar/design-system"; +import { Icon, Loader } from "@stellar/design-system"; import { BigNumber } from "bignumber.js"; import { useTranslation } from "react-i18next"; @@ -11,7 +11,7 @@ import { getDecimals, getName, getSymbol } from "@shared/helpers/soroban/token"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { emitMetric } from "helpers/metrics"; -import { SorobanContext } from "popup/SorobanContext"; +import { SorobanContext, hasSorobanClient } from "popup/SorobanContext"; import { formatTokenAmount, getAttrsFromSorobanHorizonOp, @@ -24,7 +24,6 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { TransactionDetailProps } from "../TransactionDetail"; import "./styles.scss"; -// TODO function capitalize(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } @@ -117,6 +116,7 @@ export const HistoryItem = ({ const [txDetails, setTxDetails] = useState(transactionDetailPropsBase); const [dateText, setDateText] = useState(date); const [rowText, setRowText] = useState(""); + const [isLoading, setIsLoading] = useState(false); const [IconComponent, setIconComponent] = useState( ( @@ -229,6 +229,7 @@ export const HistoryItem = ({ // they are not neccessarily minted to themselves. // If user has minted to self, add token to their token list. if (!token) { + setIsLoading(true); // TODO: When fetching contract details, we could encounter an expired state entry // and fail to fetch values through the RPC. // We can address this in several ways - @@ -236,6 +237,10 @@ export const HistoryItem = ({ // 2. If not SAC or unknown, look up ledger entry directly. try { + if (!hasSorobanClient(sorobanClient)) { + throw new Error("Soroban RPC not supported for this network"); + } + const tokenDecimals = await getDecimals( attrs.contractId, sorobanClient.server, @@ -288,6 +293,7 @@ export const HistoryItem = ({ isRecipient: isRecieving, operationText: `${formattedTokenAmount} ${tokenSymbol}`, })); + setIsLoading(false); } catch (error) { console.error(error); captureException(`Error fetching token details: ${error}`); @@ -315,6 +321,7 @@ export const HistoryItem = ({ isRecipient: isRecieving, operationText: operationString, })); + setIsLoading(false); } } else { const formattedTokenAmount = formatTokenAmount( @@ -436,13 +443,21 @@ export const HistoryItem = ({ }} >
-
{renderIcon()}
-
- {rowText} -
{dateText}
-
+ {isLoading ? ( +
+ +
+ ) : ( + <> +
{renderIcon()}
+
+ {rowText} +
{dateText}
+
-
{renderBodyComponent()}
+
{renderBodyComponent()}
+ + )}
); diff --git a/extension/src/popup/components/accountHistory/HistoryItem/styles.scss b/extension/src/popup/components/accountHistory/HistoryItem/styles.scss index c88716fa3e..294811e4f4 100644 --- a/extension/src/popup/components/accountHistory/HistoryItem/styles.scss +++ b/extension/src/popup/components/accountHistory/HistoryItem/styles.scss @@ -2,6 +2,13 @@ color: var(--pal-text-primary); cursor: pointer; + &__loader { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + &__row { display: flex; } diff --git a/extension/src/popup/components/sendPayment/SendConfirm/TransactionDetails/index.tsx b/extension/src/popup/components/sendPayment/SendConfirm/TransactionDetails/index.tsx index 49ad22ca6f..e7e7fa24eb 100644 --- a/extension/src/popup/components/sendPayment/SendConfirm/TransactionDetails/index.tsx +++ b/extension/src/popup/components/sendPayment/SendConfirm/TransactionDetails/index.tsx @@ -8,7 +8,7 @@ import { Types } from "@stellar/wallet-sdk"; import { Card, Loader, Icon } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; -import { SorobanContext } from "popup/SorobanContext"; +import { SorobanContext, hasSorobanClient } from "popup/SorobanContext"; import { getAssetFromCanonical, getCanonicalFromAsset, @@ -216,8 +216,6 @@ export const TransactionDetails = ({ goBack }: { goBack: () => void }) => { const { t } = useTranslation(); - const { server: sorobanServer } = useContext(SorobanContext); - const publicKey = useSelector(publicKeySelector); const networkDetails = useSelector(settingsNetworkDetailsSelector); const hardwareWalletType = useSelector(hardwareWalletTypeSelector); @@ -287,13 +285,17 @@ export const TransactionDetails = ({ goBack }: { goBack: () => void }) => { new SorobanClient.XdrLargeInt("i128", parsedAmount.toNumber()).toI128(), // amount ]; + if (!hasSorobanClient(sorobanClient)) { + throw new Error("Soroban RPC not supported for this network"); + } + const builder = await sorobanClient.newTxBuilder( xlmToStroop(transactionFee).toFixed(), ); const transaction = await transfer(assetAddress, params, memo, builder); - const preparedTransaction = await sorobanServer.prepareTransaction( + const preparedTransaction = await sorobanClient.server.prepareTransaction( transaction, networkDetails.networkPassphrase, ); diff --git a/extension/src/popup/components/signAuthEntry/AuthEntry/index.tsx b/extension/src/popup/components/signAuthEntry/AuthEntry/index.tsx new file mode 100644 index 0000000000..3094bfcd26 --- /dev/null +++ b/extension/src/popup/components/signAuthEntry/AuthEntry/index.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { xdr } from "soroban-client"; + +import { SimpleBarWrapper } from "popup/basics/SimpleBarWrapper"; +import { buildInvocationTree } from "../invocation"; +import "./styles.scss"; + +interface TransactionProps { + authEntryXdr: string; +} + +export const AuthEntry = ({ authEntryXdr }: TransactionProps) => { + const { t } = useTranslation(); + const authEntry = xdr.SorobanAuthorizationEntry.fromXDR( + authEntryXdr, + "base64", + ); + const rootJson = buildInvocationTree(authEntry.rootInvocation()); + + return ( +
+
{t("Authorization Entry")}
+
+
+          
+            {JSON.stringify(rootJson, null, 2)}
+          
+        
+
+
+ ); +}; diff --git a/extension/src/popup/components/signAuthEntry/AuthEntry/styles.scss b/extension/src/popup/components/signAuthEntry/AuthEntry/styles.scss new file mode 100644 index 0000000000..77aa5d1559 --- /dev/null +++ b/extension/src/popup/components/signAuthEntry/AuthEntry/styles.scss @@ -0,0 +1,26 @@ +.AuthEntry { + .AuthEntryHeader { + border-bottom: 1px solid var(--pal-border-secondary); + font-size: 0.875rem; + font-weight: var(--font-weight-medium); + line-height: 0.72rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + text-transform: uppercase; + } + + .AuthEntryAttributes { + display: flex; + flex-direction: column; + gap: 2rem; + margin-bottom: 1.5rem; + + pre { + background: var(--pal-example-details); + border-radius: 0.5rem; + font-size: 0.875rem; + margin: 0.5rem 0; + padding: 1rem; + } + } +} diff --git a/extension/src/popup/components/signAuthEntry/invocation.ts b/extension/src/popup/components/signAuthEntry/invocation.ts new file mode 100644 index 0000000000..457a8fcddd --- /dev/null +++ b/extension/src/popup/components/signAuthEntry/invocation.ts @@ -0,0 +1,87 @@ +import { Asset, Address, scValToNative, xdr } from "soroban-client"; + +interface RootOutput { + type: string; + args: Record; + subInvocations: RootOutput[]; +} + +export function buildInvocationTree(root: xdr.SorobanAuthorizedInvocation) { + const fn = root.function(); + const output = {} as RootOutput; + + switch (fn.switch().value) { + // sorobanAuthorizedFunctionTypeContractFn + case 0: { + const inner = fn.value() as xdr.SorobanAuthorizedContractFunction; + output.type = "execute"; + output.args = { + source: Address.fromScAddress(inner.contractAddress()).toString(), + function: inner.functionName(), + args: inner.args().map((arg) => scValToNative(arg).toString()), + }; + break; + } + + // sorobanAuthorizedFunctionTypeCreateContractHostFn + case 1: { + const inner = fn.value() as xdr.CreateContractArgs; + output.type = "create"; + output.args = { + type: "sac", + }; + + // If the executable is a WASM, the preimage MUST be an address. If it's a + // token, the preimage MUST be an asset. This is a cheeky way to check + // that, because wasm=0, address=1 and token=1, asset=0 in the XDR switch + // values. + // + // The first part may not be true in V2, but we'd need to update this code + // anyway so it can still be an error. + const [exec, preimage] = [inner.executable(), inner.contractIdPreimage()]; + if (!exec.switch().value !== !!preimage.switch().value) { + throw new Error( + `creation function appears invalid: ${JSON.stringify(inner)}`, + ); + } + + switch (exec.switch().value) { + // contractExecutableWasm + case 0: { + const details = preimage.fromAddress(); + + output.args.type = "wasm"; + output.args.args = { + hash: exec.wasmHash().toString("hex"), + address: Address.fromScAddress(details.address()).toString(), + salt: details.salt().toString("hex"), + }; + break; + } + + // contractExecutableToken + case 1: + output.args.type = "sac"; + output.args.asset = Asset.fromOperation( + preimage.fromAsset(), + ).toString(); + break; + + default: + throw new Error(`unknown creation type: ${JSON.stringify(exec)}`); + } + + break; + } + + default: + throw new Error( + `unknown invocation type (${fn.switch()}): ${JSON.stringify(fn)}`, + ); + } + + output.subInvocations = root + .subInvocations() + .map((i) => buildInvocationTree(i)); + return output; +} diff --git a/extension/src/popup/components/signBlob/index.tsx b/extension/src/popup/components/signBlob/index.tsx index 871761043f..94ae5efbc3 100644 --- a/extension/src/popup/components/signBlob/index.tsx +++ b/extension/src/popup/components/signBlob/index.tsx @@ -10,6 +10,6 @@ interface BlobProps { export const Blob = (props: BlobProps) => ( Signing data: -
{props.blob}
+
{atob(props.blob)}
); diff --git a/extension/src/popup/components/signTransaction/Operations/index.tsx b/extension/src/popup/components/signTransaction/Operations/index.tsx index 5ee81eec3d..ccec9ced7c 100644 --- a/extension/src/popup/components/signTransaction/Operations/index.tsx +++ b/extension/src/popup/components/signTransaction/Operations/index.tsx @@ -27,9 +27,11 @@ import { import { SimpleBarWrapper } from "popup/basics/SimpleBarWrapper"; import { KeyIdenticon } from "popup/components/identicons/KeyIdenticon"; -import { SorobanContext } from "popup/SorobanContext"; +import { hasSorobanClient, SorobanContext } from "popup/SorobanContext"; import "./styles.scss"; +import { xdr } from "soroban-client"; +import { buildInvocationTree } from "popup/components/signAuthEntry/invocation"; interface Path { code: string; @@ -166,6 +168,37 @@ const KeyValueWithScValue = ({ ); +const KeyValueWithScAuth = ({ + operationKey, + operationValue, +}: { + operationKey: string; + operationValue: { + _attributes: { + credentials: xdr.SorobanCredentials; + rootInvocation: xdr.SorobanAuthorizedInvocation; + }; + }[]; +}) => { + // TODO: use getters in signTx to get these correctly + const rawEntry = operationValue[0] && operationValue[0]._attributes; + const authEntry = new xdr.SorobanAuthorizationEntry(rawEntry); + const rootJson = buildInvocationTree(authEntry.rootInvocation()); + return ( +
+
+ {operationKey} + {operationKey ? ":" : null} +
+ +
+
{JSON.stringify(rootJson, null, 2)}
+
+
+
+ ); +}; + const PathList = ({ paths }: { paths: [Path] }) => { const { t } = useTranslation(); @@ -321,7 +354,7 @@ export const Operations = ({ const [decimals, setDecimals] = useState(0); useEffect(() => { - if (!contractId) return; + if (!contractId || !hasSorobanClient(sorobanClient)) return; const fetchContractDecimals = async () => { const contractDecimals = await getDecimals( contractId, @@ -783,15 +816,9 @@ export const Operations = ({ /> ))} - {scFunc ? ( - - ) : null} {scAuth ? ( - ) : null} diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index b5addf81a9..fd7c93970a 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -19,6 +19,8 @@ export const METRIC_NAMES = { viewRecoverAccount: "loaded screen: recover account", viewRecoverAccountSuccess: "loaded screen: recover account: success", viewSignTransaction: "loaded screen: sign transaction", + viewSignBlob: "loaded screen: sign blob", + viewSignAuthEntry: "loaded screen: sign auth entry", viewUnlockAccount: "loaded screen: unlock account", viewVerifyAccount: "loaded screen: verify account", viewUnlockBackupPhrase: "loaded screen: unlock backup phrase", @@ -116,6 +118,9 @@ export const METRIC_NAMES = { signBlob: "sign blob: confirmed", rejectBlob: "sign blob: rejected", + signAuthEntry: "sign auth entry: confirmed", + rejectAuthEntry: "sign auth entry: rejected", + backupPhraseSuccess: "backup phrase: success", backupPhraseFail: "backup phrase: error", diff --git a/extension/src/popup/constants/routes.ts b/extension/src/popup/constants/routes.ts index a31b26362d..c5356ae2de 100644 --- a/extension/src/popup/constants/routes.ts +++ b/extension/src/popup/constants/routes.ts @@ -25,6 +25,8 @@ export enum ROUTES { swapConfirm = "/swap/confirm", addAccount = "/add-account", signTransaction = "/sign-transaction", + signBlob = "/sign-blob", + signAuthEntry = "/sign-auth-entry", grantAccess = "/grant-access", mnemonicPhrase = "/mnemonic-phrase", mnemonicPhraseConfirm = "/mnemonic-phrase/confirm", diff --git a/extension/src/popup/ducks/access.ts b/extension/src/popup/ducks/access.ts index 740492b586..f763fed89d 100644 --- a/extension/src/popup/ducks/access.ts +++ b/extension/src/popup/ducks/access.ts @@ -5,6 +5,7 @@ import { grantAccess as internalGrantAccess, signTransaction as internalSignTransaction, signBlob as internalSignBlob, + signAuthEntry as internalSignAuthEntry, } from "@shared/api/internal"; export const grantAccess = createAsyncThunk("grantAccess", internalGrantAccess); @@ -20,6 +21,7 @@ export const signTransaction = createAsyncThunk( ); export const signBlob = createAsyncThunk("signBlob", internalSignBlob); +export const signEntry = createAsyncThunk("signEntry", internalSignAuthEntry); // Basically an alias for metrics purposes export const rejectTransaction = createAsyncThunk( @@ -29,3 +31,7 @@ export const rejectTransaction = createAsyncThunk( // Basically an alias for metrics purposes export const rejectBlob = createAsyncThunk("rejectBlob", internalRejectAccess); +export const rejectAuthEntry = createAsyncThunk( + "rejectAuthEntry", + internalRejectAccess, +); diff --git a/extension/src/popup/ducks/soroban.ts b/extension/src/popup/ducks/soroban.ts index 4a23923344..2fbced0de3 100644 --- a/extension/src/popup/ducks/soroban.ts +++ b/extension/src/popup/ducks/soroban.ts @@ -8,7 +8,10 @@ import { getTokenIds as internalGetTokenIds, } from "@shared/api/internal"; import { ErrorMessage, ActionStatus, TokenBalances } from "@shared/api/types"; -import { SorobanContextInterface } from "popup/SorobanContext"; +import { + SorobanContextInterface, + hasSorobanClient, +} from "popup/SorobanContext"; export const getTokenBalances = createAsyncThunk< TokenBalances, @@ -32,6 +35,10 @@ export const getTokenBalances = createAsyncThunk< */ try { + if (!hasSorobanClient(sorobanClient)) { + throw new Error("Soroban RPC is not supprted for this network"); + } + /* eslint-disable no-await-in-loop */ const { balance, ...rest } = await internalGetSorobanTokenBalance( sorobanClient.server, diff --git a/extension/src/popup/helpers/useSetupSigningFlow.ts b/extension/src/popup/helpers/useSetupSigningFlow.ts new file mode 100644 index 0000000000..ca2e9160fe --- /dev/null +++ b/extension/src/popup/helpers/useSetupSigningFlow.ts @@ -0,0 +1,138 @@ +import { useEffect, useRef, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { AppDispatch } from "popup/App"; +import { signTransaction, rejectTransaction } from "popup/ducks/access"; + +import { Account } from "@shared/api/types"; + +import { + allAccountsSelector, + confirmPassword, + hardwareWalletTypeSelector, + hasPrivateKeySelector, + makeAccountActive, + publicKeySelector, +} from "popup/ducks/accountServices"; + +import { + ShowOverlayStatus, + startHwSign, + transactionSubmissionSelector, +} from "popup/ducks/transactionSubmission"; + +export function useSetupSigningFlow( + reject: typeof rejectTransaction, + signFn: typeof signTransaction, + transactionXdr: string, + accountToSign?: string, +) { + const [isConfirming, setIsConfirming] = useState(false); + const [isPasswordRequired, setIsPasswordRequired] = useState(false); + const [startedHwSign, setStartedHwSign] = useState(false); + const [accountNotFound, setAccountNotFound] = useState(false); + const [currentAccount, setCurrentAccount] = useState({} as Account); + + const dispatch: AppDispatch = useDispatch(); + const allAccounts = useSelector(allAccountsSelector); + const hasPrivateKey = useSelector(hasPrivateKeySelector); + const hardwareWalletType = useSelector(hardwareWalletTypeSelector); + const publicKey = useSelector(publicKeySelector); + + // the public key the user had selected before starting this flow + const defaultPublicKey = useRef(publicKey); + const allAccountsMap = useRef({} as { [key: string]: Account }); + const isHardwareWallet = !!hardwareWalletType; + const { + hardwareWalletData: { status: hwStatus }, + } = useSelector(transactionSubmissionSelector); + + const rejectAndClose = () => { + dispatch(reject()); + window.close(); + }; + + const signAndClose = async () => { + if (isHardwareWallet) { + dispatch( + startHwSign({ transactionXDR: transactionXdr, shouldSubmit: false }), + ); + setStartedHwSign(true); + } else { + await dispatch(signFn()); + window.close(); + } + }; + + const handleApprove = async () => { + setIsConfirming(true); + + if (hasPrivateKey) { + await signAndClose(); + } else { + setIsPasswordRequired(true); + } + + setIsConfirming(false); + }; + + const verifyPasswordThenSign = async (password: string) => { + const confirmPasswordResp = await dispatch(confirmPassword(password)); + + if (confirmPassword.fulfilled.match(confirmPasswordResp)) { + await signAndClose(); + } + }; + + useEffect(() => { + if (startedHwSign && hwStatus === ShowOverlayStatus.IDLE) { + window.close(); + } + }, [startedHwSign, hwStatus]); + + useEffect(() => { + // handle auto selecting the right account based on `accountToSign` + let autoSelectedAccountDetails; + + allAccounts.forEach((account) => { + if (accountToSign) { + // does the user have the `accountToSign` somewhere in the accounts list? + if (account.publicKey === accountToSign) { + // if the `accountToSign` is found, but it isn't active, make it active + if (defaultPublicKey.current !== account.publicKey) { + dispatch(makeAccountActive(account.publicKey)); + } + + // save the details of the `accountToSign` + autoSelectedAccountDetails = account; + } + } + + // create an object so we don't need to keep iterating over allAccounts when we switch accounts + allAccountsMap.current[account.publicKey] = account; + }); + + if (!autoSelectedAccountDetails) { + setAccountNotFound(true); + } + }, [accountToSign, allAccounts, dispatch]); + + useEffect(() => { + // handle any changes to the current acct - whether by auto select or manual select + setCurrentAccount(allAccountsMap.current[publicKey] || ({} as Account)); + }, [allAccounts, publicKey]); + + return { + allAccounts, + accountNotFound, + currentAccount, + handleApprove, + isHardwareWallet, + publicKey, + hwStatus, + isConfirming, + isPasswordRequired, + rejectAndClose, + setIsPasswordRequired, + verifyPasswordThenSign, + }; +} diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 9708107fbe..c0e428d8c4 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -52,7 +52,7 @@ "Asset not found": "Asset not found", "Assets found in this domain": "Assets found in this domain", "At least one uppercase letter": "At least one uppercase letter", - "Auth": "Auth", + "Authorization Entry": "Authorization Entry", "Authorization Immutable": "Authorization Immutable", "Authorization Required": "Authorization Required", "Authorization Required; Authorization Immutable": "Authorization Required; Authorization Immutable", @@ -167,7 +167,6 @@ }, "Friendbot URL": "Friendbot URL", "From": "From", - "Func": "Func", "Function Name": "Function Name", "Fund with Friendbot": "Fund with Friendbot", "Got it": "Got it", @@ -201,8 +200,10 @@ "Invalid Format Asset": "Invalid Format Asset", "invalid public key": "invalid public key", "INVALID STELLAR ADDRESS": "INVALID STELLAR ADDRESS", + "Invocation": "Invocation", "is requesting approval to a": "is requesting approval to a", "is requesting approval to sign a blob of data": "is requesting approval to sign a blob of data", + "is requesting approval to sign an authorization entry": "is requesting approval to sign an authorization entry", "Issuer": "Issuer", "Keep it in a safe place": "Keep it in a safe place.", "Keep your recovery phrase in a safe and secure place": { @@ -333,6 +334,7 @@ "Share Public Key": "Share Public Key", "Show recovery phrase": "Show recovery phrase", "Signer": "Signer", + "Signing arbitrary data with a hardware wallet is currently not supported": "Signing arbitrary data with a hardware wallet is currently not supported.", "Signing this transaction is not possible at the moment": "Signing this transaction is not possible at the moment.", "Source": "Source", "Sponsored Id": "Sponsored Id", @@ -395,6 +397,7 @@ "Unable to connect to": "Unable to connect to", "Unable to search for assets": "Unable to search for assets", "Unsafe": "Unsafe", + "Unsupported signing method": "Unsupported signing method", "URL": "URL", "Validate addresses that require a memo": "Validate addresses that require a memo", "Value Data": "Value Data", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 2c11a19733..0179c0a99a 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -52,7 +52,7 @@ "Asset not found": "Asset not found", "Assets found in this domain": "Assets found in this domain", "At least one uppercase letter": "At least one uppercase letter", - "Auth": "Auth", + "Authorization Entry": "Authorization Entry", "Authorization Immutable": "Authorization Immutable", "Authorization Required": "Authorization Required", "Authorization Required; Authorization Immutable": "Authorization Required; Authorization Immutable", @@ -167,7 +167,6 @@ }, "Friendbot URL": "Friendbot URL", "From": "From", - "Func": "Func", "Function Name": "Function Name", "Fund with Friendbot": "Fund with Friendbot", "Got it": "Got it", @@ -201,8 +200,10 @@ "Invalid Format Asset": "Invalid Format Asset", "invalid public key": "invalid public key", "INVALID STELLAR ADDRESS": "INVALID STELLAR ADDRESS", + "Invocation": "Invocation", "is requesting approval to a": "is requesting approval to a", "is requesting approval to sign a blob of data": "is requesting approval to sign a blob of data", + "is requesting approval to sign an authorization entry": "is requesting approval to sign an authorization entry", "Issuer": "Issuer", "Keep it in a safe place": "Keep it in a safe place.", "Keep your recovery phrase in a safe and secure place": { @@ -333,6 +334,7 @@ "Share Public Key": "Share Public Key", "Show recovery phrase": "Show recovery phrase", "Signer": "Signer", + "Signing arbitrary data with a hardware wallet is currently not supported": "Signing arbitrary data with a hardware wallet is currently not supported.", "Signing this transaction is not possible at the moment": "Signing this transaction is not possible at the moment.", "Source": "Source", "Sponsored Id": "Sponsored Id", @@ -395,6 +397,7 @@ "Unable to connect to": "Unable to connect to", "Unable to search for assets": "Unable to search for assets", "Unsafe": "Unsafe", + "Unsupported signing method": "Unsupported signing method", "URL": "URL", "Validate addresses that require a memo": "Validate addresses that require a memo", "Value Data": "Value Data", diff --git a/extension/src/popup/metrics/access.ts b/extension/src/popup/metrics/access.ts index c3cb0fe918..a547e2d488 100644 --- a/extension/src/popup/metrics/access.ts +++ b/extension/src/popup/metrics/access.ts @@ -3,6 +3,7 @@ import { METRIC_NAMES } from "popup/constants/metricsNames"; import { grantAccess, rejectAccess, + signEntry, signTransaction, signBlob, rejectTransaction, @@ -36,3 +37,11 @@ registerHandler(signBlob.fulfilled, () => { accountType: metricsData.accountType, }); }); +registerHandler(signEntry.fulfilled, () => { + const metricsData: MetricsData = JSON.parse( + localStorage.getItem(METRICS_DATA) || "{}", + ); + emitMetric(METRIC_NAMES.signAuthEntry, { + accountType: metricsData.accountType, + }); +}); diff --git a/extension/src/popup/metrics/views.ts b/extension/src/popup/metrics/views.ts index e063fbf550..7f53341ad8 100644 --- a/extension/src/popup/metrics/views.ts +++ b/extension/src/popup/metrics/views.ts @@ -17,7 +17,9 @@ const routeToEventName = { [ROUTES.connectWallet]: METRIC_NAMES.viewConnectWallet, [ROUTES.connectWalletPlugin]: METRIC_NAMES.viewConnectWalletPlugin, [ROUTES.connectLedger]: METRIC_NAMES.viewConnectLedger, + [ROUTES.signBlob]: METRIC_NAMES.viewSignBlob, [ROUTES.signTransaction]: METRIC_NAMES.viewSignTransaction, + [ROUTES.signAuthEntry]: METRIC_NAMES.viewSignAuthEntry, [ROUTES.grantAccess]: METRIC_NAMES.viewGrantAccess, [ROUTES.mnemonicPhrase]: METRIC_NAMES.viewMnemonicPhrase, [ROUTES.mnemonicPhraseConfirm]: METRIC_NAMES.viewMnemonicPhraseConfirm, @@ -74,7 +76,7 @@ registerHandler(navigate, (_, a) => { throw new Error(`Didn't find a metric event name for path '${pathname}'`); } - // "/sign-transaction" and "/grant-access" require additionak metrics on loaded page + // "/sign-transaction" and "/grant-access" require additional metrics on loaded page if (pathname === ROUTES.grantAccess) { const { url } = parsedSearchParam(search); const METRIC_OPTION_DOMAIN = { @@ -87,17 +89,27 @@ registerHandler(navigate, (_, a) => { const { url } = parsedSearchParam(search); const info = getTransactionInfo(search); - if (!("blob" in info)) { - const { operations, operationTypes } = info; - const METRIC_OPTIONS = { - domain: getUrlDomain(url), - subdomain: getUrlHostname(url), - number_of_operations: operations.length, - operationTypes, - }; + const { operations, operationTypes } = info; + const METRIC_OPTIONS = { + domain: getUrlDomain(url), + subdomain: getUrlHostname(url), + number_of_operations: operations.length, + operationTypes, + }; + + emitMetric(eventName, METRIC_OPTIONS); + } else if ( + pathname === ROUTES.signAuthEntry || + pathname === ROUTES.signBlob + ) { + const { url } = parsedSearchParam(search); + + const METRIC_OPTIONS = { + domain: getUrlDomain(url), + subdomain: getUrlHostname(url), + }; - emitMetric(eventName, METRIC_OPTIONS); - } + emitMetric(eventName, METRIC_OPTIONS); } else { emitMetric(eventName); } diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 9b23ddfef0..300aeb727e 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -48,6 +48,7 @@ import { navigateTo } from "popup/helpers/navigate"; import { AccountAssets } from "popup/components/account/AccountAssets"; import { AccountHeader } from "popup/components/account/AccountHeader"; import { AssetDetail } from "popup/components/account/AssetDetail"; +import { Loading } from "popup/components/Loading"; import { NotFundedMessage } from "popup/components/account/NotFundedMessage"; import { BottomNav } from "popup/components/BottomNav"; import { SorobanContext } from "../../SorobanContext"; @@ -175,7 +176,9 @@ export const Account = () => { /> ) : ( <> - {isLoading ? null : ( + {isLoading ? ( + + ) : (
{ const stellarExpertUrl = getStellarExpertUrl(networkDetails); // differentiate between if data is still loading and if no account history results came back from Horizon + const shouldLoadToken = + isExperimentalModeEnabled || networkDetails.network === NETWORKS.FUTURENET; + const isTokenBalanceLoading = + (getTokenBalancesStatus === ActionStatus.IDLE || + getTokenBalancesStatus === ActionStatus.PENDING) && + shouldLoadToken; const isAccountHistoryLoading = isExperimentalModeEnabled - ? historySegments === null || - getTokenBalancesStatus === ActionStatus.IDLE || - getTokenBalancesStatus === ActionStatus.PENDING + ? historySegments === null || isTokenBalanceLoading : historySegments === null; useEffect(() => { const isSupportedSorobanAccountItem = (operation: HorizonOperation) => - // TODO: add mint and other common token interactions getIsSupportedSorobanOp(operation, networkDetails); - setIsLoading(true); const createSegments = ( operations: HorizonOperation[], showSorobanTxs = false, @@ -155,18 +158,24 @@ export const AccountHistory = () => { createSegments(res.operations, isExperimentalModeEnabled), ); - if (isExperimentalModeEnabled) { + if (shouldLoadToken) { dispatch(getTokenBalances({ sorobanClient })); } } catch (e) { console.error(e); } + }; + + const getData = async () => { + setIsLoading(true); + await fetchAccountHistory(); setIsLoading(false); }; - fetchAccountHistory(); + + getData(); return () => { - if (isExperimentalModeEnabled) { + if (shouldLoadToken) { dispatch(resetSorobanTokensStatus()); } }; @@ -175,6 +184,7 @@ export const AccountHistory = () => { networkDetails, isExperimentalModeEnabled, sorobanClient, + shouldLoadToken, dispatch, ]); @@ -182,7 +192,7 @@ export const AccountHistory = () => { ) : (
- {isLoading ? ( + {isLoading || isTokenBalanceLoading ? (
diff --git a/extension/src/popup/views/SignAuthEntry/index.tsx b/extension/src/popup/views/SignAuthEntry/index.tsx new file mode 100644 index 0000000000..67f6a6ce02 --- /dev/null +++ b/extension/src/popup/views/SignAuthEntry/index.tsx @@ -0,0 +1,218 @@ +import React, { useState } from "react"; +import { Card, Icon } from "@stellar/design-system"; +import { useLocation } from "react-router-dom"; +import { useSelector } from "react-redux"; +import { useTranslation, Trans } from "react-i18next"; +import { + ButtonsContainer, + ModalHeader, + ModalWrapper, +} from "popup/basics/Modal"; + +import { truncatedPublicKey } from "helpers/stellar"; +import { Button } from "popup/basics/buttons/Button"; +import { InfoBlock } from "popup/basics/InfoBlock"; +import { LedgerSign } from "popup/components/hardwareConnect/LedgerSign"; +import { AccountListIdenticon } from "popup/components/identicons/AccountListIdenticon"; +import { AccountList, OptionTag } from "popup/components/account/AccountList"; +import { PunycodedDomain } from "popup/components/PunycodedDomain"; +import { SlideupModal } from "popup/components/SlideupModal"; +import { + FirstTimeWarningMessage, + WarningMessageVariant, + WarningMessage, +} from "popup/components/WarningMessages"; +import { signEntry, rejectAuthEntry } from "popup/ducks/access"; +import { settingsExperimentalModeSelector } from "popup/ducks/settings"; +import { ShowOverlayStatus } from "popup/ducks/transactionSubmission"; +import { VerifyAccount } from "popup/views/VerifyAccount"; + +import { EntryToSign, parsedSearchParam } from "helpers/urls"; +import { AuthEntry } from "popup/components/signAuthEntry/AuthEntry"; + +import "./styles.scss"; +import { useSetupSigningFlow } from "popup/helpers/useSetupSigningFlow"; + +export const SignAuthEntry = () => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const location = useLocation(); + const { t } = useTranslation(); + const isExperimentalModeEnabled = useSelector( + settingsExperimentalModeSelector, + ); + + const params = parsedSearchParam(location.search) as EntryToSign; + const { accountToSign } = params; + + const { + allAccounts, + accountNotFound, + currentAccount, + isConfirming, + isPasswordRequired, + publicKey, + handleApprove, + hwStatus, + isHardwareWallet, + rejectAndClose, + setIsPasswordRequired, + verifyPasswordThenSign, + } = useSetupSigningFlow( + rejectAuthEntry, + signEntry, + params.entry, + accountToSign, + ); + + if (isHardwareWallet) { + return ( + + window.close()} + isActive + header={t("Unsupported signing method")} + > +

+ {t( + "Signing arbitrary data with a hardware wallet is currently not supported.", + )} +

+
+
+ ); + } + + if (!params.url.startsWith("https") && !isExperimentalModeEnabled) { + return ( + + window.close()} + isActive + variant={WarningMessageVariant.warning} + header={t("WEBSITE CONNECTION IS NOT SECURE")} + > +

+ + The website {{ domain: params.url }} does not use + an SSL certificate. For additional safety Freighter only works + with websites that provide an SSL certificate. + +

+
+
+ ); + } + + return isPasswordRequired ? ( + setIsPasswordRequired(false)} + customSubmit={verifyPasswordThenSign} + /> + ) : ( + <> + {hwStatus === ShowOverlayStatus.IN_PROGRESS && } +
+ + + {t("Confirm Data")} + + {isExperimentalModeEnabled ? ( + +

+ {t( + "You are interacting with data that may be using untested and changing schemas. Proceed at your own risk.", + )} +

+
+ ) : null} + {!params.isDomainListedAllowed ? : null} +
+ + +
+ {t("is requesting approval to sign an authorization entry")} +
+
+
+ {t("Approve using")}: +
+
setIsDropdownOpen(true)} + > + + + +
+ +
+
+
+
+ {accountNotFound && accountToSign ? ( +
+ + {t("The application is requesting a specific account")} ( + {truncatedPublicKey(accountToSign)}),{" "} + {t( + "which is not available on Freighter. If you own this account, you can import it into Freighter to complete this request.", + )} + +
+ ) : null} +
+ {/* Can replace AuthEntry once SignTx supports xdr classes */} + {/* */} + +
+ + + + + +
+ +
+
+
+ + ); +}; diff --git a/extension/src/popup/views/SignAuthEntry/styles.scss b/extension/src/popup/views/SignAuthEntry/styles.scss new file mode 100644 index 0000000000..ab1f214d34 --- /dev/null +++ b/extension/src/popup/views/SignAuthEntry/styles.scss @@ -0,0 +1,68 @@ +.SignAuthEntry { + height: var(--popup--height); + overflow: hidden; + position: relative; + + .AuthEntry { + display: -webkit-box; + width: 100%; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + word-wrap: break-word; + } + + &__inner-transaction { + border: 1px solid var(--pal-border-primary); + border-radius: 0.5rem; + height: 10rem; + opacity: 0.7; + overflow: auto; + padding: 1rem 2rem; + zoom: 0.7; + } + + &__info { + margin-bottom: 2rem; + } + + &__subject { + border-bottom: 1px solid var(--pal-border-secondary); + color: var(--pal-text-primary); + font-size: 0.875rem; + line-height: 1.5rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + } + + &__approval { + margin-bottom: -1rem; + + &__title { + color: rgba(255, 255, 255, 0.6); + font-size: 0.875rem; + line-height: 1.5rem; + } + } + + &__current-account { + align-items: flex-start; + display: flex; + justify-content: space-between; + margin: 1rem 0 0.5rem 0; + width: 100%; + + &__chevron { + display: flex; + width: 0.75rem; + } + } + + &__modal { + padding: 0.5rem 0 0.5rem 1.5rem; + } + + &__account-not-found { + margin-top: 1rem; + } +} diff --git a/extension/src/popup/views/SignTransaction/SignBlob/index.tsx b/extension/src/popup/views/SignBlob/index.tsx similarity index 73% rename from extension/src/popup/views/SignTransaction/SignBlob/index.tsx rename to extension/src/popup/views/SignBlob/index.tsx index 7957b97894..104b77184f 100644 --- a/extension/src/popup/views/SignTransaction/SignBlob/index.tsx +++ b/extension/src/popup/views/SignBlob/index.tsx @@ -1,106 +1,85 @@ -import React from "react"; +import React, { useState } from "react"; +import { useLocation } from "react-router-dom"; +import { useSelector } from "react-redux"; import { Card, Icon } from "@stellar/design-system"; import { useTranslation, Trans } from "react-i18next"; - -import { truncatedPublicKey } from "helpers/stellar"; +import { signBlob, rejectBlob } from "popup/ducks/access"; import { Button } from "popup/basics/buttons/Button"; import { InfoBlock } from "popup/basics/InfoBlock"; -import { signBlob } from "popup/ducks/access"; -import { confirmPassword } from "popup/ducks/accountServices"; - -import { - ButtonsContainer, - ModalHeader, - ModalWrapper, -} from "popup/basics/Modal"; - import { AccountListIdenticon } from "popup/components/identicons/AccountListIdenticon"; import { AccountList, OptionTag } from "popup/components/account/AccountList"; import { PunycodedDomain } from "popup/components/PunycodedDomain"; +import { Blob } from "popup/components/signBlob"; +import { settingsExperimentalModeSelector } from "popup/ducks/settings"; import { WarningMessageVariant, WarningMessage, FirstTimeWarningMessage, } from "popup/components/WarningMessages"; + +import { ShowOverlayStatus } from "popup/ducks/transactionSubmission"; + +import { + ButtonsContainer, + ModalHeader, + ModalWrapper, +} from "popup/basics/Modal"; + import { LedgerSign } from "popup/components/hardwareConnect/LedgerSign"; import { SlideupModal } from "popup/components/SlideupModal"; import { VerifyAccount } from "popup/views/VerifyAccount"; -import { AppDispatch } from "popup/App"; -import { - ShowOverlayStatus, - startHwSign, -} from "popup/ducks/transactionSubmission"; - -import { Account } from "@shared/api/types"; -import { BlobToSign } from "helpers/urls"; +import { BlobToSign, parsedSearchParam } from "helpers/urls"; +import { truncatedPublicKey } from "helpers/stellar"; -import "../styles.scss"; -import { useDispatch } from "react-redux"; -import { Blob } from "popup/components/signBlob"; +import "./styles.scss"; +import { useSetupSigningFlow } from "popup/helpers/useSetupSigningFlow"; -interface SignBlobBodyProps { - accountNotFound: boolean; - allAccounts: Account[]; - blob: BlobToSign; - currentAccount: Account; - handleApprove: (signAndClose: () => Promise) => () => Promise; - hwStatus: ShowOverlayStatus; - isConfirming: boolean; - isDropdownOpen: boolean; - isExperimentalModeEnabled: boolean; - isHardwareWallet: boolean; - isPasswordRequired: boolean; - publicKey: string; - rejectAndClose: () => void; - setIsDropdownOpen: (isRequired: boolean) => void; - setIsPasswordRequired: (isRequired: boolean) => void; - setStartedHwSign: (hasStarted: boolean) => void; -} +export const SignBlob = () => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); -export const SignBlobBody = ({ - publicKey, - allAccounts, - isDropdownOpen, - handleApprove, - isConfirming, - accountNotFound, - rejectAndClose, - currentAccount, - setIsDropdownOpen, - setIsPasswordRequired, - isPasswordRequired, - blob, - isExperimentalModeEnabled, - hwStatus, - isHardwareWallet, - setStartedHwSign, -}: SignBlobBodyProps) => { - const dispatch: AppDispatch = useDispatch(); + const location = useLocation(); const { t } = useTranslation(); - const { accountToSign, domain, isDomainListedAllowed } = blob; - - const signAndClose = async () => { - if (isHardwareWallet) { - await dispatch( - startHwSign({ transactionXDR: blob.blob, shouldSubmit: false }), - ); - setStartedHwSign(true); - } else { - await dispatch(signBlob()); - window.close(); - } - }; + const isExperimentalModeEnabled = useSelector( + settingsExperimentalModeSelector, + ); - const _handleApprove = handleApprove(signAndClose); + const blob = parsedSearchParam(location.search) as BlobToSign; + const { accountToSign, domain, isDomainListedAllowed } = blob; - const verifyPasswordThenSign = async (password: string) => { - const confirmPasswordResp = await dispatch(confirmPassword(password)); + const { + allAccounts, + accountNotFound, + currentAccount, + isConfirming, + isPasswordRequired, + publicKey, + handleApprove, + hwStatus, + isHardwareWallet, + rejectAndClose, + setIsPasswordRequired, + verifyPasswordThenSign, + } = useSetupSigningFlow(rejectBlob, signBlob, blob.blob, accountToSign); - if (confirmPassword.fulfilled.match(confirmPasswordResp)) { - await signAndClose(); - } - }; + if (isHardwareWallet) { + return ( + + window.close()} + isActive + header={t("Unsupported signing method")} + > +

+ {t( + "Signing arbitrary data with a hardware wallet is currently not supported.", + )} +

+
+
+ ); + } if (!domain.startsWith("https") && !isExperimentalModeEnabled) { return ( @@ -217,7 +196,7 @@ export const SignBlobBody = ({ diff --git a/extension/src/popup/views/SignBlob/styles.scss b/extension/src/popup/views/SignBlob/styles.scss new file mode 100644 index 0000000000..35b8153673 --- /dev/null +++ b/extension/src/popup/views/SignBlob/styles.scss @@ -0,0 +1,68 @@ +.SignBlob { + height: var(--popup--height); + overflow: hidden; + position: relative; + + .Blob { + display: -webkit-box; + width: 100%; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + word-wrap: break-word; + } + + &__inner-transaction { + border: 1px solid var(--pal-border-primary); + border-radius: 0.5rem; + height: 10rem; + opacity: 0.7; + overflow: auto; + padding: 1rem 2rem; + zoom: 0.7; + } + + &__info { + margin-bottom: 2rem; + } + + &__subject { + border-bottom: 1px solid var(--pal-border-secondary); + color: var(--pal-text-primary); + font-size: 0.875rem; + line-height: 1.5rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + } + + &__approval { + margin-bottom: -1rem; + + &__title { + color: rgba(255, 255, 255, 0.6); + font-size: 0.875rem; + line-height: 1.5rem; + } + } + + &__current-account { + align-items: flex-start; + display: flex; + justify-content: space-between; + margin: 1rem 0 0.5rem 0; + width: 100%; + + &__chevron { + display: flex; + width: 0.75rem; + } + } + + &__modal { + padding: 0.5rem 0 0.5rem 1.5rem; + } + + &__account-not-found { + margin-top: 1rem; + } +} diff --git a/extension/src/popup/views/SignTransaction/SignTx/index.tsx b/extension/src/popup/views/SignTransaction/SignTx/index.tsx deleted file mode 100644 index fc9cccafc3..0000000000 --- a/extension/src/popup/views/SignTransaction/SignTx/index.tsx +++ /dev/null @@ -1,412 +0,0 @@ -import React, { useCallback, useEffect } from "react"; -import { Card, Icon } from "@stellar/design-system"; -import StellarSdk, { FederationServer, MuxedAccount } from "stellar-sdk"; -import * as SorobanSdk from "soroban-client"; -import { useTranslation, Trans } from "react-i18next"; - -import { TRANSACTION_WARNING } from "constants/transaction"; - -import { emitMetric } from "helpers/metrics"; -import { - isFederationAddress, - isMuxedAccount, - truncatedPublicKey, -} from "helpers/stellar"; -import { decodeMemo } from "popup/helpers/parseTransaction"; -import { Button } from "popup/basics/buttons/Button"; -import { InfoBlock } from "popup/basics/InfoBlock"; -import { TransactionHeading } from "popup/basics/TransactionHeading"; -import { signTransaction } from "popup/ducks/access"; - -import { - ButtonsContainer, - ModalHeader, - ModalWrapper, -} from "popup/basics/Modal"; - -import { METRIC_NAMES } from "popup/constants/metricsNames"; - -import { AccountListIdenticon } from "popup/components/identicons/AccountListIdenticon"; -import { AccountList, OptionTag } from "popup/components/account/AccountList"; -import { PunycodedDomain } from "popup/components/PunycodedDomain"; -import { - WarningMessageVariant, - WarningMessage, - FirstTimeWarningMessage, - FlaggedWarningMessage, -} from "popup/components/WarningMessages"; -import { Transaction } from "popup/components/signTransaction/Transaction"; -import { LedgerSign } from "popup/components/hardwareConnect/LedgerSign"; -import { SlideupModal } from "popup/components/SlideupModal"; - -import { VerifyAccount } from "popup/views/VerifyAccount"; -import { - ShowOverlayStatus, - startHwSign, -} from "popup/ducks/transactionSubmission"; - -import { Account } from "@shared/api/types"; -import { FlaggedKeys } from "types/transactions"; -import { AppDispatch } from "popup/App"; - -import "../styles.scss"; -import { TransactionInfo } from "popup/components/signTransaction/TransactionInfo"; -import { confirmPassword } from "popup/ducks/accountServices"; -import { useDispatch } from "react-redux"; - -interface SignTxBodyProps { - allAccountsMap: React.MutableRefObject<{ [key: string]: Account }>; - accountNotFound: boolean; - allAccounts: Account[]; - currentAccount: Account; - handleApprove: (signAndClose: () => Promise) => () => Promise; - hwStatus: ShowOverlayStatus; - isConfirming: boolean; - isDropdownOpen: boolean; - isExperimentalModeEnabled: boolean; - isHardwareWallet: boolean; - isPasswordRequired: boolean; - networkName: string; - networkPassphrase: string; - publicKey: string; - rejectAndClose: () => void; - setCurrentAccount: (account: Account) => void; - setIsDropdownOpen: (isRequired: boolean) => void; - setIsPasswordRequired: (isRequired: boolean) => void; - setStartedHwSign: (hasStarted: boolean) => void; - startedHwSign: boolean; - tx: { - accountToSign: string | undefined; - transactionXdr: string; - domain: string; - domainTitle: any; - isHttpsDomain: boolean; - operations: any; - operationTypes: any; - isDomainListedAllowed: boolean; - flaggedKeys: FlaggedKeys; - }; -} - -export const SignTxBody = ({ - allAccountsMap, - setCurrentAccount, - startedHwSign, - setStartedHwSign, - publicKey, - allAccounts, - isDropdownOpen, - handleApprove, - isConfirming, - accountNotFound, - rejectAndClose, - currentAccount, - setIsDropdownOpen, - setIsPasswordRequired, - isPasswordRequired, - tx, - isExperimentalModeEnabled, - networkName, - networkPassphrase, - hwStatus, - isHardwareWallet, -}: SignTxBodyProps) => { - const dispatch: AppDispatch = useDispatch(); - const { t } = useTranslation(); - - const { - accountToSign: _accountToSign, - transactionXdr, - domain, - isDomainListedAllowed, - isHttpsDomain, - flaggedKeys, - } = tx; - - /* - Reconstruct the tx from xdr as passing a tx through extension contexts - loses custom prototypes associated with some values. This is fine for most cases - where we just need a high level overview of the tx, like just a list of operations. - But in this case, we will need the hostFn prototype associated with Soroban tx operations. - */ - - const SDK = isExperimentalModeEnabled ? SorobanSdk : StellarSdk; - const transaction = SDK.TransactionBuilder.fromXDR( - transactionXdr, - networkPassphrase, - ); - - const { - _fee, - _innerTransaction, - _memo, - _networkPassphrase, - _sequence, - } = transaction; - - const isFeeBump = !!_innerTransaction; - const memo = decodeMemo(_memo); - let accountToSign = _accountToSign; - - useEffect(() => { - if (startedHwSign && hwStatus === ShowOverlayStatus.IDLE) { - window.close(); - } - }, [startedHwSign, hwStatus]); - - const signAndClose = async () => { - if (isHardwareWallet) { - await dispatch( - startHwSign({ transactionXDR: transactionXdr, shouldSubmit: false }), - ); - setStartedHwSign(true); - } else { - await dispatch(signTransaction()); - window.close(); - } - }; - - const _handleApprove = handleApprove(signAndClose); - - const verifyPasswordThenSign = async (password: string) => { - const confirmPasswordResp = await dispatch(confirmPassword(password)); - - if (confirmPassword.fulfilled.match(confirmPasswordResp)) { - await signAndClose(); - } - }; - - const flaggedKeyValues = Object.values(flaggedKeys); - const isUnsafe = flaggedKeyValues.some(({ tags }) => - tags.includes(TRANSACTION_WARNING.unsafe), - ); - const isMalicious = flaggedKeyValues.some(({ tags }) => - tags.includes(TRANSACTION_WARNING.malicious), - ); - const isMemoRequired = flaggedKeyValues.some( - ({ tags }) => tags.includes(TRANSACTION_WARNING.memoRequired) && !memo, - ); - - const resolveFederatedAddress = useCallback(async (inputDest) => { - let resolvedPublicKey; - try { - const fedResp = await FederationServer.resolve(inputDest); - resolvedPublicKey = fedResp.account_id; - } catch (e) { - console.error(e); - } - - return resolvedPublicKey; - }, []); - - const decodeAccountToSign = async () => { - if (_accountToSign) { - if (isMuxedAccount(_accountToSign)) { - const mAccount = MuxedAccount.fromAddress(_accountToSign, "0"); - accountToSign = mAccount.baseAccount().accountId(); - } - if (isFederationAddress(_accountToSign)) { - accountToSign = (await resolveFederatedAddress( - accountToSign, - )) as string; - } - } - }; - decodeAccountToSign(); - - useEffect(() => { - if (isMemoRequired) { - emitMetric(METRIC_NAMES.signTransactionMemoRequired); - } - if (isUnsafe) { - emitMetric(METRIC_NAMES.signTransactionUnsafe); - } - if (isMalicious) { - emitMetric(METRIC_NAMES.signTransactionMalicious); - } - }, [isMemoRequired, isMalicious, isUnsafe]); - - useEffect(() => { - // handle any changes to the current acct - whether by auto select or manual select - setCurrentAccount(allAccountsMap.current[publicKey] || ({} as Account)); - }, [allAccountsMap, allAccounts, publicKey, setCurrentAccount]); - - const isSubmitDisabled = isMemoRequired || isMalicious; - - if (_networkPassphrase !== networkPassphrase) { - return ( - - window.close()} - isActive - header={`${t("Freighter is set to")} ${networkName}`} - > -

- {t("The transaction you’re trying to sign is on")}{" "} - {_networkPassphrase}. -

-

{t("Signing this transaction is not possible at the moment.")}

-
-
- ); - } - - if (!isHttpsDomain && !isExperimentalModeEnabled) { - return ( - - window.close()} - isActive - variant={WarningMessageVariant.warning} - header={t("WEBSITE CONNECTION IS NOT SECURE")} - > -

- - The website {{ domain }} does not use an SSL - certificate. For additional safety Freighter only works with - websites that provide an SSL certificate. - -

-
-
- ); - } - return isPasswordRequired ? ( - setIsPasswordRequired(false)} - customSubmit={verifyPasswordThenSign} - /> - ) : ( - <> - {hwStatus === ShowOverlayStatus.IN_PROGRESS && } -
- - - {t("Confirm Transaction")} - - {isExperimentalModeEnabled ? ( - -

- {t( - "You are interacting with a transaction that may be using untested and changing schemas. Proceed at your own risk.", - )} -

-
- ) : null} - {flaggedKeyValues.length ? ( - - ) : null} - {!isDomainListedAllowed && !isSubmitDisabled ? ( - - ) : null} -
- - -
- {t("is requesting approval to a")}{" "} - {isFeeBump ? "fee bump " : ""} - {t("transaction")}: -
-
-
- {t("Approve using")}: -
-
setIsDropdownOpen(true)} - > - - - -
- -
-
-
-
- {accountNotFound && accountToSign ? ( -
- - {t("The application is requesting a specific account")} ( - {truncatedPublicKey(accountToSign)}),{" "} - {t( - "which is not available on Freighter. If you own this account, you can import it into Freighter to complete this transaction.", - )} - -
- ) : null} -
- {isFeeBump ? ( -
- -
- ) : ( - - )} - {t("Transaction Info")} - -
- - - - - -
- -
-
-
- - ); -}; diff --git a/extension/src/popup/views/SignTransaction/index.tsx b/extension/src/popup/views/SignTransaction/index.tsx index a34bfd6e82..5ae629feab 100644 --- a/extension/src/popup/views/SignTransaction/index.tsx +++ b/extension/src/popup/views/SignTransaction/index.tsx @@ -1,169 +1,357 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; -import { useDispatch, useSelector } from "react-redux"; -import { getTransactionInfo } from "helpers/stellar"; -import { rejectTransaction, rejectBlob } from "popup/ducks/access"; -import { - allAccountsSelector, - hasPrivateKeySelector, - makeAccountActive, - publicKeySelector, - hardwareWalletTypeSelector, -} from "popup/ducks/accountServices"; +import { useSelector } from "react-redux"; +import { useTranslation, Trans } from "react-i18next"; +import { Card, Icon } from "@stellar/design-system"; +import * as SorobanSdk from "soroban-client"; +import StellarSdk, { FederationServer, MuxedAccount } from "stellar-sdk"; + +import { signTransaction, rejectTransaction } from "popup/ducks/access"; import { settingsNetworkDetailsSelector, settingsExperimentalModeSelector, } from "popup/ducks/settings"; +import { ShowOverlayStatus } from "popup/ducks/transactionSubmission"; + +import { TRANSACTION_WARNING } from "constants/transaction"; + +import { emitMetric } from "helpers/metrics"; +import { + getTransactionInfo, + isFederationAddress, + isMuxedAccount, + truncatedPublicKey, +} from "helpers/stellar"; +import { decodeMemo } from "popup/helpers/parseTransaction"; +import { useSetupSigningFlow } from "popup/helpers/useSetupSigningFlow"; +import { Button } from "popup/basics/buttons/Button"; +import { InfoBlock } from "popup/basics/InfoBlock"; +import { TransactionHeading } from "popup/basics/TransactionHeading"; + import { - ShowOverlayStatus, - transactionSubmissionSelector, -} from "popup/ducks/transactionSubmission"; + ButtonsContainer, + ModalHeader, + ModalWrapper, +} from "popup/basics/Modal"; -import { Account } from "@shared/api/types"; -import { AppDispatch } from "popup/App"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; + +import { AccountListIdenticon } from "popup/components/identicons/AccountListIdenticon"; +import { AccountList, OptionTag } from "popup/components/account/AccountList"; +import { PunycodedDomain } from "popup/components/PunycodedDomain"; +import { + WarningMessageVariant, + WarningMessage, + FirstTimeWarningMessage, + FlaggedWarningMessage, +} from "popup/components/WarningMessages"; +import { Transaction } from "popup/components/signTransaction/Transaction"; +import { LedgerSign } from "popup/components/hardwareConnect/LedgerSign"; +import { SlideupModal } from "popup/components/SlideupModal"; + +import { VerifyAccount } from "popup/views/VerifyAccount"; import "./styles.scss"; -import { SignBlobBody } from "./SignBlob"; -import { SignTxBody } from "./SignTx"; + +import { FlaggedKeys } from "types/transactions"; +import { TransactionInfo } from "popup/components/signTransaction/TransactionInfo"; export const SignTransaction = () => { const location = useLocation(); - const blobOrTx = getTransactionInfo(location.search); - const isBlob = "blob" in blobOrTx; + const { t } = useTranslation(); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const dispatch: AppDispatch = useDispatch(); - const { networkName, networkPassphrase } = useSelector( - settingsNetworkDetailsSelector, - ); const isExperimentalModeEnabled = useSelector( settingsExperimentalModeSelector, ); + const { networkName, networkPassphrase } = useSelector( + settingsNetworkDetailsSelector, + ); + + const tx = getTransactionInfo(location.search); - const hardwareWalletType = useSelector(hardwareWalletTypeSelector); - const isHardwareWallet = !!hardwareWalletType; const { - hardwareWalletData: { status: hwStatus }, - } = useSelector(transactionSubmissionSelector); + accountToSign: _accountToSign, + transactionXdr, + domain, + isDomainListedAllowed, + isHttpsDomain, + flaggedKeys, + } = tx; - const [startedHwSign, setStartedHwSign] = useState(false); - const [currentAccount, setCurrentAccount] = useState({} as Account); - const [isPasswordRequired, setIsPasswordRequired] = useState(false); - const [isConfirming, setIsConfirming] = useState(false); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [accountNotFound, setAccountNotFound] = useState(false); + /* + Reconstruct the tx from xdr as passing a tx through extension contexts + loses custom prototypes associated with some values. This is fine for most cases + where we just need a high level overview of the tx, like just a list of operations. + But in this case, we will need the hostFn prototype associated with Soroban tx operations. + */ + + const SDK = isExperimentalModeEnabled ? SorobanSdk : StellarSdk; + const transaction = SDK.TransactionBuilder.fromXDR( + transactionXdr, + networkPassphrase, + ); - const allAccounts = useSelector(allAccountsSelector); - const publicKey = useSelector(publicKeySelector); - const hasPrivateKey = useSelector(hasPrivateKeySelector); + const { + _fee, + _innerTransaction, + _memo, + _networkPassphrase, + _sequence, + } = transaction; - // the public key the user had selected before starting this flow - const defaultPublicKey = useRef(publicKey); - const allAccountsMap = useRef({} as { [key: string]: Account }); - const accountToSign = blobOrTx.accountToSign; // both types have this key + const isFeeBump = !!_innerTransaction; + const memo = decodeMemo(_memo); + let accountToSign = _accountToSign; - const rejectAndClose = () => { - const _reject = isBlob ? rejectTransaction : rejectBlob; - dispatch(_reject()); - window.close(); - }; + const { + allAccounts, + accountNotFound, + currentAccount, + isConfirming, + isPasswordRequired, + publicKey, + handleApprove, + hwStatus, + rejectAndClose, + setIsPasswordRequired, + verifyPasswordThenSign, + } = useSetupSigningFlow( + rejectTransaction, + signTransaction, + transactionXdr, + accountToSign, + ); - const handleApprove = (signAndClose: () => Promise) => async () => { - setIsConfirming(true); + const flaggedKeyValues = Object.values(flaggedKeys as FlaggedKeys); + const isUnsafe = flaggedKeyValues.some(({ tags }) => + tags.includes(TRANSACTION_WARNING.unsafe), + ); + const isMalicious = flaggedKeyValues.some(({ tags }) => + tags.includes(TRANSACTION_WARNING.malicious), + ); + const isMemoRequired = flaggedKeyValues.some( + ({ tags }) => tags.includes(TRANSACTION_WARNING.memoRequired) && !memo, + ); - if (hasPrivateKey) { - await signAndClose(); - } else { - setIsPasswordRequired(true); + const resolveFederatedAddress = useCallback(async (inputDest) => { + let resolvedPublicKey; + try { + const fedResp = await FederationServer.resolve(inputDest); + resolvedPublicKey = fedResp.account_id; + } catch (e) { + console.error(e); } - setIsConfirming(false); - }; + return resolvedPublicKey; + }, []); - useEffect(() => { - if (startedHwSign && hwStatus === ShowOverlayStatus.IDLE) { - window.close(); + const decodeAccountToSign = async () => { + if (_accountToSign) { + if (isMuxedAccount(_accountToSign)) { + const mAccount = MuxedAccount.fromAddress(_accountToSign, "0"); + accountToSign = mAccount.baseAccount().accountId(); + } + if (isFederationAddress(_accountToSign)) { + accountToSign = (await resolveFederatedAddress( + accountToSign, + )) as string; + } } - }, [startedHwSign, hwStatus]); + }; + decodeAccountToSign(); useEffect(() => { - // handle auto selecting the right account based on `accountToSign` - let autoSelectedAccountDetails; - - allAccounts.forEach((account) => { - if (accountToSign) { - // does the user have the `accountToSign` somewhere in the accounts list? - if (account.publicKey === accountToSign) { - // if the `accountToSign` is found, but it isn't active, make it active - if (defaultPublicKey.current !== account.publicKey) { - dispatch(makeAccountActive(account.publicKey)); - } - - // save the details of the `accountToSign` - autoSelectedAccountDetails = account; - } - } - - // create an object so we don't need to keep iterating over allAccounts when we switch accounts - allAccountsMap.current[account.publicKey] = account; - }); - - if (!autoSelectedAccountDetails) { - setAccountNotFound(true); + if (isMemoRequired) { + emitMetric(METRIC_NAMES.signTransactionMemoRequired); + } + if (isUnsafe) { + emitMetric(METRIC_NAMES.signTransactionUnsafe); + } + if (isMalicious) { + emitMetric(METRIC_NAMES.signTransactionMalicious); } - }, [accountToSign, allAccounts, dispatch]); + }, [isMemoRequired, isMalicious, isUnsafe]); - useEffect(() => { - // handle any changes to the current acct - whether by auto select or manual select - setCurrentAccount(allAccountsMap.current[publicKey] || ({} as Account)); - }, [allAccounts, publicKey]); + const isSubmitDisabled = isMemoRequired || isMalicious; + + if (_networkPassphrase !== networkPassphrase) { + return ( + + window.close()} + isActive + header={`${t("Freighter is set to")} ${networkName}`} + > +

+ {t("The transaction you’re trying to sign is on")}{" "} + {_networkPassphrase}. +

+

{t("Signing this transaction is not possible at the moment.")}

+
+
+ ); + } - if ("blob" in blobOrTx) { + if (!isHttpsDomain && !isExperimentalModeEnabled) { return ( - + + window.close()} + isActive + variant={WarningMessageVariant.warning} + header={t("WEBSITE CONNECTION IS NOT SECURE")} + > +

+ + The website {{ domain }} does not use an SSL + certificate. For additional safety Freighter only works with + websites that provide an SSL certificate. + +

+
+
); } - return ( - setIsPasswordRequired(false)} + customSubmit={verifyPasswordThenSign} /> + ) : ( + <> + {hwStatus === ShowOverlayStatus.IN_PROGRESS && } +
+ + + {t("Confirm Transaction")} + + {isExperimentalModeEnabled ? ( + +

+ {t( + "You are interacting with a transaction that may be using untested and changing schemas. Proceed at your own risk.", + )} +

+
+ ) : null} + {flaggedKeyValues.length ? ( + + ) : null} + {!isDomainListedAllowed && !isSubmitDisabled ? ( + + ) : null} +
+ + +
+ {t("is requesting approval to a")}{" "} + {isFeeBump ? "fee bump " : ""} + {t("transaction")}: +
+
+
+ {t("Approve using")}: +
+
setIsDropdownOpen(true)} + > + + + +
+ +
+
+
+
+ {accountNotFound && accountToSign ? ( +
+ + {t("The application is requesting a specific account")} ( + {truncatedPublicKey(accountToSign)}),{" "} + {t( + "which is not available on Freighter. If you own this account, you can import it into Freighter to complete this transaction.", + )} + +
+ ) : null} +
+ {isFeeBump ? ( +
+ +
+ ) : ( + + )} + {t("Transaction Info")} + +
+ + + + + +
+ +
+
+
+ ); }; diff --git a/extension/src/popup/views/SignTransaction/styles.scss b/extension/src/popup/views/SignTransaction/styles.scss index 53e41144bb..df9b48e8c8 100644 --- a/extension/src/popup/views/SignTransaction/styles.scss +++ b/extension/src/popup/views/SignTransaction/styles.scss @@ -1,5 +1,4 @@ -.SignTransaction, -.SignBlob { +.SignTransaction { height: var(--popup--height); overflow: hidden; position: relative;