diff --git a/assets/_locales/en/messages.json b/assets/_locales/en/messages.json index 452fae32b..764d5e980 100644 --- a/assets/_locales/en/messages.json +++ b/assets/_locales/en/messages.json @@ -1329,6 +1329,10 @@ "message": "Sign Item", "description": "Sign message popup title" }, + "batch_sign_items": { + "message": "Batch Sign Items", + "description": "Batch sign message popup title" + }, "titles_signature": { "message": "Sign message", "description": "Sign message popup title" @@ -1343,6 +1347,16 @@ } } }, + "batch_sign_data_description": { + "message": "$APPNAME$ wants to sign the following transactions. Review the details below.", + "description": "Desription for signing an item containing a transfer", + "placeholders": { + "appname": { + "content": "$1", + "example": "permafacts.arweave.dev" + } + } + }, "signature_description": { "message": "$APPNAME$ wants to sign a message. Review the message below.", "description": "App signature request for data that cannot be decoded to string", diff --git a/assets/_locales/zh_CN/messages.json b/assets/_locales/zh_CN/messages.json index b2c6e7c3d..b6484314a 100644 --- a/assets/_locales/zh_CN/messages.json +++ b/assets/_locales/zh_CN/messages.json @@ -1317,6 +1317,10 @@ "message": "签署项目", "description": "Sign message popup title" }, + "batch_sign_items": { + "message": "批量签署项目", + "description": "批量签署消息弹出标题" + }, "titles_signature": { "message": "签署消息", "description": "Sign message popup title" @@ -1331,6 +1335,16 @@ } } }, + "batch_sign_data_description": { + "message": "$APPNAME$ 想要签署以下交易。请查看以下详细信息。", + "description": "Description for signing an item containing a transfer", + "placeholders": { + "appname": { + "content": "$1", + "example": "permafacts.arweave.dev" + } + } + }, "signature_description": { "message": "$APPNAME$ 想要签署一条消息。请查看以下消息。", "description": "App signature request for data that cannot be decoded to string", diff --git a/src/api/background.ts b/src/api/background.ts index 9980ce8f2..0be5da8e5 100644 --- a/src/api/background.ts +++ b/src/api/background.ts @@ -39,6 +39,8 @@ import verifyMessageModule from "./modules/verify_message"; import verifyMessage from "./modules/verify_message/verify_message.background"; import signDataItemModule from "./modules/sign_data_item"; import signDataItem from "./modules/sign_data_item/sign_data_item.background"; +import batchSignDataItemModule from "./modules/batch_sign_data_item"; +import batchSignDataItem from "./modules/batch_sign_data_item/batch_sign_data_item.background"; import subscriptionModule from "./modules/subscription"; import subscription from "./modules/subscription/subscription.background"; import userTokensModule from "./modules/user_tokens"; @@ -66,7 +68,8 @@ const modules: BackgroundModule[] = [ { ...verifyMessageModule, function: verifyMessage }, { ...signDataItemModule, function: signDataItem }, { ...subscriptionModule, function: subscription }, - { ...userTokensModule, function: userTokens } + { ...userTokensModule, function: userTokens }, + { ...batchSignDataItemModule, function: batchSignDataItem } ]; export default modules; diff --git a/src/api/foreground.ts b/src/api/foreground.ts index 8b32738a2..cd0ca12b2 100644 --- a/src/api/foreground.ts +++ b/src/api/foreground.ts @@ -55,6 +55,10 @@ import privateHash, { } from "./modules/private_hash/private_hash.foreground"; import verifyMessageModule from "./modules/verify_message"; import verifyMessage from "./modules/verify_message/verify_message.foreground"; +import batchSignDataItemModule from "./modules/batch_sign_data_item"; +import batchSignDataItem, { + finalizer as batchSignDataItemFinalizer +} from "./modules/batch_sign_data_item/batch_sign_data_item.foreground"; import signDataItemModule from "./modules/sign_data_item"; import signDataItem, { finalizer as signDataItemFinalizer @@ -96,7 +100,12 @@ const modules: ForegroundModule[] = [ finalizer: signDataItemFinalizer }, { ...subscriptionModule, function: subscription }, - { ...userTokensModule, function: userTokens } + { ...userTokensModule, function: userTokens }, + { + ...batchSignDataItemModule, + function: batchSignDataItem, + finalizer: batchSignDataItemFinalizer + } ]; export default modules; diff --git a/src/api/modules/batch_sign_data_item/batch_sign_data_item.background.ts b/src/api/modules/batch_sign_data_item/batch_sign_data_item.background.ts new file mode 100644 index 000000000..600ebdf5e --- /dev/null +++ b/src/api/modules/batch_sign_data_item/batch_sign_data_item.background.ts @@ -0,0 +1,68 @@ +import type { ModuleFunction } from "~api/module"; +import { isNotCancelError, isRawDataItem } from "~utils/assertions"; +import authenticate from "../connect/auth"; +import browser from "webextension-polyfill"; +import { getActiveKeyfile } from "~wallets"; +import { freeDecryptedWallet } from "~wallets/encryption"; +import { ArweaveSigner, createData, DataItem } from "arbundles"; +import type { RawDataItem } from "../sign_data_item/types"; + +const background: ModuleFunction = async ( + appData, + dataItems: unknown[] +) => { + // validate + if (!Array.isArray(dataItems)) { + throw new Error("Input must be an array of data items"); + } + + for (const dataItem of dataItems) { + isRawDataItem(dataItem); + } + + const results: number[][] = []; + + await authenticate({ + type: "batchSignDataItem", + data: dataItems, + appData + }); + + // grab the user's keyfile + const decryptedWallet = await getActiveKeyfile().catch((e) => { + isNotCancelError(e); + + // if there are no wallets added, open the welcome page + browser.tabs.create({ url: browser.runtime.getURL("tabs/welcome.html") }); + + throw new Error("No wallets added"); + }); + + try { + if (decryptedWallet.type !== "local") { + throw new Error( + "Only local wallets are currently supported for batch signing" + ); + } + + const dataSigner = new ArweaveSigner(decryptedWallet.keyfile); + + for (const dataItem of dataItems as RawDataItem[]) { + const { data, ...options } = dataItem; + const binaryData = new Uint8Array(data); + + const dataEntry = createData(binaryData, dataSigner, options); + + await dataEntry.sign(dataSigner); + + results.push(Array.from(dataEntry.getRaw())); + } + } finally { + // @ts-expect-error + freeDecryptedWallet(decryptedWallet.keyfile); + } + + return results; +}; + +export default background; diff --git a/src/api/modules/batch_sign_data_item/batch_sign_data_item.foreground.ts b/src/api/modules/batch_sign_data_item/batch_sign_data_item.foreground.ts new file mode 100644 index 000000000..094276e2b --- /dev/null +++ b/src/api/modules/batch_sign_data_item/batch_sign_data_item.foreground.ts @@ -0,0 +1,54 @@ +import type { TransformFinalizer } from "~api/foreground"; +import type { ModuleFunction } from "~api/module"; +import type { RawDataItem, SignDataItemParams } from "../sign_data_item/types"; +import { isArrayBuffer } from "~utils/assertions"; + +const MAX_TOTAL_SIZE = 200 * 1024; + +const foreground: ModuleFunction[]> = async ( + dataItems: SignDataItemParams[] +) => { + if (!Array.isArray(dataItems)) { + throw new Error("Input must be an array of data items"); + } + + const totalSize = dataItems.reduce((acc, dataItem) => { + const dataSize = + typeof dataItem.data === "string" + ? new TextEncoder().encode(dataItem.data).length + : dataItem.data.length; + return acc + dataSize; + }, 0); + + if (totalSize > MAX_TOTAL_SIZE) { + throw new Error("Total size of data items exceeds 200 KB"); + } + + const rawDataItems: RawDataItem[] = dataItems.map((dataItem) => { + let rawDataItem: RawDataItem; + + if (typeof dataItem.data !== "string") { + isArrayBuffer(dataItem.data); + + rawDataItem = { + ...dataItem, + data: Array.from(dataItem.data) + }; + } else { + rawDataItem = { + ...dataItem, + data: Array.from(new TextEncoder().encode(dataItem.data)) + }; + } + + return rawDataItem; + }); + + return [rawDataItems]; +}; + +export const finalizer: TransformFinalizer = (result) => { + return result.map((item) => new Uint8Array(item).buffer); +}; + +export default foreground; diff --git a/src/api/modules/batch_sign_data_item/index.ts b/src/api/modules/batch_sign_data_item/index.ts new file mode 100644 index 000000000..473f4ba3c --- /dev/null +++ b/src/api/modules/batch_sign_data_item/index.ts @@ -0,0 +1,11 @@ +import type { PermissionType } from "~applications/permissions"; +import type { ModuleProperties } from "~api/module"; + +const permissions: PermissionType[] = ["SIGN_TRANSACTION"]; + +const batchSignDataItem: ModuleProperties = { + functionName: "batchSignDataItem", + permissions +}; + +export default batchSignDataItem; diff --git a/src/api/modules/connect/auth.ts b/src/api/modules/connect/auth.ts index dd9bbadaf..bade30f90 100644 --- a/src/api/modules/connect/auth.ts +++ b/src/api/modules/connect/auth.ts @@ -3,6 +3,11 @@ import { objectToUrlParams } from "./url"; import type { AuthResult } from "shim"; import { nanoid } from "nanoid"; import browser from "webextension-polyfill"; +import { Mutex } from "~utils/mutex"; + +const mutex = new Mutex(); +let keepAliveInterval: number | null = null; +let activePopups = 0; export type AuthType = | "connect" @@ -13,7 +18,8 @@ export type AuthType = | "subscription" | "signKeystone" | "signature" - | "signDataItem"; + | "signDataItem" + | "batchSignDataItem"; export interface AuthData { // type of auth to request from the user @@ -74,8 +80,11 @@ async function createAuthPopup(data: AuthData) { * Await for a browser message from the popup */ const result = (authID: string, tabId: number) => - new Promise((resolve, reject) => + new Promise(async (resolve, reject) => { + startKeepAlive(); + onMessage("auth_result", ({ sender, data }) => { + stopKeepAlive(); // validate sender by it's tabId if (sender.tabId !== tabId) { return; @@ -93,5 +102,47 @@ const result = (authID: string, tabId: number) => } else { resolve(data); } - }) - ); + }); + }); + +/** + * Function to send periodic keep-alive messages + */ +const startKeepAlive = async () => { + const unlock = await mutex.lock(); + + try { + // Increment the active popups count + activePopups++; + if (activePopups > 0 && keepAliveInterval === null) { + console.log("Started keep-alive messages..."); + keepAliveInterval = setInterval( + () => browser.alarms.create("keep-alive", { when: Date.now() + 1 }), + 20000 + ); + } + } finally { + unlock(); + } +}; + +/** + * Function to stop sending keep-alive messages + */ +const stopKeepAlive = async () => { + const unlock = await mutex.lock(); + + try { + // Decrement the active popups count + activePopups--; + if (activePopups <= 0 && keepAliveInterval !== null) { + // Stop keep-alive messages when no popups are active + browser.alarms.clear("keep-alive"); + clearInterval(keepAliveInterval); + keepAliveInterval = null; + console.log("Stopped keep-alive messages..."); + } + } finally { + unlock(); + } +}; diff --git a/src/api/modules/sign/sign.background.ts b/src/api/modules/sign/sign.background.ts index a52765c95..d1706233f 100644 --- a/src/api/modules/sign/sign.background.ts +++ b/src/api/modules/sign/sign.background.ts @@ -95,7 +95,7 @@ const background: ModuleFunction = async ( // check if there is an allowance limit, if there is we need to check allowance // if alwaysAsk is true, then we'll need to signAuth popup // if allowance is disabled, proceed with signing - if (alwaysAsk) { + if (alwaysAsk || activeWallet.type === "hardware") { // get address of keyfile const addr = activeWallet.type === "local" diff --git a/src/background.ts b/src/background.ts index 59dd18d10..40a15215e 100644 --- a/src/background.ts +++ b/src/background.ts @@ -54,6 +54,13 @@ browser.alarms.onAlarm.addListener(keyRemoveAlarmListener); // handle importing ao tokens browser.alarms.onAlarm.addListener(importAoTokens); +// handle keep alive alarm +browser.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "keep-alive") { + console.log("keep alive alarm"); + } +}); + // handle window close browser.windows.onRemoved.addListener(onWindowClose); diff --git a/src/components/signDataItem/index.tsx b/src/components/signDataItem/index.tsx new file mode 100644 index 000000000..93a627053 --- /dev/null +++ b/src/components/signDataItem/index.tsx @@ -0,0 +1,254 @@ +import { replyToAuthRequest, useAuthParams, useAuthUtils } from "~utils/auth"; +import { + ButtonV2, + InputV2, + Loading, + Section, + Spacer, + Text, + useInput, + useToasts +} from "@arconnect/components"; +import { + FiatAmount, + AmountTitle, + Properties, + TransactionProperty, + PropertyName, + PropertyValue, + TagValue, + useAdjustAmountTitleWidth +} from "~routes/popup/transaction/[id]"; +import Wrapper from "~components/auth/Wrapper"; +import browser from "webextension-polyfill"; +import { useEffect, useMemo, useRef, useState } from "react"; +import styled from "styled-components"; +import HeadV2 from "~components/popup/HeadV2"; +import { formatAddress } from "~utils/format"; +import { useStorage } from "@plasmohq/storage/hook"; +import { ExtensionStorage } from "~utils/storage"; +import { checkPassword } from "~wallets/auth"; +import { Quantity, Token } from "ao-tokens"; +import prettyBytes from "pretty-bytes"; +import { formatFiatBalance } from "~tokens/currency"; +import useSetting from "~settings/hook"; +import { getPrice } from "~lib/coingecko"; +import type { TokenInfo, TokenInfoWithProcessId } from "~tokens/aoTokens/ao"; +import { ChevronUpIcon, ChevronDownIcon } from "@iconicicons/react"; +import { getUserAvatar } from "~lib/avatar"; +import { LogoWrapper, Logo, WarningIcon } from "~components/popup/Token"; +import arLogoLight from "url:/assets/ar/logo_light.png"; +import arLogoDark from "url:/assets/ar/logo_dark.png"; +import { useTheme } from "~utils/theme"; +import { checkWalletBits, type WalletBitsCheck } from "~utils/analytics"; +import { Degraded, WarningWrapper } from "~routes/popup/send"; + +export default function SignDataItemDetails({ params }) { + const [loading, setLoading] = useState(false); + const [tokenName, setTokenName] = useState(""); + const [logo, setLogo] = useState(""); + const [amount, setAmount] = useState(null); + const [showTags, setShowTags] = useState(false); + + const recipient = + params?.tags?.find((tag) => tag.name === "Recipient")?.value || ""; + const quantity = + params?.tags?.find((tag) => tag.name === "Quantity")?.value || "0"; + const transfer = params?.tags?.some( + (tag) => tag.name === "Action" && tag.value === "Transfer" + ); + + const theme = useTheme(); + const arweaveLogo = useMemo( + () => (theme === "dark" ? arLogoDark : arLogoLight), + [theme] + ); + + useEffect(() => { + const fetchTokenInfo = async () => { + if (!process || !transfer) return; + let tokenInfo: TokenInfo; + try { + setLoading(true); + const token = await Token(params.target); + tokenInfo = { + ...token.info, + Denomination: Number(token.info.Denomination) + }; + } catch (err) { + // fallback + console.log("err", err); + try { + const aoTokens = + (await ExtensionStorage.get( + "ao_tokens" + )) || []; + const aoTokensCache = + (await ExtensionStorage.get( + "ao_tokens_cache" + )) || []; + const aoTokensCombined = [...aoTokens, ...aoTokensCache]; + const token = aoTokensCombined.find( + ({ processId }) => params.target === processId + ); + if (token) { + tokenInfo = token; + } + } catch {} + } finally { + if (tokenInfo) { + if (tokenInfo?.Logo) { + const logo = await getUserAvatar(tokenInfo?.Logo); + setLogo(logo || ""); + } else { + setLogo(arweaveLogo); + } + + const tokenAmount = new Quantity( + BigInt(quantity), + BigInt(tokenInfo.Denomination) + ); + setTokenName(tokenInfo.Name); + setAmount(tokenAmount); + } + setLoading(false); + } + }; + fetchTokenInfo(); + }, [params]); + + // currency setting + const [currency] = useSetting("currency"); + + // token price + const [price, setPrice] = useState(0); + + // transaction price + const fiatPrice = useMemo(() => +(amount || 0) * price, [amount]); + + const process = params?.target; + + const formattedAmount = useMemo( + () => (amount || 0).toLocaleString(), + [amount] + ); + + // active address + const [activeAddress] = useStorage( + { + key: "active_address", + instance: ExtensionStorage + }, + "" + ); + // adjust amount title font sizes + const parentRef = useRef(null); + const childRef = useRef(null); + return ( + <> + {params ? ( +
+
+ {!loading ? ( + logo && ( + + + + ) + ) : ( + + )} +
+ {transfer && ( + <> + {formatFiatBalance(fiatPrice, currency)} + + {formattedAmount} + {tokenName} + + + )} + + + {params?.target && ( + + + {browser.i18n.getMessage("process_id")} + + + {formatAddress(params?.target, 6)} + + + )} + + + {browser.i18n.getMessage("transaction_from")} + + {formatAddress(activeAddress, 6)} + + {recipient && ( + + + {browser.i18n.getMessage("transaction_to")} + + {formatAddress(recipient, 6)} + + )} + + + + {browser.i18n.getMessage("transaction_fee")} + + 0 AR + + + + {browser.i18n.getMessage("transaction_size")} + + {prettyBytes(params?.data.length)} + + + setShowTags(!showTags)} + > + {browser.i18n.getMessage("transaction_tags")} + {showTags ? : } + + {console.log(params)} + + {showTags && + params?.tags?.map((tag, i) => ( + + {tag.name} + {tag.value} + + ))} + +
+ ) : ( + + )} + + ); +} diff --git a/src/routes/auth/batchSignDataItem.tsx b/src/routes/auth/batchSignDataItem.tsx new file mode 100644 index 000000000..47f1d56c0 --- /dev/null +++ b/src/routes/auth/batchSignDataItem.tsx @@ -0,0 +1,225 @@ +import { replyToAuthRequest, useAuthParams, useAuthUtils } from "~utils/auth"; +import { + ButtonV2, + InputV2, + ListItem, + Section, + Text, + useInput, + useToasts +} from "@arconnect/components"; +import Wrapper from "~components/auth/Wrapper"; +import browser from "webextension-polyfill"; +import { useEffect, useState } from "react"; +import styled from "styled-components"; + +import { ResetButton } from "~components/dashboard/Reset"; +import SignDataItemDetails from "~components/signDataItem"; +import HeadV2 from "~components/popup/HeadV2"; +import { Quantity, Token } from "ao-tokens"; +import { timeoutPromise } from "~tokens/aoTokens/ao"; +import { ExtensionStorage } from "~utils/storage"; +import { useStorage } from "@plasmohq/storage/hook"; +import { checkPassword } from "~wallets/auth"; + +interface Tag { + name: string; + value: string; +} + +interface DataStructure { + data: number[]; + target?: string; + tags: Tag[]; +} + +export default function BatchSignDataItem() { + // connect params + const params = useAuthParams<{ + appData: { appURL: string }; + data: DataStructure; + }>(); + const { setToast } = useToasts(); + const [loading, setLoading] = useState(false); + const [transaction, setTransaction] = useState(null); + const [transactionList, setTransactionList] = useState(null); + const [password, setPassword] = useState(false); + const { closeWindow, cancel } = useAuthUtils( + "batchSignDataItem", + params?.authID + ); + const passwordInput = useInput(); + async function sign() { + if (password) { + const checkPw = await checkPassword(passwordInput.state); + if (!checkPw) { + setToast({ + type: "error", + content: browser.i18n.getMessage("invalidPassword"), + duration: 2400 + }); + return; + } + } + await replyToAuthRequest("batchSignDataItem", params?.authID); + closeWindow(); + } + + const [signatureAllowance] = useStorage( + { + key: "signatureAllowance", + instance: ExtensionStorage + }, + 10 + ); + + useEffect(() => { + const fetchTransactionList = async () => { + setLoading(true); + try { + if (Array.isArray(params?.data)) { + const listItems = await Promise.all( + params.data.map(async (item, index) => { + let amount = ""; + let name = ""; + const quantity = + item?.tags?.find((tag) => tag.name === "Quantity")?.value || + "0"; + const transfer = item?.tags?.some( + (tag) => tag.name === "Action" && tag.value === "Transfer" + ); + + if (transfer && quantity) { + let tokenInfo: any; + try { + const token = await timeoutPromise(Token(item.target), 6000); + tokenInfo = { + ...token.info, + Denomination: Number(token.info.Denomination) + }; + const tokenAmount = new Quantity( + BigInt(quantity), + BigInt(tokenInfo.Denomination) + ); + amount = tokenAmount.toLocaleString(); + name = tokenInfo.Name; + console.log(signatureAllowance, Number(amount)); + if (signatureAllowance > Number(amount)) { + setPassword(true); + } + } catch (error) { + console.error("Token fetch timed out or failed", error); + amount = quantity; + name = item.target; + } + } + return ( + setTransaction(item)} + /> + ); + }) + ); + setTransactionList(listItems); + } + } finally { + setLoading(false); + } + }; + + fetchTransactionList(); + }, [params]); + + return ( + +
+ (transaction ? setTransaction(null) : cancel())} + /> + + + {browser.i18n.getMessage( + "batch_sign_data_description", + params?.appData.appURL + )} + + + + {transaction ? ( + + ) : ( +
+ {transactionList} +
+ )} +
+ +
+ {!transaction ? ( + <> + {password && ( +
+ { + if (e.key !== "Enter") return; + await sign(); + }} + fullWidth + /> +
+ )} + + + {browser.i18n.getMessage("signature_authorize")} + + + {browser.i18n.getMessage("cancel")} + + + ) : ( + setTransaction(null)}> + {browser.i18n.getMessage("continue")} + + )} +
+
+ ); +} + +function formatTransactionDescription( + amount?: string, + tokenName?: string +): string { + if (amount && tokenName) { + return `Sending ${amount} of ${tokenName}`; + } + return "Unknown transaction"; +} + +const Description = styled(Section)` + display: flex; + flex-direction: column; + gap: 18px; +`; diff --git a/src/tabs/auth.tsx b/src/tabs/auth.tsx index 54dd641e5..574ea18d5 100644 --- a/src/tabs/auth.tsx +++ b/src/tabs/auth.tsx @@ -17,6 +17,7 @@ import Token from "~routes/auth/token"; import Sign from "~routes/auth/sign"; import Subscription from "~routes/auth/subscription"; import SignKeystone from "~routes/auth/signKeystone"; +import BatchSignDataItem from "~routes/auth/batchSignDataItem"; export default function Auth() { const theme = useTheme(); @@ -40,6 +41,7 @@ export default function Auth() { + diff --git a/src/utils/mutex.ts b/src/utils/mutex.ts new file mode 100644 index 000000000..412e49e5f --- /dev/null +++ b/src/utils/mutex.ts @@ -0,0 +1,14 @@ +export class Mutex { + private mutex = Promise.resolve(); + + lock(): Promise<() => void> { + let unlockNext: () => void; + const willLock = new Promise((resolve) => (unlockNext = resolve)); + // To release the lock + const unlock = () => unlockNext(); + + const currentLock = this.mutex.then(() => unlock); + this.mutex = this.mutex.then(() => willLock); + return currentLock; + } +}