diff --git a/assets/_locales/en/messages.json b/assets/_locales/en/messages.json index 7e7e65de7..0b8d8cf92 100644 --- a/assets/_locales/en/messages.json +++ b/assets/_locales/en/messages.json @@ -49,6 +49,18 @@ "message": "Hide", "description": "Hide text" }, + "close": { + "message": "Close", + "description": "Close button text" + }, + "retry": { + "message": "Retry", + "description": "Retry button text" + }, + "save": { + "message": "Save", + "description": "Save button text" + }, "add_wallet": { "message": "Add wallet", "description": "Add a wallet text" @@ -248,39 +260,39 @@ "description": "Send transaction button" }, "permissionDescriptionAccessAddress": { - "message": "Access the current address selected in ArConnect", + "message": "Allows access to the active address", "description": "Description for the \"ACCESS_ADDRESS\" permission" }, "permissionDescriptionAccessPublicKey": { - "message": "Access the public key of the current address selected in ArConnect", + "message": "Allows access to the active address public key", "description": "Description for the \"ACCESS_PUBLIC_KEY\" permission" }, "permissionDescriptionAccessAllAddresses": { - "message": "Access all addresses added to ArConnect", + "message": "Allows access to all the active and non-active wallet addresses", "description": "Description for the \"ACCESS_ALL_ADDRESSES\" permission" }, "permissionDescriptionSign": { - "message": "Sign a transaction", + "message": "Allows the signing of a transaction.", "description": "Description for the \"SIGN_TRANSACTION\" permission" }, "permissionDescriptionEncrypt": { - "message": "Encrypt data using the user's keyfile", + "message": "Allows encrypting data using wallet's keyfile. It does not grant access to keyfile.", "description": "Description for the \"ENCRYPT\" permission" }, "permissionDescriptionDecrypt": { - "message": "Decrypt data using the user's keyfile", + "message": "Allows decrypting data using wallet's keyfile. It does not grant access to keyfile.", "description": "Description for the \"DECRYPT\" permission" }, "permissionDescriptionSignature": { - "message": "Sign data using the user's keyfile", + "message": "Allows the signing of data.", "description": "Description for the \"SIGNATURE\" permission" }, "permissionDescriptionArweaveConfig": { - "message": "Access the user's custom Arweave config", + "message": "Access read access to ArConnect configuration file", "description": "Description for the \"ACCESS_ARWEAVE_CONFIG\" permission" }, "permissionDescriptionDispatch": { - "message": "Dispatch an Arweave transaction or interaction", + "message": "Allows using dispatch transactions.", "description": "Description for the \"DISPATCH\" permission" }, "copyId": { @@ -665,6 +677,18 @@ "message": "Error exporting wallet", "description": "Wallet export error notification" }, + "import_wallet_key_length_short_error_title": { + "message": "Key Length Too Short", + "description": "Key Length Too Short error title" + }, + "import_wallet_key_length_short_error": { + "message": "The key length of the wallet you are importing is less than the required length to transact on the AO network. It is recommended you create a new ArConnect wallet and transfer your assets to your new wallet.", + "description": "Key Length Too Short error" + }, + "generate_wallet_in_progress": { + "message": "The wallet creation process can take 30-60 seconds.", + "description": "Generate wallet in progresss text" + }, "development_version": { "message": "Development", "description": "Development version badge text" @@ -779,6 +803,10 @@ "message": "Generating wallet...", "description": "Generating wallet in progress text" }, + "longer_than_usual": { + "message": "Please wait, this is taking longer than usual...", + "description": "Longer than usual text" + }, "keyfile": { "message": "Keyfile", "description": "Keyfile text" @@ -1909,11 +1937,11 @@ "description": "Popup description about ao token transfer learn more" }, "ao_degraded": { - "message": "AO Token Process Network Degraded.", + "message": "Unable to connect to AO Token Process", "description": "ao degraded title text" }, "ao_degraded_description": { - "message": "AO token process will be available when
the network issues are resolved.", + "message": "AO balance will be available when
network issues are resolved.", "description": "ao degraded description text" }, "network_issue": { @@ -2109,5 +2137,13 @@ "incorrect_password_error_message": { "message": "Password incorrect. Please enter the password to your ArConnect and try again. Not the password to the wallet you are importing.", "description": "Error message if password is incorrect" + }, + "mismatch_warning_title": { + "message": "Wallet Mismatch Detected.", + "description": "Title for wallet mismatch warning on sda" + }, + "mismatch_warning": { + "message": "Your wallet has a mismatched bit length. You can proceed, but may encounter errors. Transactions could fail or behave unexpectedly.", + "description": "warning for mismatch wallet" } } diff --git a/assets/_locales/zh_CN/messages.json b/assets/_locales/zh_CN/messages.json index 7851219ed..770aa7c55 100644 --- a/assets/_locales/zh_CN/messages.json +++ b/assets/_locales/zh_CN/messages.json @@ -49,6 +49,18 @@ "message": "隐藏", "description": "Hide text" }, + "close": { + "message": "关闭", + "description": "Close button text" + }, + "retry": { + "message": "重试", + "description": "Retry button text" + }, + "save": { + "message": "节省", + "description": "Save button text" + }, "add_wallet": { "message": "添加钱包", "description": "Add a wallet text" @@ -248,39 +260,39 @@ "description": "Send transaction button" }, "permissionDescriptionAccessAddress": { - "message": "访问 ArConnect 中选择的当前地址", + "message": "允许访问活动地址", "description": "Description for the \"ACCESS_ADDRESS\" permission" }, "permissionDescriptionAccessPublicKey": { - "message": "访问 ArConnect 中选择的当前地址的公钥", + "message": "允许访问活动地址的公钥", "description": "Description for the \"ACCESS_PUBLIC_KEY\" permission" }, "permissionDescriptionAccessAllAddresses": { - "message": "访问添加到 ArConnect 的所有地址", + "message": "允许访问所有活动和非活动钱包地址", "description": "Description for the \"ACCESS_ALL_ADDRESSES\" permission" }, "permissionDescriptionSign": { - "message": "签署交易", + "message": "允许签署交易。", "description": "Description for the \"SIGN_TRANSACTION\" permission" }, "permissionDescriptionEncrypt": { - "message": "使用用户的密钥文件加密数据", + "message": "允许使用钱包的密钥文件加密数据。这并不授予对密钥文件的访问权限", "description": "Description for the \"ENCRYPT\" permission" }, "permissionDescriptionDecrypt": { - "message": "使用用户的密钥文件解密数据", + "message": "允许使用钱包的密钥文件解密数据。这并不授予对密钥文件的访问权限", "description": "Description for the \"DECRYPT\" permission" }, "permissionDescriptionSignature": { - "message": "使用用户的密钥文件签署数据", + "message": "允许签署数据。", "description": "Description for the \"SIGNATURE\" permission" }, "permissionDescriptionArweaveConfig": { - "message": "访问用户的自定义 Arweave 配置", + "message": "允许读取 ArConnect 配置文件", "description": "Description for the \"ACCESS_ARWEAVE_CONFIG\" permission" }, "permissionDescriptionDispatch": { - "message": "发送 Arweave 交易或互动", + "message": "允许使用调度交易。", "description": "Description for the \"DISPATCH\" permission" }, "copyId": { @@ -665,6 +677,18 @@ "message": "导出钱包出错", "description": "Wallet export error notification" }, + "import_wallet_key_length_short_error_title": { + "message": "密钥长度太短", + "description": "Key Length Too Short error" + }, + "import_wallet_key_length_short_error": { + "message": "您导入的钱包的密钥长度小于在 AO 网络上进行交易所需的长度。建议您创建一个新的ArConnect钱包,并将您的资产转移到新钱包中。", + "description": "Key Length Too Short error" + }, + "generate_wallet_in_progress": { + "message": "钱包创建过程可能需要 30-60 秒。", + "description": "Generate wallet in progresss text" + }, "development_version": { "message": "开发版本", "description": "Development version badge text" @@ -779,6 +803,10 @@ "message": "生成钱包中...", "description": "Generating wallet in progress text" }, + "longer_than_usual": { + "message": "请稍等,这比平常需要更长的时间...", + "description": "Longer than usual text" + }, "keyfile": { "message": "密钥文件", "description": "Keyfile text" @@ -1907,11 +1935,11 @@ "description": "Popup description about ao token transfer learn more" }, "ao_degraded": { - "message": "AO 代币处理网络降级。", + "message": "无法连接到 AO 令牌进程", "description": "ao degraded title text" }, "ao_degraded_description": { - "message": "网络问题解决后,AO 代币处理将可用。", + "message": "网络问题解决后,AO 余额将可用。", "description": "ao degraded description text" }, "network_issue": { @@ -2103,5 +2131,13 @@ "incorrect_password_error_message": { "message": "密码错误。请输入 ArConnect 的密码后再试,而不是您正在导入的钱包密码。", "description": "Error message if password is incorrect" + }, + "mismatch_warning_title": { + "message": "检测到钱包不匹配。", + "description": "Title for wallet mismatch warning on sda" + }, + "mismatch_warning": { + "message": "您的钱包存在不匹配的位长度。您可以继续操作,但可能会遇到错误。交易可能会失败或表现异常。", + "description": "warning for mismatch wallet" } } diff --git a/src/api/modules/connect/auth.ts b/src/api/modules/connect/auth.ts index a389df498..dd9bbadaf 100644 --- a/src/api/modules/connect/auth.ts +++ b/src/api/modules/connect/auth.ts @@ -11,7 +11,7 @@ export type AuthType = | "token" | "sign" | "subscription" - | "signMessage" + | "signKeystone" | "signature" | "signDataItem"; diff --git a/src/api/modules/sign/chunks.ts b/src/api/modules/sign/chunks.ts index 51d757595..affe23612 100644 --- a/src/api/modules/sign/chunks.ts +++ b/src/api/modules/sign/chunks.ts @@ -7,7 +7,7 @@ import { nanoid } from "nanoid"; */ export interface Chunk { collectionID: string; // unique ID for the collection, that is the parent of this chunk - type: "tag" | "data" | "start" | "end"; + type: "tag" | "data" | "bytes" | "start" | "end"; index: number; // index of the chunk, to make sure it is not in the wrong order value?: number[] | Tag; // Uint8Array converted to number array or a tag } diff --git a/src/api/modules/sign/sign_auth.ts b/src/api/modules/sign/sign_auth.ts index 3b006fcd0..d025d1d69 100644 --- a/src/api/modules/sign/sign_auth.ts +++ b/src/api/modules/sign/sign_auth.ts @@ -1,8 +1,9 @@ import { onMessage, sendMessage } from "@arconnect/webext-bridge"; -import { deconstructTransaction } from "./transaction_builder"; +import { bytesToChunks, deconstructTransaction } from "./transaction_builder"; import type Transaction from "arweave/web/lib/transaction"; import type { AuthResult } from "shim"; import authenticate from "../connect/auth"; +import { nanoid } from "nanoid"; /** * Request a manual signature for the transaction. @@ -73,15 +74,57 @@ export const signAuth = ( } ); -export const signAuthMessage = (dataToSign: Uint8Array) => +export type AuthKeystoneType = "Message" | "DataItem"; + +export interface AuthKeystoneData { + type: AuthKeystoneType; + data: Uint8Array; +} + +export const signAuthKeystone = (dataToSign: AuthKeystoneData) => new Promise>( (resolve, reject) => { // start auth + const collectionID = nanoid(); authenticate({ - type: "signMessage", - data: Buffer.from(dataToSign).toString("base64") + type: "signKeystone", + keystoneSignType: dataToSign.type, + collectionID }) .then((res) => resolve(res)) .catch((err) => reject(err)); + const dataChunks = bytesToChunks(dataToSign.data, collectionID, 0); + + // send tx in chunks to sign if requested + onMessage("auth_listening", async ({ sender }) => { + if (sender.context !== "web_accessible") return; + + // send data chunks + for (const chunk of dataChunks) { + try { + await sendMessage( + "auth_chunk", + chunk, + `web_accessible@${sender.tabId}` + ); + } catch (e) { + // chunk fail + return reject( + `Error while sending a data chunk of collection "${collectionID}": \n${e}` + ); + } + } + + // end chunk + await sendMessage( + "auth_chunk", + { + collectionID, + type: "end", + index: dataChunks.length + }, + `web_accessible@${sender.tabId}` + ); + }); } ); diff --git a/src/api/modules/sign/transaction_builder.ts b/src/api/modules/sign/transaction_builder.ts index c6c83dfde..348b6c9c7 100644 --- a/src/api/modules/sign/transaction_builder.ts +++ b/src/api/modules/sign/transaction_builder.ts @@ -9,6 +9,52 @@ import { nanoid } from "nanoid"; */ export type SplitTransaction = Partial; +/** + * Split an Uint8Array to chunks, per chunks value max size is limited to 0.5mb + */ +export const bytesToChunks = ( + data: Uint8Array, + id: string, + start_index: number +): Chunk[] => { + const dataChunks: Chunk[] = []; + + for (let i = 0; i < Math.ceil(data.length / CHUNK_SIZE); i++) { + const sliceFrom = i * CHUNK_SIZE; + const chunkValue = data.slice(sliceFrom, sliceFrom + CHUNK_SIZE); + + dataChunks.push({ + collectionID: id, + type: "bytes", + index: i + start_index, + value: Array.from(chunkValue) + }); + } + return dataChunks; +}; + +/** + * Reconstruct bytes from chunks + */ +export const bytesFromChunks = (chunks: Chunk[]): Uint8Array => { + chunks.sort((a, b) => a.index - b.index); + + const dataSize = getDataSize(chunks); + const reconstructedData = new Uint8Array(dataSize); + + let previousLength = 0; + + for (const chunk of chunks) { + if (chunk.type === "bytes") { + const chunkBuffer = new Uint8Array(chunk.value as number[]); + + reconstructedData.set(chunkBuffer, previousLength); + previousLength += chunkBuffer.length; + } + } + return reconstructedData; +}; + /** * Split the tags and the data of a transaction in * chunks and remove them from the transaction object @@ -81,7 +127,7 @@ export function getDataSize(chunks: Chunk[]): number { let dataSize = 0; for (const chunk of chunks) { - if (chunk.type === "data") { + if (chunk.type === "data" || chunk.type === "bytes") { dataSize += chunk.value?.length || 0; } } diff --git a/src/api/modules/sign_data_item/sign_data_item.background.ts b/src/api/modules/sign_data_item/sign_data_item.background.ts index cca051169..3056c5376 100644 --- a/src/api/modules/sign_data_item/sign_data_item.background.ts +++ b/src/api/modules/sign_data_item/sign_data_item.background.ts @@ -9,12 +9,18 @@ import type { ModuleFunction } from "~api/background"; import { ArweaveSigner, createData } from "arbundles"; import Application from "~applications/application"; import { getPrice } from "../dispatch/uploader"; -import { getActiveKeyfile } from "~wallets"; +import { getActiveKeyfile, getActiveWallet } from "~wallets"; import browser from "webextension-polyfill"; -import { signAuth } from "../sign/sign_auth"; +import { + signAuth, + signAuthKeystone, + type AuthKeystoneData +} from "../sign/sign_auth"; import Arweave from "arweave"; import authenticate from "../connect/auth"; import BigNumber from "bignumber.js"; +import { createDataItem } from "~utils/data_item"; +import signMessage from "../sign_message"; const background: ModuleFunction = async ( appData, @@ -69,10 +75,6 @@ const background: ModuleFunction = async ( throw new Error("No wallets added"); }); - // ensure that the currently selected - // wallet is not a local wallet - isLocalWallet(decryptedWallet); - // create app const app = new Application(appData.appURL); @@ -83,44 +85,71 @@ const background: ModuleFunction = async ( const { data, ...options } = dataItem; const binaryData = new Uint8Array(data); - // create bundlr tx as a data entry - const dataSigner = new ArweaveSigner(decryptedWallet.keyfile); - const dataEntry = createData(binaryData, dataSigner, options); + if (decryptedWallet.type == "local") { + // create bundlr tx as a data entry + const dataSigner = new ArweaveSigner(decryptedWallet.keyfile); + const dataEntry = createData(binaryData, dataSigner, options); - // check allowance - // const price = await getPrice(dataEntry, await app.getBundler()); - const allowance = await app.getAllowance(); + // check allowance + // const price = await getPrice(dataEntry, await app.getBundler()); + const allowance = await app.getAllowance(); - // allowance or sign auth - try { - if (!allowance.enabled) { - // get address - const address = await arweave.wallets.jwkToAddress( - decryptedWallet.keyfile - ); + // allowance or sign auth + try { + if (!allowance.enabled) { + // get address + const address = await arweave.wallets.jwkToAddress( + decryptedWallet.keyfile + ); - await signAuth( - appData.appURL, - // @ts-expect-error - dataEntry.toJSON(), - address - ); + await signAuth( + appData.appURL, + // @ts-expect-error + dataEntry.toJSON(), + address + ); + } + } catch (e) { + freeDecryptedWallet(decryptedWallet.keyfile); + throw new Error(e?.message || e); } - } catch (e) { - freeDecryptedWallet(decryptedWallet.keyfile); - throw new Error(e?.message || e); - } + // sign item + await dataEntry.sign(dataSigner); - // sign item - await dataEntry.sign(dataSigner); + // update allowance spent amount (in winstons) + // await updateAllowance(appData.appURL, price); - // update allowance spent amount (in winstons) - // await updateAllowance(appData.appURL, price); - - // remove keyfile - freeDecryptedWallet(decryptedWallet.keyfile); + // remove keyfile + freeDecryptedWallet(decryptedWallet.keyfile); - return Array.from(dataEntry.getRaw()); + return Array.from(dataEntry.getRaw()); + } else { + // create bundlr tx as a data entry + const activeWallet = await getActiveWallet(); + if (activeWallet.type != "hardware") throw new Error("Invalid Wallet Type"); + const signerConfig = { + signatureType: 1, + signatureLength: 512, + ownerLength: 512, + publicKey: Buffer.from( + Arweave.utils.b64UrlToBuffer(activeWallet.publicKey) + ) + }; + const dataEntry = createDataItem(binaryData, signerConfig, options); + try { + const data: AuthKeystoneData = { + type: "DataItem", + data: dataEntry.getRaw() + }; + const res = await signAuthKeystone(data); + dataEntry.setSignature( + Buffer.from(Arweave.utils.b64UrlToBuffer(res.data.signature)) + ); + } catch (e) { + throw new Error(e?.message || e); + } + return Array.from(dataEntry.getRaw()); + } }; export default background; diff --git a/src/api/modules/sign_message/sign_message.background.ts b/src/api/modules/sign_message/sign_message.background.ts index 716b25d65..c680c03b0 100644 --- a/src/api/modules/sign_message/sign_message.background.ts +++ b/src/api/modules/sign_message/sign_message.background.ts @@ -8,7 +8,7 @@ import { isNumberArray, isSignMessageOptions } from "~utils/assertions"; -import { signAuthMessage } from "../sign/sign_auth"; +import { signAuthKeystone, type AuthKeystoneData } from "../sign/sign_auth"; import Arweave from "arweave"; const background: ModuleFunction = async ( @@ -66,7 +66,11 @@ const background: ModuleFunction = async ( return Array.from(new Uint8Array(signature)); } else { - const res = await signAuthMessage(dataToSign); + const data: AuthKeystoneData = { + type: "Message", + data: dataToSign + }; + const res = await signAuthKeystone(data); const sig = Arweave.utils.b64UrlToBuffer(res.data.signature); return Array.from(sig); } diff --git a/src/components/dashboard/subsettings/AddWallet.tsx b/src/components/dashboard/subsettings/AddWallet.tsx index 324a508d8..a1767f307 100644 --- a/src/components/dashboard/subsettings/AddWallet.tsx +++ b/src/components/dashboard/subsettings/AddWallet.tsx @@ -4,14 +4,15 @@ import type { JWKInterface } from "arweave/web/lib/wallet"; import { checkPassword } from "~wallets/auth"; import { useEffect, useState } from "react"; import { useLocation } from "wouter"; -import { addWallet } from "~wallets"; +import { addWallet, getWalletKeyLength } from "~wallets"; import { Text, useInput, Spacer, useToasts, ButtonV2, - InputV2 + InputV2, + useModal } from "@arconnect/components"; import BackupWalletPage from "~components/welcome/generate/BackupWalletPage"; import KeystoneButton from "~components/hardware/KeystoneButton"; @@ -21,11 +22,18 @@ import * as bip39 from "bip39-web-crypto"; import Arweave from "arweave/web/common"; import styled from "styled-components"; import { defaultGateway } from "~gateways/gateway"; +import { WalletKeySizeErrorModal } from "~components/modals/WalletKeySizeErrorModal"; export default function AddWallet() { // password input const passwordInput = useInput(); + // wallet size error modal + const walletModal = useModal(); + + // wallet generation taking longer + const [showLongWaitMessage, setShowLongWaitMessage] = useState(false); + // toasts const { setToast } = useToasts(); @@ -107,6 +115,7 @@ export default function AddWallet() { const finishUp = () => { // reset before unload window.onbeforeunload = null; + setShowLongWaitMessage(false); setLoading(false); }; @@ -126,12 +135,28 @@ export default function AddWallet() { } try { + const startTime = Date.now(); // load jwk from seedphrase input state - const jwk = + let jwk = typeof providedWallet === "string" ? await jwkFromMnemonic(providedWallet) : providedWallet; + let { actualLength, expectedLength } = await getWalletKeyLength(jwk); + if (expectedLength !== actualLength) { + if (typeof providedWallet !== "string") { + walletModal.setOpen(true); + finishUp(); + return; + } else { + while (expectedLength !== actualLength) { + setShowLongWaitMessage(Date.now() - startTime > 30000); + jwk = await jwkFromMnemonic(providedWallet); + ({ actualLength, expectedLength } = await getWalletKeyLength(jwk)); + } + } + } + await addWallet(jwk, passwordInput.state); // send success toast @@ -175,15 +200,25 @@ export default function AddWallet() { async function generateWallet() { setGenerating(true); + const startTime = Date.now(); + // generate a seedphrase const seedphrase = await bip39.generateMnemonic(); setGeneratedWallet({ seedphrase }); // generate from seedphrase - const jwk = await jwkFromMnemonic(seedphrase); + let jwk = await jwkFromMnemonic(seedphrase); + + let { actualLength, expectedLength } = await getWalletKeyLength(jwk); + while (expectedLength !== actualLength) { + setShowLongWaitMessage(Date.now() - startTime > 30000); + jwk = await jwkFromMnemonic(seedphrase); + ({ actualLength, expectedLength } = await getWalletKeyLength(jwk)); + } setGeneratedWallet((val) => ({ ...val, jwk })); + setShowLongWaitMessage(false); setGenerating(false); return { jwk, seedphrase }; @@ -325,7 +360,13 @@ export default function AddWallet() { {browser.i18n.getMessage("generate_wallet")} + {(generating || loading) && showLongWaitMessage && ( + + {browser.i18n.getMessage("longer_than_usual")} + + )} + ); } diff --git a/src/components/dashboard/subsettings/AppSettings.tsx b/src/components/dashboard/subsettings/AppSettings.tsx index 0d950b534..29784df4b 100644 --- a/src/components/dashboard/subsettings/AppSettings.tsx +++ b/src/components/dashboard/subsettings/AppSettings.tsx @@ -111,34 +111,45 @@ export default function AppSettings({ app, showTitle = false }: Props) { )} {browser.i18n.getMessage("permissions")} - {Object.keys(permissionData).map((permissionName: PermissionType, i) => ( -
- - updateSettings((val) => { - // toggle permission - if (checked && !val.permissions.includes(permissionName)) { - val.permissions.push(permissionName); - } else if (!checked) { - val.permissions = val.permissions.filter( - (p) => p !== permissionName - ); - } + {Object.keys(permissionData).map((permissionName: PermissionType, i) => { + let formattedPermissionName = permissionName + .split("_") + .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) + .join(" "); + + if (permissionName === "SIGNATURE") { + formattedPermissionName = "Sign Data"; + } - return val; - }) - } - checked={settings.permissions.includes(permissionName)} - > - {permissionName} -
- - {browser.i18n.getMessage(permissionData[permissionName])} - -
- {i !== Object.keys(permissionData).length - 1 && } -
- ))} + return ( +
+ + updateSettings((val) => { + // toggle permission + if (checked && !val.permissions.includes(permissionName)) { + val.permissions.push(permissionName); + } else if (!checked) { + val.permissions = val.permissions.filter( + (p) => p !== permissionName + ); + } + + return val; + }) + } + checked={settings.permissions.includes(permissionName)} + > + {formattedPermissionName} +
+ + {browser.i18n.getMessage(permissionData[permissionName])} + +
+ {i !== Object.keys(permissionData).length - 1 && } +
+ ); + })} {browser.i18n.getMessage("allowance")} ` + width: 245px; + text-align: center; + color: ${(props) => + props.theme.displayTheme === "light" ? "#191919" : "#FFFFFF"}; + font-weight: 500; + font-size: 11px; + line-height: 16px; + align-self: stretch; + flex: none; + flex-grow: 0; + + a { + color: rgb(${(props) => props.theme.theme}); + text-decoration: none; + } +`; + +export const HeaderText = styled(Text)<{ displayTheme?: DisplayTheme }>` + font-size: 18px; + font-weight: 500; + color: ${(props) => + props.theme.displayTheme === "light" ? "#191919" : "#FFFFFF"}; +`; diff --git a/src/components/modals/WalletKeySizeErrorModal.tsx b/src/components/modals/WalletKeySizeErrorModal.tsx new file mode 100644 index 000000000..cff645bdc --- /dev/null +++ b/src/components/modals/WalletKeySizeErrorModal.tsx @@ -0,0 +1,50 @@ +import { ButtonV2, ModalV2, Spacer } from "@arconnect/components"; +import { useRef } from "react"; +import browser from "webextension-polyfill"; +import { ContentWrapper, Content, HeaderText, CenterText } from "./Components"; + +interface Props { + isOpen: boolean; + setOpen: (value: boolean) => void; + back?: () => void; +} + +export const WalletKeySizeErrorModal = ({ isOpen, setOpen, back }: Props) => { + const modalRef = useRef(null); + + return ( + + + +
+ + {browser.i18n.getMessage( + "import_wallet_key_length_short_error_title" + )} + + + + {browser.i18n.getMessage("import_wallet_key_length_short_error")} + + +
+
+ { + setOpen(false); + if (back) { + back(); + } + }} + > + {browser.i18n.getMessage("close")} + +
+
+ ); +}; diff --git a/src/routes/auth/signDataItem.tsx b/src/routes/auth/signDataItem.tsx index 1bb21e686..9c4e5235d 100644 --- a/src/routes/auth/signDataItem.tsx +++ b/src/routes/auth/signDataItem.tsx @@ -36,10 +36,17 @@ 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 } from "~components/popup/Token"; +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, + EventType, + trackEvent, + type WalletBitsCheck +} from "~utils/analytics"; +import { Degraded, WarningWrapper } from "~routes/popup/send"; interface Tag { name: string; @@ -65,6 +72,7 @@ export default function SignDataItem() { const [logo, setLogo] = useState(""); const [amount, setAmount] = useState(null); const [showTags, setShowTags] = useState(false); + const [mismatch, setMismatch] = useState(false); const { setToast } = useToasts(); const recipient = @@ -236,6 +244,34 @@ export default function SignDataItem() { } }, [tokenName, logo, theme]); + // check for if bits check exists, if it does, check mismatch + useEffect(() => { + const walletCheck = async () => { + if (!activeAddress) { + setMismatch(false); + return; + } + try { + const storageKey = `bits_check_${activeAddress}`; + const storedCheck = await ExtensionStorage.get< + WalletBitsCheck | boolean + >(storageKey); + + if (typeof storedCheck !== "object") { + const bits = await checkWalletBits(); + if (bits !== null) { + setMismatch(bits); + } + } else { + setMismatch(storedCheck.mismatch); + } + } catch (error) { + console.error("Error checking wallet bits:", error); + } + }; + walletCheck(); + }, [activeAddress]); + return (
@@ -244,6 +280,21 @@ export default function SignDataItem() { showOptions={false} back={cancel} /> + {mismatch && transfer && ( + + + {" "} + +
+

{browser.i18n.getMessage("mismatch_warning_title")}

+ +

{browser.i18n.getMessage("mismatch_warning")}

+ + {/* Read more */} +
+
+
+ )} {browser.i18n.getMessage( diff --git a/src/routes/auth/signMessage.tsx b/src/routes/auth/signKeystone.tsx similarity index 68% rename from src/routes/auth/signMessage.tsx rename to src/routes/auth/signKeystone.tsx index 6fa406f08..fdbb94e45 100644 --- a/src/routes/auth/signMessage.tsx +++ b/src/routes/auth/signKeystone.tsx @@ -1,15 +1,13 @@ import { replyToAuthRequest, useAuthParams, useAuthUtils } from "~utils/auth"; -import { decodeSignature, messageToUR } from "~wallets/hardware/keystone"; +import { + dataItemToUR, + decodeSignature, + messageToUR +} from "~wallets/hardware/keystone"; import { useEffect, useState } from "react"; import { useScanner } from "@arconnect/keystone-sdk"; import { useActiveWallet } from "~wallets/hooks"; import type { UR } from "@ngraveio/bc-ur"; -import { - Properties, - PropertyName, - PropertyValue, - TransactionProperty -} from "~routes/popup/transaction/[id]"; import { ButtonV2, Section, @@ -24,31 +22,69 @@ import Progress from "~components/Progress"; import browser from "webextension-polyfill"; import Head from "~components/popup/Head"; import Message from "~components/auth/Message"; - -export default function SignMessage() { +import type { AuthKeystoneType } from "~api/modules/sign/sign_auth"; +import { onMessage, sendMessage } from "@arconnect/webext-bridge"; +import type { Chunk } from "~api/modules/sign/chunks"; +import { bytesFromChunks } from "~api/modules/sign/transaction_builder"; +export default function SignKeystone() { // sign params const params = useAuthParams<{ - data: string; + collectionId: string; + keystoneSignType: string; }>(); - // reconstructed transaction const [dataToSign, setDataToSign] = useState(); + const [dataType, setDataType] = useState("Message"); useEffect(() => { (async () => { - if (!params?.data) return; - // reset tx - setDataToSign(Buffer.from(params?.data, "base64")); + // request chunks + if (params) { + setDataType(params?.keystoneSignType); + sendMessage("auth_listening", null, "background"); + + const chunks: Chunk[] = []; + + // listen for chunks + onMessage("auth_chunk", ({ sender, data }) => { + // check data type + if ( + data.collectionID !== params.collectionID || + sender.context !== "background" || + data.type === "start" + ) { + return; + } + // end chunk stream + if (data.type === "end") { + const bytes = bytesFromChunks(chunks); + const signData = Buffer.from(bytes); + setDataToSign(signData); + } else if (data.type === "bytes") { + // add chunk + chunks.push(data); + } + }); + } })(); }, [params]); + useEffect(() => { + (async () => { + if (dataType === "DataItem" && !!dataToSign) { + await loadTransactionUR(); + setPage("qr"); + } + })(); + }, [dataType, dataToSign]); + // get auth utils - const { closeWindow, cancel } = useAuthUtils("signMessage", params?.authID); + const { closeWindow, cancel } = useAuthUtils("signKeystone", params?.authID); // authorize async function authorize(data?: any) { // reply to request - await replyToAuthRequest("signMessage", params.authID, undefined, data); + await replyToAuthRequest("signKeystone", params.authID, undefined, data); // close the window closeWindow(); @@ -69,11 +105,14 @@ export default function SignMessage() { async function loadTransactionUR() { if (wallet.type !== "hardware" || !dataToSign) return; - // load the ur data - const ur = await messageToUR(dataToSign, wallet.xfp); - - setTransactionUR(ur); + if (dataType === "DataItem") { + const ur = await dataItemToUR(dataToSign, wallet.xfp); + setTransactionUR(ur); + } else { + const ur = await messageToUR(dataToSign, wallet.xfp); + setTransactionUR(ur); + } } // loading @@ -106,7 +145,7 @@ export default function SignMessage() { // reply to request await replyToAuthRequest( - "signMessage", + "signKeystone", params.authID, "Failed to decode signature from keystone" ); @@ -133,7 +172,7 @@ export default function SignMessage() { allowOpen={false} /> - {(!page && dataToSign && ( + {(!page && dataToSign && dataType === "Message" && (
@@ -145,13 +184,13 @@ export default function SignMessage() { <> + onError={(error) => { setToast({ type: "error", duration: 2300, content: browser.i18n.getMessage(`keystone_${error}`) - }) - } + }); + }} /> diff --git a/src/routes/popup/announcement.tsx b/src/routes/popup/announcement.tsx index 349c012df..7b649c2b5 100644 --- a/src/routes/popup/announcement.tsx +++ b/src/routes/popup/announcement.tsx @@ -1,16 +1,15 @@ -import { - ButtonV2, - ModalV2, - Spacer, - Text, - type DisplayTheme -} from "@arconnect/components"; +import { ButtonV2, ModalV2, Spacer } from "@arconnect/components"; import { ExtensionStorage } from "~utils/storage"; import { useEffect, useRef, useState } from "react"; import browser from "webextension-polyfill"; import aoLogo from "url:/assets/ecosystem/ao-token-logo.png"; -import styled from "styled-components"; import { useStorage } from "@plasmohq/storage/hook"; +import { + ContentWrapper, + Content, + HeaderText, + CenterText +} from "~components/modals/Components"; export const AnnouncementPopup = ({ isOpen, setOpen }) => { const [notifications, setNotifications] = useStorage({ @@ -27,11 +26,6 @@ export const AnnouncementPopup = ({ isOpen, setOpen }) => { } }, [notifications]); - const handleCheckbox = async () => { - setChecked((prev) => !prev); - setNotifications((prev) => !prev); - }; - const handleClickOutside = (event) => { if (modalRef.current && !modalRef.current.contains(event.target)) { ExtensionStorage.set("show_announcement", false); @@ -87,83 +81,3 @@ export const AnnouncementPopup = ({ isOpen, setOpen }) => { ); }; - -export const Content = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - flex: none; - align-self: stretch; - flex-grow: 0; -`; - -export const ContentWrapper = styled.div` - height: 100%; - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: space-between; -`; - -const CenterText = styled(Text).attrs({ - noMargin: true -})<{ displayTheme?: DisplayTheme }>` - width: 245px; - text-align: center; - color: ${(props) => - props.theme.displayTheme === "light" ? "#191919" : "#FFFFFF"}; - font-weight: 500; - font-size: 11px; - line-height: 16px; - align-self: stretch; - flex: none; - flex-grow: 0; -`; - -const Link = styled.u` - cursor: pointer; -`; - -const CheckContainer = styled.div` - width: 245px; - display: flex; - flex-direction: row; - padding-left: 72px; - align-items: center; - isolation: isolate; - font-weight: 500; - font-size: 11px; - flex: none; - flex-grow: 0; - gap: 8px; -`; - -const CheckedSvg = styled.svg` - position: absolute; - left: calc(50% + 4px / 2 - 113px); - width: 18px; - height: 18px; - cursor: pointer; - flex: none; - flex-grow: 0; - background: #8e7bea; - border-radius: 2px; -`; - -const UncheckedSvg = styled.svg` - position: absolute; - left: calc(50% + 4px / 2 - 113px); - width: 18px; - height: 18px; - cursor: pointer; - flex: none; - flex-grow: 0; -`; - -const HeaderText = styled(Text)<{ displayTheme?: DisplayTheme }>` - font-size: 18px; - font-weight: 500; - color: ${(props) => - props.theme.displayTheme === "light" ? "#191919" : "#FFFFFF"}; -`; diff --git a/src/routes/popup/index.tsx b/src/routes/popup/index.tsx index d9a6cd89c..84e0fe085 100644 --- a/src/routes/popup/index.tsx +++ b/src/routes/popup/index.tsx @@ -107,17 +107,13 @@ export default function Home() { useEffect(() => { const checkBits = async () => { - const bits = await checkWalletBits(); + if (!loggedIn) return; - if (bits === null) { - return; - } else { - await trackEvent(EventType.BITS_LENGTH, { mismatch: bits }); - } + const bits = await checkWalletBits(); }; checkBits(); - }, []); + }, [loggedIn]); useEffect(() => { // check whether to show announcement diff --git a/src/routes/popup/passwordPopup.tsx b/src/routes/popup/passwordPopup.tsx index e9648c101..0b0f0d765 100644 --- a/src/routes/popup/passwordPopup.tsx +++ b/src/routes/popup/passwordPopup.tsx @@ -12,7 +12,7 @@ import aoLogo from "url:/assets/ecosystem/ao-token-logo.png"; import styled from "styled-components"; import { CheckIcon, CloseIcon } from "@iconicicons/react"; import { ResetButton } from "~components/dashboard/Reset"; -import { Content, ContentWrapper } from "./announcement"; +import { Content, ContentWrapper } from "~components/modals/Components"; export const PasswordWarningModal = ({ open, diff --git a/src/routes/popup/send/announcement.tsx b/src/routes/popup/send/announcement.tsx index e5f77b6b8..91f8484a0 100644 --- a/src/routes/popup/send/announcement.tsx +++ b/src/routes/popup/send/announcement.tsx @@ -1,14 +1,13 @@ -import { - ButtonV2, - ModalV2, - Spacer, - Text, - type DisplayTheme -} from "@arconnect/components"; +import { ButtonV2, ModalV2, Spacer } from "@arconnect/components"; import { useRef } from "react"; import browser from "webextension-polyfill"; import aoLogo from "url:/assets/ecosystem/ao-token-logo.png"; -import styled from "styled-components"; +import { + HeaderText, + CenterText, + Content, + ContentWrapper +} from "~components/modals/Components"; export const AnnouncementPopup = ({ isOpen, setOpen }) => { const modalRef = useRef(null); @@ -60,48 +59,3 @@ export const AnnouncementPopup = ({ isOpen, setOpen }) => { ); }; - -const Content = styled.div` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - flex: none; - align-self: stretch; - flex-grow: 0; -`; - -const ContentWrapper = styled.div` - height: 100%; - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: space-between; -`; - -const CenterText = styled(Text).attrs({ - noMargin: true -})<{ displayTheme?: DisplayTheme }>` - width: 245px; - text-align: center; - color: ${(props) => - props.theme.displayTheme === "light" ? "#191919" : "#FFFFFF"}; - font-weight: 500; - font-size: 11px; - line-height: 16px; - align-self: stretch; - flex: none; - flex-grow: 0; - - a { - color: rgb(${(props) => props.theme.theme}); - text-decoration: none; - } -`; - -const HeaderText = styled(Text)<{ displayTheme?: DisplayTheme }>` - font-size: 18px; - font-weight: 500; - color: ${(props) => - props.theme.displayTheme === "light" ? "#191919" : "#FFFFFF"}; -`; diff --git a/src/routes/popup/send/confirm.tsx b/src/routes/popup/send/confirm.tsx index 0b745bb7f..a32e82f15 100644 --- a/src/routes/popup/send/confirm.tsx +++ b/src/routes/popup/send/confirm.tsx @@ -45,16 +45,26 @@ import { import { fractionedToBalance } from "~tokens/currency"; import { type Token } from "~tokens/token"; import { useContact } from "~contacts/hooks"; -import { sendAoTransfer, useAo } from "~tokens/aoTokens/ao"; +import { + sendAoTransfer, + sendAoTransferKeystone, + useAo +} from "~tokens/aoTokens/ao"; import { useActiveWallet } from "~wallets/hooks"; import { UR } from "@ngraveio/bc-ur"; -import { decodeSignature, transactionToUR } from "~wallets/hardware/keystone"; +import { + KeystoneSigner, + decodeSignature, + transactionToUR, + type KeystoneInteraction +} from "~wallets/hardware/keystone"; import { useScanner } from "@arconnect/keystone-sdk"; import Progress from "~components/Progress"; import { updateSubscription } from "~subscriptions"; import { SubscriptionStatus } from "~subscriptions/subscription"; import { checkPassword } from "~wallets/auth"; import BigNumber from "bignumber.js"; +import { SignType } from "@keystonehq/bc-ur-registry-arweave"; interface Props { tokenID: string; @@ -507,9 +517,34 @@ export default function Confirm({ tokenID, qty, subscription }: Props) { const [transactionUR, setTransactionUR] = useState(); const [preparedTx, setPreparedTx] = useState>(); + const keystoneInteraction = useMemo(() => { + const keystoneInteraction: KeystoneInteraction = { + display(data) { + setIsLoading(false); + setTransactionUR(data); + } + }; + return keystoneInteraction; + }, [setIsLoading]); + + const keystoneSigner = useMemo(() => { + if (wallet?.type !== "hardware") return null; + const keystoneSigner = new KeystoneSigner( + Buffer.from(Arweave.utils.b64UrlToBuffer(wallet.publicKey)), + wallet.xfp, + isAo ? SignType.DataItem : SignType.Transaction, + keystoneInteraction + ); + return keystoneSigner; + }, [wallet, isAo, keystoneInteraction]); + useEffect(() => { (async () => { - if (!recipient?.address) return; + setIsLoading(true); + if (!recipient?.address) { + setIsLoading(false); + return; + } // get the tx from storage const prepared = await prepare(recipient.address); @@ -522,7 +557,36 @@ export default function Confirm({ tokenID, qty, subscription }: Props) { // check if the current wallet // is a hardware wallet - if (wallet?.type !== "hardware") return; + if (wallet?.type !== "hardware") { + setIsLoading(false); + return; + } + + if (isAo) { + try { + setPreparedTx(prepared); + const res = await sendAoTransferKeystone( + ao, + tokenID, + recipient.address, + fractionedToBalance(amount, token, "AO"), + keystoneSigner + ); + if (res) { + setToast({ + type: "success", + content: browser.i18n.getMessage("sent_tx"), + duration: 2000 + }); + push(`/transaction/${res}`); + setIsLoading(false); + } + return res; + } catch (err) { + console.log("err in ao", err); + throw err; + } + } const arweave = new Arweave(prepared.gateway); const convertedTransaction = arweave.transactions.fromRaw( @@ -531,6 +595,7 @@ export default function Confirm({ tokenID, qty, subscription }: Props) { // get tx UR try { + setIsLoading(false); setTransactionUR( await transactionToUR( convertedTransaction, @@ -548,7 +613,7 @@ export default function Confirm({ tokenID, qty, subscription }: Props) { push("/send/transfer"); } })(); - }, [wallet, recipient]); + }, [wallet, recipient, keystoneSigner, setIsLoading]); // current hardware wallet operation const [hardwareStatus, setHardwareStatus] = useState<"play" | "scan">(); @@ -582,6 +647,11 @@ export default function Confirm({ tokenID, qty, subscription }: Props) { // decode signature const { id, signature } = await decodeSignature(res); + if (isAo) { + keystoneSigner.submitSignature(signature); + return; + } + // set signature transaction.setSignature({ id, diff --git a/src/routes/popup/send/index.tsx b/src/routes/popup/send/index.tsx index 1b801e80d..ea6e621ef 100644 --- a/src/routes/popup/send/index.tsx +++ b/src/routes/popup/send/index.tsx @@ -138,9 +138,6 @@ export default function Send({ id }: Props) { "token" ); - const wallet = useActiveWallet(); - const keystoneError = wallet?.type === "hardware" && isAo; - // tokens const tokens = useTokens(); @@ -428,32 +425,21 @@ export default function Send({ id }: Props) { {AO_NATIVE_TOKEN === tokenID && ( )} - + {/* TOP INPUT */} - {(keystoneError || degraded) && ( + {degraded && (
- {keystoneError ? ( - <> -

{browser.i18n.getMessage("keystone_ao_title")}

- - {browser.i18n.getMessage("keystone_ao_description")} - - - ) : ( - <> -

{browser.i18n.getMessage("ao_degraded")}

- - {browser.i18n - .getMessage("ao_degraded_description") - .replace("
", "")} -
- - )} +

{browser.i18n.getMessage("ao_degraded")}

+ + {browser.i18n + .getMessage("ao_degraded_description") + .replace("
", "")} +
)} diff --git a/src/routes/popup/settings/apps/[url]/permissions.tsx b/src/routes/popup/settings/apps/[url]/permissions.tsx index 2f9ea8304..2145bd6cc 100644 --- a/src/routes/popup/settings/apps/[url]/permissions.tsx +++ b/src/routes/popup/settings/apps/[url]/permissions.tsx @@ -9,7 +9,7 @@ import { useLocation } from "wouter"; export default function AppPermissions({ url }: Props) { // app settings - const app = new Application(url); + const app = new Application(decodeURIComponent(url)); const [settings, updateSettings] = app.hook(); const [, setLocation] = useLocation(); @@ -24,42 +24,53 @@ export default function AppPermissions({ url }: Props) { {browser.i18n.getMessage("permissions")} {Object.keys(permissionData).map( - (permissionName: PermissionType, i) => ( -
- - - updateSettings((val) => { - // toggle permission - if ( - checked && - !val.permissions.includes(permissionName) - ) { - val.permissions.push(permissionName); - } else if (!checked) { - val.permissions = val.permissions.filter( - (p) => p !== permissionName - ); - } + (permissionName: PermissionType, i) => { + let formattedPermissionName = permissionName + .split("_") + .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) + .join(" "); - return val; - }) - } - checked={settings.permissions.includes(permissionName)} - /> -
- {permissionName} - - {browser.i18n.getMessage(permissionData[permissionName])} - -
-
- {i !== Object.keys(permissionData).length - 1 && ( - - )} -
- ) + if (permissionName === "SIGNATURE") { + formattedPermissionName = "Sign Data"; + } + + return ( +
+ + + updateSettings((val) => { + // toggle permission + if ( + checked && + !val.permissions.includes(permissionName) + ) { + val.permissions.push(permissionName); + } else if (!checked) { + val.permissions = val.permissions.filter( + (p) => p !== permissionName + ); + } + + return val; + }) + } + checked={settings.permissions.includes(permissionName)} + /> +
+ {formattedPermissionName} + + {browser.i18n.getMessage(permissionData[permissionName])} + +
+
+ {i !== Object.keys(permissionData).length - 1 && ( + + )} +
+ ); + } )}
diff --git a/src/routes/welcome/generate/backup.tsx b/src/routes/welcome/generate/backup.tsx index 846ea7233..38976d24f 100644 --- a/src/routes/welcome/generate/backup.tsx +++ b/src/routes/welcome/generate/backup.tsx @@ -1,6 +1,6 @@ import { ButtonV2, Spacer, Text } from "@arconnect/components"; import { useLocation, useRoute } from "wouter"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { WalletContext } from "../setup"; import Paragraph from "~components/Paragraph"; import browser from "webextension-polyfill"; @@ -20,7 +20,10 @@ export default function Backup() { const [shown, setShown] = useState(false); // wallet context - const generatedWallet = useContext(WalletContext); + const { wallet: generatedWallet } = useContext(WalletContext); + + // ref to track the latest generated wallet + const walletRef = useRef(generatedWallet); // route const [, params] = useRoute<{ setup: string; page: string }>("/:setup/:page"); @@ -36,6 +39,10 @@ export default function Backup() { setTimeout(() => setCopyDisplay(true), 1050); } + useEffect(() => { + walletRef.current = generatedWallet; + }, [generatedWallet]); + // Segment useEffect(() => { trackPage(PageType.ONBOARD_BACKUP); diff --git a/src/routes/welcome/generate/confirm.tsx b/src/routes/welcome/generate/confirm.tsx index 96a4d46ab..6e2430106 100644 --- a/src/routes/welcome/generate/confirm.tsx +++ b/src/routes/welcome/generate/confirm.tsx @@ -10,7 +10,7 @@ import { PageType, trackPage } from "~utils/analytics"; export default function Confirm() { // wallet context - const generatedWallet = useContext(WalletContext); + const { wallet: generatedWallet } = useContext(WalletContext); // toasts const { setToast } = useToasts(); diff --git a/src/routes/welcome/generate/done.tsx b/src/routes/welcome/generate/done.tsx index c9bebde0b..89f2a93e1 100644 --- a/src/routes/welcome/generate/done.tsx +++ b/src/routes/welcome/generate/done.tsx @@ -5,7 +5,7 @@ import { formatAddress } from "~utils/format"; import Paragraph from "~components/Paragraph"; import browser from "webextension-polyfill"; import { addWallet } from "~wallets"; -import { useContext, useEffect } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { EventType, PageType, @@ -22,7 +22,14 @@ import { addExpiration } from "~wallets/auth"; export default function Done() { // wallet context - const wallet = useContext(WalletContext); + const { wallet } = useContext(WalletContext); + const walletRef = useRef(wallet); + + // loading + const [loading, setLoading] = useState(false); + + // wallet generation taking longer + const [showLongWaitMessage, setShowLongWaitMessage] = useState(false); const [, setLocation] = useLocation(); @@ -36,13 +43,32 @@ export default function Done() { // add generated wallet async function done() { + if (loading) return; + + const startTime = Date.now(); + + setLoading(true); // add wallet let nickname: string; - if (!wallet.address || !wallet.jwk) return; + if (!walletRef.current.address || !walletRef.current.jwk) { + await new Promise((resolve) => { + const checkState = setInterval(() => { + if (walletRef.current.jwk) { + clearInterval(checkState); + resolve(null); + } + if (!showLongWaitMessage) { + setShowLongWaitMessage(Date.now() - startTime > 10000); + } + }, 1000); + }); + } try { - const ansProfile = (await getAnsProfile(wallet.address)) as AnsUser; + const ansProfile = (await getAnsProfile( + walletRef.current.address + )) as AnsUser; if (ansProfile) { nickname = ansProfile.currentLabel; @@ -51,7 +77,9 @@ export default function Done() { // add the wallet await addWallet( - nickname ? { nickname, wallet: wallet.jwk } : wallet.jwk, + nickname + ? { nickname, wallet: walletRef.current.jwk } + : walletRef.current.jwk, password ); @@ -68,6 +96,12 @@ export default function Done() { // redirect to getting started pages setLocation("/getting-started/1"); + + setShowLongWaitMessage(false); + setLoading(false); + + // reset before unload + window.onbeforeunload = null; } useEffect(() => { @@ -90,6 +124,10 @@ export default function Done() { getLocation(); }, []); + useEffect(() => { + walletRef.current = wallet; + }, [wallet]); + // Segment useEffect(() => { trackPage(PageType.ONBOARD_COMPLETE); @@ -113,9 +151,14 @@ export default function Done() { {browser.i18n.getMessage("analytics_title")} - + {browser.i18n.getMessage("done")} + {loading && showLongWaitMessage && ( + + {browser.i18n.getMessage("longer_than_usual")} + + )} ); } diff --git a/src/routes/welcome/gettingStarted.tsx b/src/routes/welcome/gettingStarted.tsx index cd74a1541..a79d7a20f 100644 --- a/src/routes/welcome/gettingStarted.tsx +++ b/src/routes/welcome/gettingStarted.tsx @@ -38,6 +38,8 @@ export default function GettingStarted({ page }) { if (pageNum < 5) { setLocation(`/getting-started/${pageNum}`); } else { + // reset before unload + window.onbeforeunload = null; window.top.close(); } }; diff --git a/src/routes/welcome/load/done.tsx b/src/routes/welcome/load/done.tsx index 329c7d863..fdbc45368 100644 --- a/src/routes/welcome/load/done.tsx +++ b/src/routes/welcome/load/done.tsx @@ -25,6 +25,9 @@ export default function Done() { await setAnalytics(false); } + // reset before unload + window.onbeforeunload = null; + // redirect to getting started pages setLocation("/getting-started/1"); } diff --git a/src/routes/welcome/load/password.tsx b/src/routes/welcome/load/password.tsx index e961db789..16e6b9bb5 100644 --- a/src/routes/welcome/load/password.tsx +++ b/src/routes/welcome/load/password.tsx @@ -70,16 +70,12 @@ export default function Password() { setLocation(`/${params.setup}/${Number(params.page) + 1}`); } - // password valid - const validPassword = useMemo( - () => checkPasswordValid(passwordInput.state), - [passwordInput] - ); - // passwords match const matches = useMemo( - () => passwordInput.state === validPasswordInput.state && validPassword, - [passwordInput, validPasswordInput, validPassword] + () => + passwordInput.state === validPasswordInput.state && + passwordInput.state?.length >= 5, + [passwordInput, validPasswordInput] ); // Segment diff --git a/src/routes/welcome/load/wallets.tsx b/src/routes/welcome/load/wallets.tsx index 1bca81ea8..2b5791014 100644 --- a/src/routes/welcome/load/wallets.tsx +++ b/src/routes/welcome/load/wallets.tsx @@ -1,6 +1,11 @@ import { isValidMnemonic, jwkFromMnemonic } from "~wallets/generator"; import { ExtensionStorage, OLD_STORAGE_NAME } from "~utils/storage"; -import { addWallet, getWallets, setActiveWallet } from "~wallets"; +import { + addWallet, + getWalletKeyLength, + getWallets, + setActiveWallet +} from "~wallets"; import type { KeystoneAccount } from "~wallets/hardware/keystone"; import type { JWKInterface } from "arweave/web/lib/wallet"; import { useContext, useEffect, useMemo, useState } from "react"; @@ -23,11 +28,15 @@ import Paragraph from "~components/Paragraph"; import browser from "webextension-polyfill"; import styled from "styled-components"; import { addExpiration } from "~wallets/auth"; +import { WalletKeySizeErrorModal } from "~components/modals/WalletKeySizeErrorModal"; export default function Wallets() { // password context const { password } = useContext(PasswordContext); + // wallet generation taking longer + const [showLongWaitMessage, setShowLongWaitMessage] = useState(false); + // migration available const [oldState] = useStorage({ key: OLD_STORAGE_NAME, @@ -37,6 +46,9 @@ export default function Wallets() { // migration modal const migrationModal = useModal(); + // wallet size error modal + const walletModal = useModal(); + // wallets to migrate const [walletsToMigrate, setWalletsToMigrate] = useState([]); @@ -101,6 +113,7 @@ export default function Wallets() { const finishUp = () => { // reset before unload window.onbeforeunload = null; + setShowLongWaitMessage(false); setLoading(false); }; @@ -126,11 +139,30 @@ export default function Wallets() { if (loadedWallet) { // load jwk from seedphrase input state - const jwk = + const startTime = Date.now(); + + let jwk = typeof loadedWallet === "string" ? await jwkFromMnemonic(loadedWallet) : loadedWallet; + let { actualLength, expectedLength } = await getWalletKeyLength(jwk); + if (expectedLength !== actualLength) { + if (typeof loadedWallet !== "string") { + walletModal.setOpen(true); + finishUp(); + return; + } else { + while (expectedLength !== actualLength) { + setShowLongWaitMessage(Date.now() - startTime > 30000); + jwk = await jwkFromMnemonic(loadedWallet); + ({ actualLength, expectedLength } = await getWalletKeyLength( + jwk + )); + } + } + } + // add wallet await addWallet(jwk, password); await addExpiration(); @@ -198,6 +230,11 @@ export default function Wallets() { {browser.i18n.getMessage("next")} + {loading && showLongWaitMessage && ( + + {browser.i18n.getMessage("longer_than_usual")} + + )} + setLocation(`/`)} /> ); } diff --git a/src/routes/welcome/setup.tsx b/src/routes/welcome/setup.tsx index 63dc56ebb..6162d6301 100644 --- a/src/routes/welcome/setup.tsx +++ b/src/routes/welcome/setup.tsx @@ -26,6 +26,7 @@ import LoadDone from "./load/done"; import Theme from "./load/theme"; import { defaultGateway } from "~gateways/gateway"; import Pagination, { Status } from "~components/Pagination"; +import { getWalletKeyLength } from "~wallets"; /** Wallet generate pages */ const generatePages = [ @@ -92,54 +93,65 @@ export default function Setup({ setupMode, page }: Props) { const { setToast } = useToasts(); // generate wallet in the background - const [generatedWallet, setGeneratedWallet] = useState( - {} - ); + const [generatedWallet, setGeneratedWallet] = useState({}); const navigate = () => { setLocation(`/${params.setup}/${page - 1}`); }; - useEffect(() => { - (async () => { - // only generate wallet if the - // setup mode is wallet generation - if (!isGenerateWallet || generatedWallet.address) return; + async function generateWallet() { + // only generate wallet if the + // setup mode is wallet generation + if (!isGenerateWallet || generatedWallet.address) return; - // prevent user from closing the window - // while ArConnect is generating a wallet - window.onbeforeunload = () => - browser.i18n.getMessage("close_tab_generate_wallet_message"); + // prevent user from closing the window + // while ArConnect is generating a wallet + window.onbeforeunload = () => + browser.i18n.getMessage("close_tab_generate_wallet_message"); - try { - const arweave = new Arweave(defaultGateway); + try { + const arweave = new Arweave(defaultGateway); - // generate seed - const seed = await bip39.generateMnemonic(); + // generate seed + const seed = await bip39.generateMnemonic(); - setGeneratedWallet({ mnemonic: seed }); + setGeneratedWallet({ mnemonic: seed }); - // generate wallet from seedphrase - const generatedKeyfile = await jwkFromMnemonic(seed); + // generate wallet from seedphrase + let generatedKeyfile = await jwkFromMnemonic(seed); - setGeneratedWallet((val) => ({ ...val, jwk: generatedKeyfile })); + let { actualLength, expectedLength } = await getWalletKeyLength( + generatedKeyfile + ); + while (expectedLength !== actualLength) { + generatedKeyfile = await jwkFromMnemonic(seed); + ({ actualLength, expectedLength } = await getWalletKeyLength( + generatedKeyfile + )); + } - // get address - const address = await arweave.wallets.jwkToAddress(generatedKeyfile); + setGeneratedWallet((val) => ({ ...val, jwk: generatedKeyfile })); - setGeneratedWallet((val) => ({ ...val, address })); - } catch (e) { - console.log("Error generating wallet", e); - setToast({ - type: "error", - content: browser.i18n.getMessage("error_generating_wallet"), - duration: 2300 - }); - } + // get address + const address = await arweave.wallets.jwkToAddress(generatedKeyfile); + + setGeneratedWallet((val) => ({ ...val, address })); + + return generatedWallet; + } catch (e) { + console.log("Error generating wallet", e); + setToast({ + type: "error", + content: browser.i18n.getMessage("error_generating_wallet"), + duration: 2300 + }); + } - // reset before unload - window.onbeforeunload = null; - })(); + return {}; + } + + useEffect(() => { + generateWallet(); }, [isGenerateWallet]); // animate content sice @@ -189,7 +201,9 @@ export default function Setup({ setupMode, page }: Props) { - + @@ -290,9 +304,17 @@ export const PasswordContext = createContext({ password: "" }); -export const WalletContext = createContext({}); +export const WalletContext = createContext({ + wallet: {}, + generateWallet: (retry?: boolean) => Promise.resolve({}) +}); interface WalletContextValue { + wallet: GeneratedWallet; + generateWallet: (retry?: boolean) => Promise; +} + +interface GeneratedWallet { address?: string; mnemonic?: string; jwk?: JWKInterface; diff --git a/src/tabs/auth.tsx b/src/tabs/auth.tsx index 0d416d350..54dd641e5 100644 --- a/src/tabs/auth.tsx +++ b/src/tabs/auth.tsx @@ -16,7 +16,7 @@ import SignDataItem from "~routes/auth/signDataItem"; import Token from "~routes/auth/token"; import Sign from "~routes/auth/sign"; import Subscription from "~routes/auth/subscription"; -import SignMessage from "~routes/auth/signMessage"; +import SignKeystone from "~routes/auth/signKeystone"; export default function Auth() { const theme = useTheme(); @@ -36,7 +36,7 @@ export default function Auth() { - + diff --git a/src/tokens/aoTokens/ao.ts b/src/tokens/aoTokens/ao.ts index 184077568..53e7b50ae 100644 --- a/src/tokens/aoTokens/ao.ts +++ b/src/tokens/aoTokens/ao.ts @@ -14,6 +14,7 @@ import { AO_NATIVE_TOKEN_BALANCE_MIRROR } from "~utils/ao_import"; import type { Alarms } from "webextension-polyfill"; +import type { KeystoneSigner } from "~wallets/hardware/keystone"; export type AoInstance = ReturnType; @@ -101,7 +102,7 @@ export function useAoTokens( () => tokens.map((token) => ({ ...token, - balance: balances.find((bal) => bal.id === token.id)?.balance ?? null + balance: balances.find((bal) => bal.id === token.id)?.balance })), [tokens, balances] ); @@ -486,6 +487,54 @@ export const aoTokensCacheHandler = async (alarmInfo?: Alarms.Alarm) => { await ExtensionStorage.set("ao_tokens", updatedTokens); }; +export const sendAoTransferKeystone = async ( + ao: AoInstance, + process: string, + recipient: string, + amount: string, + keystoneSigner: KeystoneSigner +) => { + try { + const dataItemSigner = async ({ + data, + tags = [], + target, + anchor + }: { + data: any; + tags?: { name: string; value: string }[]; + target?: string; + anchor?: string; + }): Promise<{ id: string; raw: ArrayBuffer }> => { + const signer = keystoneSigner; + const dataItem = createData(data, signer, { tags, target, anchor }); + const serial = dataItem.getRaw(); + const signature = await signer.sign(serial); + dataItem.setSignature(Buffer.from(signature)); + + return { + id: dataItem.id, + raw: dataItem.getRaw() + }; + }; + const transferID = await ao.message({ + process, + signer: dataItemSigner, + tags: [ + { name: "Action", value: "Transfer" }, + { + name: "Recipient", + value: recipient + }, + { name: "Quantity", value: amount } + ] + }); + return transferID; + } catch (err) { + console.log("err", err); + } +}; + export interface TokenInfo { Name?: string; Ticker?: string; diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 544b84514..db2f82099 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -1,7 +1,12 @@ import { getSetting } from "~settings"; import { ExtensionStorage, TempTransactionStorage } from "./storage"; import { AnalyticsBrowser } from "@segment/analytics-next"; -import { getWallets, getActiveKeyfile, getActiveAddress } from "~wallets"; +import { + getWallets, + getActiveKeyfile, + getActiveAddress, + getWalletKeyLength +} from "~wallets"; import Arweave from "arweave"; import { defaultGateway } from "~gateways/gateway"; import { v4 as uuid } from "uuid"; @@ -9,7 +14,6 @@ import browser, { type Alarms } from "webextension-polyfill"; import BigNumber from "bignumber.js"; import axios from "axios"; import { isLocalWallet } from "./assertions"; -import { ArweaveSigner } from "arbundles"; import { freeDecryptedWallet } from "~wallets/encryption"; const PUBLIC_SEGMENT_WRITEKEY = "J97E4cvSZqmpeEdiUQNC2IxS1Kw4Cwxm"; @@ -234,6 +238,11 @@ const setToStartOfNextMonth = (currentDate: Date): Date => { return newDate; }; +export interface WalletBitsCheck { + checked: boolean; + mismatch: boolean; +} + /** * Checks the bit length the active Arweave wallet. * @@ -254,11 +263,16 @@ export const checkWalletBits = async (): Promise => { if (!activeAddress) { return null; } - const hasBeenTracked = await ExtensionStorage.get( - `bits_check_${activeAddress}` + + const storageKey = `bits_check_${activeAddress}`; + + const hasBeenTracked = await ExtensionStorage.get( + storageKey ); - if (hasBeenTracked) { + if (typeof hasBeenTracked === "boolean") { + await ExtensionStorage.remove(storageKey); + } else if (hasBeenTracked && hasBeenTracked.checked) { return null; } @@ -268,16 +282,20 @@ export const checkWalletBits = async (): Promise => { }); isLocalWallet(decryptedWallet); - const signer = new ArweaveSigner(decryptedWallet.keyfile); - const owner = signer.publicKey; - const expectedLength = signer.ownerLength; - const actualLength = owner.byteLength; + const { actualLength, expectedLength } = await getWalletKeyLength( + decryptedWallet.keyfile + ); + + freeDecryptedWallet(decryptedWallet.keyfile); const lengthsMatch = expectedLength === actualLength; - freeDecryptedWallet(decryptedWallet.keyfile); + await ExtensionStorage.set(`bits_check_${activeAddress}`, { + checked: true, + mismatch: !lengthsMatch + }); - await ExtensionStorage.set(`bits_check_${activeAddress}`, true); + await trackEvent(EventType.BITS_LENGTH, { mismatch: !lengthsMatch }); return !lengthsMatch; } catch (error) { diff --git a/src/utils/data_item.ts b/src/utils/data_item.ts new file mode 100644 index 000000000..dc707bcdf --- /dev/null +++ b/src/utils/data_item.ts @@ -0,0 +1,39 @@ +import { + ArweaveSigner, + DataItem, + Signer, + createData, + type DataItemCreateOptions +} from "arbundles"; + +export interface SignerConfig { + signatureType: number; + signatureLength: number; + ownerLength: number; + publicKey: Buffer; +} + +class DummySigner implements Signer { + publicKey: Buffer; + signatureType: number; + signatureLength: number; + ownerLength: number; + pem?: string | Buffer; + constructor(signerConfig: SignerConfig) { + this.publicKey = signerConfig.publicKey; + this.signatureLength = signerConfig.signatureLength; + this.signatureType = signerConfig.signatureType; + this.ownerLength = signerConfig.ownerLength; + } + sign(message: Uint8Array, _opts?: any): Uint8Array | Promise { + throw new Error("Method not implemented."); + } +} + +export const createDataItem = ( + binary: Uint8Array, + signerConfig: SignerConfig, + options: DataItemCreateOptions +): DataItem => { + return createData(binary, new DummySigner(signerConfig), options); +}; diff --git a/src/wallets/hardware/keystone.ts b/src/wallets/hardware/keystone.ts index 4458c773a..471a7e713 100644 --- a/src/wallets/hardware/keystone.ts +++ b/src/wallets/hardware/keystone.ts @@ -10,6 +10,51 @@ import type { UR } from "@ngraveio/bc-ur"; import { v4 as uuid } from "uuid"; import Arweave from "arweave"; import { defaultGateway } from "~gateways/gateway"; +import { Signer } from "arbundles"; +import { EventEmitter } from "events"; + +export interface KeystoneInteraction { + display(data: UR); +} + +export class KeystoneSigner implements Signer { + readonly signatureType: number = 1; + readonly ownerLength: number = 512; + readonly signatureLength: number = 512; + #_event = new EventEmitter(); + public get publicKey(): Buffer { + return this._publicKey; + } + + constructor( + private _publicKey: Buffer, + private mfp: string, + private signType: SignType, + private interaction: KeystoneInteraction, + private options: SignatureOptions = { saltLength: 32 } + ) {} + sign(message: Uint8Array, _opts?: any): Promise { + const data = Buffer.from(message); + const signRequest = ArweaveSignRequest.constructArweaveRequest( + data, + this.mfp, + this.signType, + this.options.saltLength + ); + return new Promise(async (resolve) => { + const ur = signRequest.toUR(); + this.interaction.display(ur); + this.#_event.once("submit-signature", (signature) => { + resolve(signature); + }); + }); + } + + submitSignature(signature: string) { + const signatureBytes = Buffer.from(signature, "base64"); + this.#_event.emit("submit-signature", signatureBytes); + } +} /** * Decode cbor result from a keystone QR code @@ -98,6 +143,24 @@ export async function messageToUR( return signRequest.toUR(); } +export async function dataItemToUR( + data: Uint8Array, + xfp: string, + options: SignatureOptions = { saltLength: 32 } +) { + const messageBuff = Buffer.from(data); + + // construct request + const signRequest = ArweaveSignRequest.constructArweaveRequest( + messageBuff, + xfp, + SignType.DataItem, + options.saltLength + ); + + return signRequest.toUR(); +} + /** * Decode cbor result from a keystone QR code * with an Arweave transaction diff --git a/src/wallets/index.ts b/src/wallets/index.ts index d0ec04541..abcd442c9 100644 --- a/src/wallets/index.ts +++ b/src/wallets/index.ts @@ -20,6 +20,7 @@ import { getDecryptionKey, setDecryptionKey } from "./auth"; +import { ArweaveSigner } from "arbundles"; /** * Locally stored wallet @@ -395,3 +396,10 @@ export async function syncLabels(alarmInfo?: Alarms.Alarm) { })) ); } + +export async function getWalletKeyLength(jwk: JWKInterface) { + const signer = new ArweaveSigner(jwk); + const expectedLength = signer.ownerLength; + const actualLength = signer.publicKey.byteLength; + return { actualLength, expectedLength }; +}