diff --git a/@shared/api/external.ts b/@shared/api/external.ts index a3633de78e..ea7aa86443 100644 --- a/@shared/api/external.ts +++ b/@shared/api/external.ts @@ -69,6 +69,36 @@ export const submitTransaction = async ( return signedTransaction; }; +export const submitBlob = async ( + blob: string, + opts: + | { + network: string + accountToSign: string + networkPassphrase: string + }, +): Promise => { + let response = { signedTransaction: "", error: "" }; + const {network, networkPassphrase, accountToSign} = opts + try { + response = await sendMessageToContentScript({ + transactionXDR: blob, + network, + networkPassphrase, + accountToSign, + type: EXTERNAL_SERVICE_TYPES.SUBMIT_BLOB, + }); + } catch (e) { + console.error(e); + } + const { signedTransaction, error } = response; + + if (error) { + throw error; + } + return signedTransaction; +} + export const requestNetwork = async (): Promise => { let response = { network: "", error: "" }; try { diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index 9631203685..4d974d97df 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -43,6 +43,7 @@ export enum SERVICE_TYPES { export enum EXTERNAL_SERVICE_TYPES { REQUEST_ACCESS = "REQUEST_ACCESS", SUBMIT_TRANSACTION = "SUBMIT_TRANSACTION", + SUBMIT_BLOB = "SUBMIT_BLOB", REQUEST_NETWORK = "REQUEST_NETWORK", REQUEST_NETWORK_DETAILS = "REQUEST_NETWORK_DETAILS", REQUEST_CONNECTION_STATUS = "REQUEST_CONNECTION_STATUS", diff --git a/@stellar/freighter-api/src/__tests__/index.test.js b/@stellar/freighter-api/src/__tests__/index.test.js index 423e112559..5de51117e9 100644 --- a/@stellar/freighter-api/src/__tests__/index.test.js +++ b/@stellar/freighter-api/src/__tests__/index.test.js @@ -5,5 +5,6 @@ describe("freighter API", () => { expect(typeof FreighterAPI.isConnected).toBe("function"); expect(typeof FreighterAPI.getPublicKey).toBe("function"); expect(typeof FreighterAPI.signTransaction).toBe("function"); + expect(typeof FreighterAPI.signBlob).toBe("function"); }); }); diff --git a/@stellar/freighter-api/src/__tests__/signBlob.test.js b/@stellar/freighter-api/src/__tests__/signBlob.test.js new file mode 100644 index 0000000000..55dc0512c6 --- /dev/null +++ b/@stellar/freighter-api/src/__tests__/signBlob.test.js @@ -0,0 +1,18 @@ +import * as apiExternal from "@shared/api/external"; +import { signBlob } from "../signBlob"; + +describe("signBlob", () => { + it("returns a signed blob", async () => { + const TEST_BLOB = atob("AAA"); + apiExternal.submitBlob = jest.fn().mockReturnValue(TEST_BLOB); + const blob = await signBlob(); + expect(blob).toBe(TEST_BLOB); + }); + it("throws a generic error", () => { + const TEST_ERROR = "Error!"; + apiExternal.submitBlob = jest.fn().mockImplementation(() => { + throw TEST_ERROR; + }); + expect(signBlob).toThrowError(TEST_ERROR); + }); +}); diff --git a/@stellar/freighter-api/src/index.ts b/@stellar/freighter-api/src/index.ts index 29194e4623..66909f9258 100644 --- a/@stellar/freighter-api/src/index.ts +++ b/@stellar/freighter-api/src/index.ts @@ -1,5 +1,6 @@ import { getPublicKey } from "./getPublicKey"; import { signTransaction } from "./signTransaction"; +import { signBlob } from "./signBlob"; import { isConnected } from "./isConnected"; import { getNetwork } from "./getNetwork"; import { getNetworkDetails } from "./getNetworkDetails"; @@ -12,6 +13,7 @@ export const isBrowser = typeof window !== "undefined"; export { getPublicKey, signTransaction, + signBlob, isConnected, getNetwork, getNetworkDetails, @@ -22,6 +24,7 @@ export { export default { getPublicKey, signTransaction, + signBlob, isConnected, getNetwork, getNetworkDetails, diff --git a/@stellar/freighter-api/src/signBlob.ts b/@stellar/freighter-api/src/signBlob.ts new file mode 100644 index 0000000000..41d92806da --- /dev/null +++ b/@stellar/freighter-api/src/signBlob.ts @@ -0,0 +1,12 @@ +import { submitBlob } from "@shared/api/external"; +import { isBrowser } from "."; + +export const signBlob = ( + blob: string, + opts: { + network: string; + networkPassphrase: string; + accountToSign: string; + } +): Promise => + isBrowser ? submitBlob(blob, opts) : Promise.resolve(""); diff --git a/docs/docs/guide/usingFreighterNode.md b/docs/docs/guide/usingFreighterNode.md index df6713f796..77292bfee6 100644 --- a/docs/docs/guide/usingFreighterNode.md +++ b/docs/docs/guide/usingFreighterNode.md @@ -20,6 +20,7 @@ import { isConnected, getPublicKey, signTransaction, + signBlob, } from "@stellar/freighter-api"; ``` @@ -82,6 +83,7 @@ import { isConnected, getPublicKey, signTransaction, + signBlob, } from "@stellar/freighter-api"; if (await isConnected()) { @@ -123,6 +125,7 @@ import { setAllowed, getUserInfo, signTransaction, + signBlob, } from "@stellar/freighter-api"; if (await isConnected()) { @@ -175,6 +178,7 @@ import { isConnected, getNetwork, signTransaction, + signBlob, } from "@stellar/freighter-api"; if (await isConnected()) { @@ -219,11 +223,18 @@ These 2 configurations are useful in the case that the user's Freighter is confi You can also use this `opts` to specify which account's signature you’re requesting. If Freighter has the public key requested, it will switch to that account. If not, it will alert the user that they do not have the requested account. +### signBlob + +#### `signBlob(xdr: string, opts: { network: string, networkPassphrase: string, accountToSign: string }) -> >` + +This is the same as `signTransaction` but accepts a base64 encoded blob. + ```javascript import { isConnected, getPublicKey, signTransaction, + 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 a647df07d4..b4df022592 100644 --- a/extension/src/background/messageListener/freighterApiMessageListener.ts +++ b/extension/src/background/messageListener/freighterApiMessageListener.ts @@ -115,95 +115,80 @@ export const freighterApiMessageListener = ( const isDomainListedAllowed = await isSenderAllowed({ sender }); // try to build a tx xdr, if you cannot then assume the user wants to sign an arbitrary blob - let encodedBlob = "" - try { - const transaction = SDK.TransactionBuilder.fromXDR( - transactionXdr, - networkPassphrase || SDK.Networks[network], - ); - - const directoryLookupJson = await cachedFetch( - STELLAR_EXPERT_BLOCKED_ACCOUNTS_URL, - CACHED_BLOCKED_ACCOUNTS_ID, - ); - const accountData = directoryLookupJson?._embedded?.records || []; - - const _operations = - transaction._operations || transaction._innerTransaction._operations; - - const flaggedKeys: FlaggedKeys = {}; - - const isValidatingMemo = (await getIsMemoValidationEnabled()) && isMainnet; - const isValidatingSafety = - (await getIsSafetyValidationEnabled()) && isMainnet; - - if (isValidatingMemo || isValidatingSafety) { - _operations.forEach((operation: { destination: string }) => { - accountData.forEach( - ({ address, tags }: { address: string; tags: Array }) => { - if (address === operation.destination) { - let collectedTags = [...tags]; - - /* 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, - ); - } - flaggedKeys[operation.destination] = { - ...flaggedKeys[operation.destination], - tags: collectedTags, - }; + const transaction = SDK.TransactionBuilder.fromXDR( + transactionXdr, + networkPassphrase || SDK.Networks[network], + ); + + const directoryLookupJson = await cachedFetch( + STELLAR_EXPERT_BLOCKED_ACCOUNTS_URL, + CACHED_BLOCKED_ACCOUNTS_ID, + ); + const accountData = directoryLookupJson?._embedded?.records || []; + + const _operations = + transaction._operations || transaction._innerTransaction._operations; + + const flaggedKeys: FlaggedKeys = {}; + + const isValidatingMemo = (await getIsMemoValidationEnabled()) && isMainnet; + const isValidatingSafety = + (await getIsSafetyValidationEnabled()) && isMainnet; + + if (isValidatingMemo || isValidatingSafety) { + _operations.forEach((operation: { destination: string }) => { + accountData.forEach( + ({ address, tags }: { address: string; tags: Array }) => { + if (address === operation.destination) { + let collectedTags = [...tags]; + + /* if the user has opted out of validation, remove applicable tags */ + if (!isValidatingMemo) { + collectedTags.filter( + (tag) => tag !== TRANSACTION_WARNING.memoRequired, + ); } - }, - ); - }); - } - - const server = stellarSdkServer(networkUrl); - - try { - await server.checkMemoRequired(transaction); - } catch (e) { - flaggedKeys[e.accountId] = { - ...flaggedKeys[e.accountId], - tags: [TRANSACTION_WARNING.memoRequired], - }; - } - - const transactionInfo = { - transaction, - transactionXdr, - tab, - isDomainListedAllowed, - url: tabUrl, - flaggedKeys, - accountToSign, - } as TransactionInfo; - - transactionQueue.push(transaction); - encodedBlob = encodeObject(transactionInfo); - } catch (error) { - const blob = { - isDomainListedAllowed, - domain, - tab, - blob: transactionXdr, - url: tabUrl, - accountToSign - } + if (!isValidatingSafety) { + collectedTags = collectedTags.filter( + (tag) => tag !== TRANSACTION_WARNING.unsafe, + ); + collectedTags = collectedTags.filter( + (tag) => tag !== TRANSACTION_WARNING.malicious, + ); + } + flaggedKeys[operation.destination] = { + ...flaggedKeys[operation.destination], + tags: collectedTags, + }; + } + }, + ); + }); + } + + const server = stellarSdkServer(networkUrl); - blobQueue.push(blob); - encodedBlob = encodeObject(blob) + try { + await server.checkMemoRequired(transaction); + } catch (e) { + flaggedKeys[e.accountId] = { + ...flaggedKeys[e.accountId], + tags: [TRANSACTION_WARNING.memoRequired], + }; } + + const transactionInfo = { + transaction, + transactionXdr, + tab, + isDomainListedAllowed, + url: tabUrl, + flaggedKeys, + accountToSign, + } as TransactionInfo; + + transactionQueue.push(transaction); + const encodedBlob = encodeObject(transactionInfo); const popup = browser.windows.create({ url: chrome.runtime.getURL( @@ -238,6 +223,64 @@ export const freighterApiMessageListener = ( }); }; + const submitBlob = async () => { + const { + transactionXdr, + accountToSign, + } = request; + + 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 blob = { + isDomainListedAllowed, + domain, + tab, + blob: transactionXdr, + url: tabUrl, + accountToSign + } + + blobQueue.push(blob); + const encodedBlob = encodeObject(blob) + const popup = browser.windows.create({ + url: chrome.runtime.getURL( + `/index.html#/sign-transaction?${encodedBlob}`, + ), + ...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 = (signedBlob: string) => { + if (signedBlob) { + if (!isDomainListedAllowed) { + allowList.push(punycodedDomain); + localStore.setItem(ALLOWLIST_ID, allowList.join()); + } + resolve({ signedBlob }); + } + + resolve({ error: "User declined access" }); + }; + + responseQueue.push(response); + }); + }; + const requestNetwork = async () => { let network = ""; @@ -329,6 +372,7 @@ export const freighterApiMessageListener = ( const messageResponder: MessageResponder = { [EXTERNAL_SERVICE_TYPES.REQUEST_ACCESS]: requestAccess, [EXTERNAL_SERVICE_TYPES.SUBMIT_TRANSACTION]: submitTransaction, + [EXTERNAL_SERVICE_TYPES.SUBMIT_BLOB]: submitBlob, [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/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index cf657ae35b..1378f07555 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -112,6 +112,9 @@ export const METRIC_NAMES = { signTransactionUnsafe: "sign transaction: unsafe account warning", rejectTransaction: "sign transaction: rejected", + signBlob: "sign blob: confirmed", + rejectBlob: "sign blob: rejected", + backupPhraseSuccess: "backup phrase: success", backupPhraseFail: "backup phrase: error", diff --git a/extension/src/popup/ducks/access.ts b/extension/src/popup/ducks/access.ts index 61bfe84323..72d696ddeb 100644 --- a/extension/src/popup/ducks/access.ts +++ b/extension/src/popup/ducks/access.ts @@ -19,13 +19,20 @@ export const signTransaction = createAsyncThunk( internalSignTransaction, ); +export const signBlob = createAsyncThunk( + "signBlob", + internalSignBlob, +); + // Basically an alias for metrics purposes export const rejectTransaction = createAsyncThunk( "rejectTransaction", internalRejectAccess, ); -export const signBlob = createAsyncThunk( - "signBlob", - internalSignBlob, +// Basically an alias for metrics purposes +export const rejectBlob = createAsyncThunk( + "rejectBlob", + internalRejectAccess, ); + diff --git a/extension/src/popup/metrics/access.ts b/extension/src/popup/metrics/access.ts index 0a17222cfb..c3cb0fe918 100644 --- a/extension/src/popup/metrics/access.ts +++ b/extension/src/popup/metrics/access.ts @@ -4,6 +4,7 @@ import { grantAccess, rejectAccess, signTransaction, + signBlob, rejectTransaction, } from "popup/ducks/access"; import { registerHandler, emitMetric, MetricsData } from "helpers/metrics"; @@ -27,3 +28,11 @@ registerHandler(signTransaction.fulfilled, () => { registerHandler(rejectTransaction.fulfilled, () => { emitMetric(METRIC_NAMES.rejectTransaction); }); +registerHandler(signBlob.fulfilled, () => { + const metricsData: MetricsData = JSON.parse( + localStorage.getItem(METRICS_DATA) || "{}", + ); + emitMetric(METRIC_NAMES.signBlob, { + accountType: metricsData.accountType, + }); +}); diff --git a/extension/src/popup/views/SignTransaction/index.tsx b/extension/src/popup/views/SignTransaction/index.tsx index 908275d77c..d507166f88 100644 --- a/extension/src/popup/views/SignTransaction/index.tsx +++ b/extension/src/popup/views/SignTransaction/index.tsx @@ -19,7 +19,7 @@ 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 { rejectTransaction, signBlob, signTransaction } from "popup/ducks/access"; +import { rejectTransaction, rejectBlob, signBlob, signTransaction } from "popup/ducks/access"; import { allAccountsSelector, confirmPassword, @@ -72,6 +72,7 @@ import { BlobToSign } from "helpers/urls"; export const SignTransaction = () => { const location = useLocation(); const blobOrTx = getTransactionInfo(location.search); + const isBlob = "blob" in blobOrTx const { t } = useTranslation(); const dispatch: AppDispatch = useDispatch(); @@ -105,7 +106,8 @@ export const SignTransaction = () => { const accountToSign = blobOrTx.accountToSign // both types have this key const rejectAndClose = () => { - dispatch(rejectTransaction()); + const _reject = isBlob ? rejectTransaction : rejectBlob + dispatch(_reject()); window.close(); };