diff --git a/.gitignore b/.gitignore index 03f510d4..f3e80fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ **/packages/dapp/lib .DS_Store test*.png -**/coverage \ No newline at end of file +**/coverage +**/test-project/results \ No newline at end of file diff --git a/README.md b/README.md index bb45ac0a..a1df1756 100644 --- a/README.md +++ b/README.md @@ -10,39 +10,20 @@ _This is the preferred solution for end-users, updates will be automatically ins Developers working with dApps may also install directly from the release package, or by downloading the project and building it. -## 1.5.0 Release +## 1.6.0 Release -This release features the new v2 Signing functionality for dApp developers. Designed to meet the upcoming Algorand Foundation standards it will greatly simplifying complex signing scenarios like Multisignature and Atomic. Encoding is standardized and new helper functions simplify the complexity. +### Functionality +Added ability to use a Ledger device to add public addresses into AlgoSigner and for signing pay and asset transactions. For information about it how to use you can refer to the new readme. -[v2 AlgoSigner methods](docs/dApp-integration.md) +- [Ledger Readme](docs/ledger.md) -The v1 Atomic transaction functionality will start warning extension users in the next major release and be deprecated entirely in two releases. - -### 1.5.1 Patch - -Improved Application Transaction workflow: - -- `AppOnComplete` now defaults to 0. -- Improved readability on `AppArgs` during signing. The base64-encoded version will be shown. - -### 1.5.2 Patch - -v2 Signing Improvements: - -- Add informational tooltip on Multisig transactions. -- Correctly fetch Asset Unit Name and Decimals Display Amount for AxferTxs. -- `AppIndex` now defaults to 0. -- Improved readability on `AppApprovalProgram` && `AppClearProgram` during signing. The base64-encoded version will be shown. - -Other Usability Improvements: - -- Enabled the option to remove the last selected word when confirming the Mnemonic during Account Creation. -- Fixed bug that prevented Mnemonics with repeated words to be correctly confirmed during Account Creation. -- Added scrollbar to transaction overview section of all signing methods. This helps with better visualization of transactions with many fields, such as Application Transactions. -- UX Improvements for the Network Config screen: - - Added links to the documentation regarding custom networks. - - More responsive tooltip explaining the Network Headers field. - - UI Layout improvements. +### Various Fixes +- UI general improvements +- Improved validation handling in v2 signing +- Modified asset verification to use algod +- Fixed issue with 404's from asset lookups +- Modify transactions without an amount to default to 0 +- Fixed issue with creating assets that have zero decimals ## New Users diff --git a/docs/ledger.md b/docs/ledger.md new file mode 100644 index 00000000..22074fc7 --- /dev/null +++ b/docs/ledger.md @@ -0,0 +1,66 @@ +# AlgoSigner Ledger Periphreral Hardware Actions + +## Overview:
+Allow the AlgoSigner extension to work with the primary Ledger device account via HID for transaction signing on pay and asset transactions. This includes transactions originating in the extension and from DApps. + +It supports adding the account to your normal accounts tracking in the extension to supply both methods with the ability to sign. + +## Common Errors:
+During any action the ledger device may prompt to connect. If the device is not connected then the screen may be blank. + +

+ +If the device is connected, but the app is not open you may recieve a not supported error. + +

+ +## Adding an address:
+Open AlgoSigner and go to the default account overview. +With the desired network selected click the "Add Account" button at the bottom of the page. +Then choose "Link hardware device account" from the options. + +

+ +The page will inform you that it must open AlgoSigner in a tab to connect to the device. Press "Continue". +A new tab should open in the window where AlgoSigner is active. + +

+ +Populate the Account name field with the value name of the new account. +Verify the device is connected, unlocked and that the Algorand application is installed and active then press "Add Address". +This will get the default account for the connected device. +Once this address is obtained it will be added to your AlgoSigner account and you are free to close the tab. + +

+ +## Signing with the extension:
+First you must add an address to the appropriate network. +With the proper network added you can select the account from the list of accounts. +This will show you the normal account information and give you the option to "Send" as normal. +After entering your transaction information and choosing to send you will be prompted for your password. +Once your password is deemed correct and the address is internally determined to be a hardware account a new tab will automatically open. Here you will be presented with a similar transaction view to one seen from DApps in the extension. + +

+ +You can review here, but you should verify ALL data thoroughly from the Ledger device. +Verify the device is connected, unlocked and that the Algorand application is installed and active then press "Send to device". +After signing you will be presented with the transaction id and the transaction will be sent to the network. +You are then free to close the tab. + +

+ +## Signing with a DApp:
+First the user must add an address to the appropriate network. +A transanction can be then be sent with the network and matching Ledger address. +The user will be presented with the normal sign page. +Once the password is deemed correct and the address is internally determined to be a hardware account a new tab will automatically open. +The new tab will show the same information the user just seen on the normal sign screen. +They are free to review here, but should verify ALL data thoroughly from the Ledger device. +Verify the device is connected, unlocked and that the Algorand application is installed and active then press "Send to device". +After signing the blob will be sent back to the DApp tab in the same way that a transaction sign normally does from AlgoSigner. + +

+ +The blob will also be presented to the user indicating they are free to close the tab. + +

\ No newline at end of file diff --git a/docs/ledger_images/connect0.png b/docs/ledger_images/connect0.png new file mode 100644 index 00000000..e28bd064 Binary files /dev/null and b/docs/ledger_images/connect0.png differ diff --git a/docs/ledger_images/connect1.png b/docs/ledger_images/connect1.png new file mode 100644 index 00000000..92140361 Binary files /dev/null and b/docs/ledger_images/connect1.png differ diff --git a/docs/ledger_images/connect2.png b/docs/ledger_images/connect2.png new file mode 100644 index 00000000..7c51ad56 Binary files /dev/null and b/docs/ledger_images/connect2.png differ diff --git a/docs/ledger_images/connect3.png b/docs/ledger_images/connect3.png new file mode 100644 index 00000000..6c3b8ee5 Binary files /dev/null and b/docs/ledger_images/connect3.png differ diff --git a/docs/ledger_images/connect_err0.png b/docs/ledger_images/connect_err0.png new file mode 100644 index 00000000..3f043410 Binary files /dev/null and b/docs/ledger_images/connect_err0.png differ diff --git a/docs/ledger_images/connect_err1.png b/docs/ledger_images/connect_err1.png new file mode 100644 index 00000000..dca7df08 Binary files /dev/null and b/docs/ledger_images/connect_err1.png differ diff --git a/docs/ledger_images/sign_dapp0.png b/docs/ledger_images/sign_dapp0.png new file mode 100644 index 00000000..6bba5925 Binary files /dev/null and b/docs/ledger_images/sign_dapp0.png differ diff --git a/docs/ledger_images/sign_dapp1.png b/docs/ledger_images/sign_dapp1.png new file mode 100644 index 00000000..3d5d1e88 Binary files /dev/null and b/docs/ledger_images/sign_dapp1.png differ diff --git a/docs/ledger_images/sign_extension0.png b/docs/ledger_images/sign_extension0.png new file mode 100644 index 00000000..1e51fcd5 Binary files /dev/null and b/docs/ledger_images/sign_extension0.png differ diff --git a/docs/ledger_images/sign_extension1.png b/docs/ledger_images/sign_extension1.png new file mode 100644 index 00000000..d0044ad4 Binary files /dev/null and b/docs/ledger_images/sign_extension1.png differ diff --git a/package.json b/package.json index 90b054e7..afeb0b48 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { "name": "algosigner", - "version": "1.5.2", + "version": "1.6.0", "author": "https://developer.purestake.io", "description": "Sign Algorand transactions in your browser with PureStake.", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "keywords": [ "Algorand", "PureStake" @@ -10,6 +12,8 @@ "scripts": { "install:extension": "(cd ./packages/common && npm install); (cd ./packages/crypto && npm install); (cd ./packages/storage && npm install); (cd ./packages/ui && npm install); (cd ./packages/extension && npm install); (cd ./packages/dapp && npm install);", "install:test": "(cd ./packages/test-project && npm install);", + "removelocks": "rm -rf ./package-lock.json && find -path \"./packages/*\" -name \"package-lock.json\" -not -path \"*/node_modules/*\" -exec rm -rf {} \\;", + "update": "npm update && find -maxdepth 2 -path \"./packages/*\" -exec npm update {} \\;", "build": "(cd ./packages/common && npm run build); (cd ./packages/crypto && npm run build); (cd ./packages/dapp && npm run build); (cd ./packages/storage && npm run build); (cd ./packages/ui && npm run build); (cd ./packages/extension && npm run build);", "build:ui": "cd ./packages/ui && npm run build && cp -r ./dist/* ../../dist/", "build:extension": "cd ./packages/extension && npm run build && cp -r ./dist/* ../../dist/", @@ -18,7 +22,8 @@ "prebuild": "rm -rf ./dist/*", "postbuild": "npm run copy", "postinstall": "npm run install:extension && npm run install:test", - "coveragetest": "cd ./packages/test-project && npm run coveragetest", + "rebuild": "npm run clean && npm run removelocks && npm install && npm run update && npm run build", + "coveragetest": "(cd ./packages/test-project && npm run coveragetest)", "test": "npm run test:unit && npm run test:e2e", "test:unit": "(cd ./packages/crypto && npm run test) && (cd ./packages/extension && npm run test) && (cd ./packages/ui && npm run test) && (cd ./packages/dapp && npm run test) && (cd ./packages/common && npm run test)", "test:e2e": "(cd ./packages/test-project && npm run test)", @@ -35,7 +40,6 @@ }, "husky": { "hooks": { - "pre-commit": "lint-staged", "pre-push": "npm run test:unit" } }, diff --git a/packages/common/package.json b/packages/common/package.json index 720d3ab6..56496684 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,13 +1,15 @@ { "name": "@algosigner/common", - "version": "1.5.2", + "version": "1.6.0", "author": "https://developer.purestake.io", "description": "Common library functions for AlgoSigner.", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "devDependencies": { "@types/jest": "^26.0.14", - "jest": "^26.4.1", + "jest": "27.0.0", "jest-webextension-mock": "^3.6.1", - "ts-jest": "^26.3.0", + "ts-jest": "^27.0.0", "ts-loader": "^8.0.3", "typescript": "^3.7.5" }, diff --git a/packages/common/src/interfaces/acfg_create.ts b/packages/common/src/interfaces/acfg_create.ts index cc66ee27..8b80b7f6 100644 --- a/packages/common/src/interfaces/acfg_create.ts +++ b/packages/common/src/interfaces/acfg_create.ts @@ -8,7 +8,7 @@ import { IBaseTx } from './baseTx'; export interface IAssetCreateTx extends IBaseTx { type: string, //"acfg" assetTotal: number, //uint64 "t" The total number of base units of the asset to create. This number cannot be changed. - assetDecimals: number, //uint32 "dc" The number of digits to use after the decimal point when displaying the asset. If 0, the asset is not divisible. If 1, the base unit of the asset is in tenths. If 2, the base unit of the asset is in hundredths. + assetDecimals?: number, //uint32 "dc" The number of digits to use after the decimal point when displaying the asset. If 0, the asset is not divisible. If 1, the base unit of the asset is in tenths. If 2, the base unit of the asset is in hundredths. assetDefaultFrozen?: boolean, //bool "df" True to freeze holdings for this asset by default. assetUnitName?: string, //string "un" The name of a unit of this asset. Supplied on creation. Example: USDT assetName?: string, //string "an" The name of the asset. Supplied on creation. Example: Tether diff --git a/packages/common/src/logging.ts b/packages/common/src/logging.ts index ad8699b8..e0d9ac58 100644 --- a/packages/common/src/logging.ts +++ b/packages/common/src/logging.ts @@ -1,8 +1,26 @@ +/* eslint-disable no-unused-vars */ /// // Central error handling. /// -export class Logging { - log(error: string): void { +export enum LogLevel { + None = 0, + Normal = 1, + Debug = 2, +} + +class Logging { + // Raise to Debug to show additional messages, or lower to None to ignore all + logThreshold = LogLevel.Normal; + + log(error: string, level?: LogLevel): void { + // Set the default to Normal for backwards compatibility + level = level || LogLevel.Normal; + + // If we area below the current threshold then return + if (this.logThreshold === LogLevel.None || this.logThreshold < level) { + return; + } + // TODO: BC - How should we handle errors? // Should likely use a logging packackage here to send errors to the user or backend logging. try { diff --git a/packages/common/src/messaging/types.ts b/packages/common/src/messaging/types.ts index e757ee83..c4e8ea55 100644 --- a/packages/common/src/messaging/types.ts +++ b/packages/common/src/messaging/types.ts @@ -39,6 +39,13 @@ export enum JsonRpcMethod { SaveNetwork = 'save-network', DeleteNetwork = 'delete-network', GetLedgers = 'get-ledgers', + + // Ledger Device Methods + LedgerSaveAccount = 'ledger-save-account', + LedgerLinkAddress = 'ledger-link-address', + LedgerGetSessionTxn = 'ledger-get-session-txn', + LedgerSendTxnResponse = 'ledger-send-txn-response', + LedgerSignTransaction = 'ledger-sign-transaction', } export type JsonPayload = { diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index bfea345a..72bc7b3b 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1,4 +1,5 @@ import { extensionBrowser } from './chrome'; +import { getBaseSupportedLedgers } from './types/ledgers'; export function isFromExtension(origin: string): boolean { const s = origin.split('://'); @@ -18,3 +19,12 @@ export function removeEmptyFields(obj: { [index: string]: any }): any { }); return obj; } + +/** + * Check if a ledger belongs to our base supported ledgers (e.g: links to GoalSeeker) + * @param ledger + * @returns boolean + */ +export function isLedgerBaseSupported(ledger: string): boolean { + return getBaseSupportedLedgers().map((l) => l.name.toLowerCase()).includes(ledger.toLowerCase()); +} \ No newline at end of file diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 29591881..f65593f2 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -1,17 +1,17 @@ { "name": "algosigner-crypto", - "version": "1.5.2", + "version": "1.6.0", "author": "https://developer.purestake.io", "description": "Cryptographic wrapper for saving and retrieving extention information in Algosigner.", "repository": { "type": "git", "url": "https://github.com/PureStake/algosigner/packages/crypto.git" }, - "license": "", + "license": "MIT", "devDependencies": { "@types/jest": "^26.0.14", - "jest": "^26.4.2", - "ts-jest": "^26.3.0", + "jest": "27.0.0", + "ts-jest": "^27.0.0", "ts-loader": "^7.0.5", "typescript": "^3.9.7", "webpack": "^4.44.1", diff --git a/packages/crypto/webpack.config.js b/packages/crypto/webpack.config.js index f2e10172..730e59c1 100644 --- a/packages/crypto/webpack.config.js +++ b/packages/crypto/webpack.config.js @@ -21,7 +21,7 @@ module.exports = { //devtool: 'source-map', optimization: { minimize: false, - namedModules: true + moduleIds: "named" }, module: { rules: [ diff --git a/packages/dapp/package.json b/packages/dapp/package.json index b020d479..48013be1 100644 --- a/packages/dapp/package.json +++ b/packages/dapp/package.json @@ -1,7 +1,9 @@ { "name": "@algosigner/dapp", - "version": "1.5.2", + "version": "1.6.0", "author": "https://developer.purestake.io", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "description": "Sample DAPP for use with AlgoSigner.", "scripts": { "build": "npm run clean && webpack", @@ -9,14 +11,14 @@ "test": "jest" }, "devDependencies": { - "jest": "^26.4.2", + "jest": "27.0.0", "ts-loader": "^7.0.5", "typescript": "^3.9.7", "webpack": "^4.44.1", - "webpack-cli": "^3.3.12" + "webpack-cli": "^3.3.11" }, "dependencies": { "@types/jest": "^26.0.14", - "ts-jest": "^26.3.0" + "ts-jest": "^27.0.0" } } diff --git a/packages/dapp/src/fn/router.ts b/packages/dapp/src/fn/router.ts index 13f28429..392eb0ea 100644 --- a/packages/dapp/src/fn/router.ts +++ b/packages/dapp/src/fn/router.ts @@ -9,12 +9,15 @@ // custom handler for different message types, etc.. import { MessageApi } from '../messaging/api'; import { Task } from './task'; +import { MessageSource } from '@algosigner/common/messaging/types'; +import logging from '@algosigner/common/logging'; export class Router { handler: Function; constructor() { this.handler = this.default; window.addEventListener('message', (event) => { + logging.log(`Router DApp message event: ${JSON.stringify(event)}`, 2); const d = event.data; try { @@ -26,8 +29,8 @@ export class Router { } } else { if (Object.prototype.toString.call(d) === '[object Object]' && 'source' in d) { - if (d.source == 'extension') { - d.source = 'router'; + if (d.source == MessageSource.Extension) { + d.source = MessageSource.Router; d.origin = window.location.origin; this.handler(d); } diff --git a/packages/dapp/webpack.config.js b/packages/dapp/webpack.config.js index 5f218953..2cf7bef5 100644 --- a/packages/dapp/webpack.config.js +++ b/packages/dapp/webpack.config.js @@ -21,7 +21,7 @@ module.exports = { }, optimization: { minimize: false, - namedModules: true, + moduleIds: 'named', }, module: { rules: [ diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index e0664505..b2786577 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "AlgoSigner", "author": "https://developer.purestake.io", - "version": "1.5.2", + "version": "1.6.0", "description": "Algorand Wallet Extension | Send & Receive ALGOs | Sign dApp Transactions", "icons": { "48": "icon.png" diff --git a/packages/extension/package.json b/packages/extension/package.json index a3a8bd07..042e8567 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,7 +1,9 @@ { "name": "algosigner-extension", - "version": "1.5.2", + "version": "1.6.0", "author": "https://developer.purestake.io", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "description": "Sign Algorand transactions in your browser with PureStake.", "keywords": [ "Algorand", @@ -9,13 +11,13 @@ ], "devDependencies": { "@types/jest": "^26.0.14", - "jest": "^26.4.2", + "jest": "27.0.0", "jest-webextension-mock": "^3.6.1", - "ts-jest": "^26.3.0", + "ts-jest": "^27.0.0", "ts-loader": "^7.0.5", "typescript": "^3.9.7", "webpack": "^4.44.1", - "webpack-cli": "^3.3.12" + "webpack-cli": "^3.3.11" }, "dependencies": { "algosdk": "1.8.1" diff --git a/packages/extension/src/background/messaging/handler.ts b/packages/extension/src/background/messaging/handler.ts index 6d74fe30..46e02f2d 100644 --- a/packages/extension/src/background/messaging/handler.ts +++ b/packages/extension/src/background/messaging/handler.ts @@ -1,114 +1,112 @@ import { MessageApi } from './api'; import { Task } from './task'; -import { extensionBrowser } from '@algosigner/common/chrome'; -import encryptionWrap from "../encryptionWrap"; +import encryptionWrap from '../encryptionWrap'; import { isFromExtension } from '@algosigner/common/utils'; import { RequestErrors } from '@algosigner/common/types'; import { JsonRpcMethod, MessageSource } from '@algosigner/common/messaging/types'; +import logging from '@algosigner/common/logging'; const auth_methods = [ - JsonRpcMethod.Authorization, - JsonRpcMethod.AuthorizationAllow, - JsonRpcMethod.AuthorizationDeny + JsonRpcMethod.Authorization, + JsonRpcMethod.AuthorizationAllow, + JsonRpcMethod.AuthorizationDeny, ]; class RequestValidation { - public static isAuthorization(method: JsonRpcMethod) { - if(auth_methods.indexOf(method) > -1) - return true; - return false; - } - public static isPublic(method: JsonRpcMethod) { - if(method in Task.methods().public) - return true; - return false; - } + public static isAuthorization(method: JsonRpcMethod) { + if (auth_methods.indexOf(method) > -1) return true; + return false; + } + public static isPublic(method: JsonRpcMethod) { + if (method in Task.methods().public) return true; + return false; + } } export class OnMessageHandler extends RequestValidation { - static events: {[key: string]: any} = {}; + static events: { [key: string]: any } = {}; - static handle(request: any, sender: any, sendResponse: any) { - //console.log('HANDLIG MESSAGE', request, sender); + static handle(request: any, sender: any, sendResponse: any) { + logging.log( + `Handler in background messaging: ${JSON.stringify(request)}, ${JSON.stringify(sender)}`, + 2 + ); - if ('tab' in sender){ - request.originTabID = sender.tab.id; - request.originTitle = sender.tab.title; - if ('favIconUrl' in sender.tab) - request.favIconUrl = sender.tab.favIconUrl; - } - - try { - request.origin = new URL(sender.url).origin; - } catch(e) { - request.error = RequestErrors.NotAuthorized; - MessageApi.send(request); - return; - } + if ('tab' in sender) { + request.originTabID = sender.tab.id; + request.originTitle = sender.tab.title; + if ('favIconUrl' in sender.tab) request.favIconUrl = sender.tab.favIconUrl; + } - return this.processMessage(request, sender, sendResponse); + try { + request.origin = new URL(sender.url).origin; + } catch (e) { + request.error = RequestErrors.NotAuthorized; + MessageApi.send(request); + return; } - static processMessage(request: any, sender: any, sendResponse: any) { - const source : MessageSource = request.source; - const body = request.body; - const method = body.method; - const id = body.id; + return this.processMessage(request, sender, sendResponse); + } - // Check if the message comes from the extension - if (isFromExtension(sender.origin)) { - // Message from extension - switch(source) { - // Message from extension to dapp - case MessageSource.Extension: - if(OnMessageHandler.isAuthorization(method) - && !OnMessageHandler.isPublic(method)) { - // Is a protected authorization message, allowing or denying auth - Task.methods().private[method](request); - } else { - OnMessageHandler.events[id] = sendResponse; - MessageApi.send(request); - // Tell Chrome that this response will be resolved asynchronously. - return true; - } - break; - case MessageSource.UI: - return Task.methods().extension[method](request, sendResponse); - break; - } - } else { - // Reject message if there's no wallet - new encryptionWrap("").checkStorage((exist: boolean) => { - if (!exist) { - request.error = { - message: RequestErrors.NotAuthorized - }; - MessageApi.send(request); - } else { - if (OnMessageHandler.isAuthorization(method) - && OnMessageHandler.isPublic(method)) { - // Is a public authorization message, dapp is asking to connect - Task.methods().public[method](request); - } else { - // Other requests from dapp fall here - if (Task.isAuthorized(request.origin)) { - // If the origin is authorized, build a promise - Task.build(request) - .then((d) => { - MessageApi.send(d); - }) - .catch((d) => { - MessageApi.send(d); - }); - } else { - // Origin is not authorized - request.error = RequestErrors.NotAuthorized; - MessageApi.send(request); - } - } - } - }); + static processMessage(request: any, sender: any, sendResponse: any) { + const source: MessageSource = request.source; + const body = request.body; + const method = body.method; + const id = body.id; + + // Check if the message comes from the extension + if (isFromExtension(sender.origin)) { + // Message from extension + switch (source) { + // Message from extension to dapp + case MessageSource.Extension: + if (OnMessageHandler.isAuthorization(method) && !OnMessageHandler.isPublic(method)) { + // Is a protected authorization message, allowing or denying auth + Task.methods().private[method](request); + } else { + OnMessageHandler.events[id] = sendResponse; + MessageApi.send(request); + // Tell Chrome that this response will be resolved asynchronously. return true; + } + break; + case MessageSource.UI: + return Task.methods().extension[method](request, sendResponse); + break; + } + } else { + // Reject message if there's no wallet + new encryptionWrap('').checkStorage((exist: boolean) => { + if (!exist) { + request.error = { + message: RequestErrors.NotAuthorized, + }; + MessageApi.send(request); + } else { + if (OnMessageHandler.isAuthorization(method) && OnMessageHandler.isPublic(method)) { + // Is a public authorization message, dapp is asking to connect + Task.methods().public[method](request); + } else { + // Other requests from dapp fall here + if (Task.isAuthorized(request.origin)) { + // If the origin is authorized, build a promise + Task.build(request) + .then((d) => { + MessageApi.send(d); + }) + .catch((d) => { + MessageApi.send(d); + }); + } else { + // Origin is not authorized + request.error = RequestErrors.NotAuthorized; + MessageApi.send(request); + } + } } + }); + return true; } -} \ No newline at end of file + } +} diff --git a/packages/extension/src/background/messaging/internalMethods.ts b/packages/extension/src/background/messaging/internalMethods.ts index d4442106..4b685bf5 100644 --- a/packages/extension/src/background/messaging/internalMethods.ts +++ b/packages/extension/src/background/messaging/internalMethods.ts @@ -12,11 +12,16 @@ import Session from '../utils/session'; import AssetsDetailsHelper from '../utils/assetsDetailsHelper'; import { initializeCache, getAvailableLedgersExt } from '../utils/helper'; import { ValidationStatus } from '../utils/validator'; -import { getValidatedTxnWrap, getLedgerFromGenesisId } from '../transaction/actions'; +import { + calculateEstimatedFee, + getValidatedTxnWrap, + getLedgerFromGenesisId, +} from '../transaction/actions'; import { BaseValidatedTxnWrap } from '../transaction/baseValidatedTxnWrap'; import { buildTransaction } from '../utils/transactionBuilder'; import { getBaseSupportedLedgers, LedgerTemplate } from '@algosigner/common/types/ledgers'; import { NoAccountMatch } from '../../errors/transactionSign'; +import { extensionBrowser } from '@algosigner/common/chrome'; const session = new Session(); @@ -61,7 +66,7 @@ export class InternalMethods { break; } } - if (!found) throw new NoAccountMatch(address); + if (!found) throw new NoAccountMatch(address, ledger); } private static loadAccountAssetsDetails(address: string, ledger: Ledger) { @@ -294,6 +299,165 @@ export class InternalMethods { return true; } + public static [JsonRpcMethod.LedgerLinkAddress](request: any, sendResponse: Function) { + const ledger = request.body.params.ledger; + extensionBrowser.tabs.create( + { + active: true, + url: extensionBrowser.extension.getURL(`/index.html#/${ledger}/ledger-hardware-connector`), + }, + (tab) => { + // Tab object is created here, but extension popover will close. + sendResponse(tab); + } + ); + return true; + } + public static [JsonRpcMethod.LedgerGetSessionTxn](request: any, sendResponse: Function) { + if (session.txnWrap && 'body' in session.txnWrap) { + // The transaction may contain source and JSONRPC info, the body.params will be the transaction validation object + sendResponse(session.txnWrap.body.params); + } else { + sendResponse({ error: 'Transaction not found in session.' }); + } + return true; + } + + public static [JsonRpcMethod.LedgerSendTxnResponse](request: any, sendResponse: Function) { + if (session.txnWrap && 'body' in session.txnWrap) { + const txnBuf = Buffer.from(request.body.params.txn, 'base64'); + const decodedTxn = algosdk.decodeSignedTransaction(txnBuf); + const signedTxnEntries = Object.entries(decodedTxn.txn).sort(); + + // Get the session transaction + const sessTxn = session.txnWrap.body.params.transaction; + + // Set the fee to the estimate we showed on the screen for validation. + sessTxn['fee'] = session.txnWrap.body.params.estimatedFee; + const sessTxnEntries = Object.entries(sessTxn).sort(); + + // Update fields in the signed transaction that are not the same format + for (let i = 0; i < signedTxnEntries.length; i++) { + if (signedTxnEntries[i][0] === 'from') { + signedTxnEntries[i][1] = algosdk.encodeAddress(signedTxnEntries[i][1]['publicKey']); + } else if (signedTxnEntries[i][0] === 'to') { + signedTxnEntries[i][1] = algosdk.encodeAddress(signedTxnEntries[i][1]['publicKey']); + } else if (signedTxnEntries[i][1] && signedTxnEntries[i][1].constructor === Uint8Array) { + //@ts-ignore + signedTxnEntries[i][1] = Buffer.from(signedTxnEntries[i][1]).toString('base64'); + } + } + + logging.log(`Signed Txn: ${signedTxnEntries}`, 2); + logging.log(`Session Txn: ${sessTxnEntries}`, 2); + + if ( + signedTxnEntries['amount'] === sessTxnEntries['amount'] && + signedTxnEntries['fee'] === sessTxnEntries['fee'] && + signedTxnEntries['genesisID'] === sessTxnEntries['genesisID'] && + signedTxnEntries['firstRound'] === sessTxnEntries['firstRound'] && + signedTxnEntries['lastRound'] === sessTxnEntries['lastRound'] && + signedTxnEntries['type'] === sessTxnEntries['type'] && + signedTxnEntries['to'] === sessTxnEntries['to'] && + signedTxnEntries['from'] === sessTxnEntries['from'] && + signedTxnEntries['closeRemainderTo'] === sessTxnEntries['closeRemainderTo'] + ) { + //Check the txnWrap for a dApp response and return the transaction + if (session.txnWrap.source === 'dapp') { + const message = session.txnWrap; + message.response = { + blob: request.body.params.txn, + }; + sendResponse({ message: message }); + } + // If this is a ui transaction then we need to also submit + else if (session.txnWrap.source === 'ui') { + const txHeaders = { 'Content-Type': 'application/x-binary' }; + const ledger = getLedgerFromGenesisId(decodedTxn.txn.genesisID); + + const algod = this.getAlgod(ledger); + algod + .sendRawTransaction(txnBuf, txHeaders) + .do() + .then((resp: any) => { + sendResponse({ txId: resp.txId }); + }) + .catch((e: any) => { + if (e.message.includes('overspend')) + sendResponse({ + error: "Overspending. Your account doesn't have sufficient funds.", + }); + else sendResponse({ error: e.message }); + }); + } else { + sendResponse({ error: 'Session transaction does not match the signed transaction.' }); + } + + // Clear the cached transaction + session.txnWrap.body.params.transaction = undefined; + } else { + sendResponse({ error: 'Transaction not found in session, unable to validate for send.' }); + } + } + return true; + } + + // Protected because this should only be called from within the ui or dapp sign methods + protected static [JsonRpcMethod.LedgerSignTransaction](request: any, sendResponse: Function) { + // Access store here here to save the transaction wrap to cache before the site picks it up. + // Explicitly using txnWrap on session instead of auth message for two reasons: + // 1) So it lives inside background sandbox containment. + // 2) The extension may close before a proper id on the new tab can allow the data to be saved. + session.txnWrap = request; + + // Transaction wrap will contain response message if from dApp and structure will be different + const ledger = getLedgerFromGenesisId(request.body.params.transaction.genesisID); + extensionBrowser.tabs.create( + { + active: true, + url: extensionBrowser.extension.getURL(`/index.html#/${ledger}/ledger-hardware-sign`), + }, + (tab) => { + // Tab object is created here, but extension popover will close. + sendResponse(tab); + } + ); + } + + public static [JsonRpcMethod.LedgerSaveAccount](request: any, sendResponse: Function) { + const { name, ledger, passphrase } = request.body.params; + // The value returned from the Ledger device is hex. + // This is passed directly to save and needs to be converted. + const address = algosdk.encodeAddress(Buffer.from(request.body.params.hexAddress, 'hex')); + + this._encryptionWrap = new encryptionWrap(passphrase); + this._encryptionWrap.unlock((unlockedValue: any) => { + if ('error' in unlockedValue) { + sendResponse(unlockedValue); + } else { + const newAccount = { + address: address, + name: name, + isHardware: true, + }; + + if (!unlockedValue[ledger]) { + unlockedValue[ledger] = []; + } + + unlockedValue[ledger].push(newAccount); + this._encryptionWrap?.lock(JSON.stringify(unlockedValue), (isSuccessful: any) => { + if (isSuccessful) { + session.wallet = this.safeWallet(unlockedValue); + sendResponse(session.wallet); + } else { + sendResponse({ error: 'Lock failed' }); + } + }); + } + }); + return true; + } public static [JsonRpcMethod.AccountDetails](request: any, sendResponse: Function) { const { ledger, address } = request.body.params; const algod = this.getAlgod(ledger); @@ -520,7 +684,6 @@ export class InternalMethods { } } - var recoveredAccount = algosdk.mnemonicToSecretKey(account.mnemonic); const params = await algod.getTransactionParams().do(); const txn = { ...txnParams, @@ -570,31 +733,61 @@ export class InternalMethods { sendResponse({ error: e }); return; } else { - // We have a transaction which does not contain invalid fields, but may contain fields that are dangerous - // or ones we've flagged as needing to be reviewed. We can use a modified popup to allow the normal flow, but require extra scrutiny. - let signedTxn; - try { - const builtTx = buildTransaction(txn); - signedTxn = { - txID: builtTx.txID().toString(), - blob: builtTx.signTxn(recoveredAccount.sk), - }; - } catch (e) { - sendResponse({ error: e.message }); - return; - } + // We have a transaction which does not contain invalid fields, + // but may still contain fields that are dangerous + // or ones we've flagged as needing to be reviewed. + // Perform a change based on if this is a ledger device account + if (account.isHardware) { + // TODO: Temporary workaround by adding min-fee for estimate calculations since it's not in the sdk get params. + params['min-fee'] = 1000; + calculateEstimatedFee(transactionWrap, params); + + // Pass the transaction wrap we can pass to the + // central sign ledger function for consistency + this[JsonRpcMethod.LedgerSignTransaction]( + { source: 'ui', body: { params: transactionWrap } }, + (response) => { + // We only have to worry about possible errors here so we can ignore the created tab + if ('error' in response) { + sendResponse(response); + } else { + // Respond with a 0 tx id so that the page knows not to try and show it. + sendResponse({ txId: 0 }); + } + } + ); - algod - .sendRawTransaction(signedTxn.blob, txHeaders) - .do() - .then((resp: any) => { - sendResponse({ txId: resp.txId }); - }) - .catch((e: any) => { - if (e.message.includes('overspend')) - sendResponse({ error: "Overspending. Your account doesn't have sufficient funds." }); - else sendResponse({ error: e.message }); - }); + // Return to close connection + return true; + } else { + // We can use a modified popup to allow the normal flow, but require extra scrutiny. + const recoveredAccount = algosdk.mnemonicToSecretKey(account.mnemonic); + let signedTxn; + try { + const builtTx = buildTransaction(txn); + signedTxn = { + txID: builtTx.txID().toString(), + blob: builtTx.signTxn(recoveredAccount.sk), + }; + } catch (e) { + sendResponse({ error: e.message }); + return false; + } + + algod + .sendRawTransaction(signedTxn.blob, txHeaders) + .do() + .then((resp: any) => { + sendResponse({ txId: resp.txId }); + }) + .catch((e: any) => { + if (e.message.includes('overspend')) + sendResponse({ + error: "Overspending. Your account doesn't have sufficient funds.", + }); + else sendResponse({ error: e.message }); + }); + } } }); diff --git a/packages/extension/src/background/messaging/task.ts b/packages/extension/src/background/messaging/task.ts index 8e077152..449a54f3 100644 --- a/packages/extension/src/background/messaging/task.ts +++ b/packages/extension/src/background/messaging/task.ts @@ -10,7 +10,7 @@ import { calculateEstimatedFee, } from '../transaction/actions'; import { BaseValidatedTxnWrap } from '../transaction/baseValidatedTxnWrap'; -import { ValidationStatus } from '../utils/validator'; +import { ValidationStatus, ValidationResponse } from '../utils/validator'; import { InternalMethods } from './internalMethods'; import { MessageApi } from './api'; import encryptionWrap from '../encryptionWrap'; @@ -105,7 +105,7 @@ export class Task { if (transactionWrap.transaction['type'] === 'axfer') { const assetIndex = transactionWrap.transaction['assetIndex']; const ledger = getLedgerFromGenesisId(transactionWrap.transaction['genesisID']); - const conn = Settings.getBackendParams(ledger, API.Indexer); + const conn = Settings.getBackendParams(ledger, API.Algod); const sendPath = `/v2/assets/${assetIndex}`; const fetchAssets: any = { headers: { @@ -116,9 +116,15 @@ export class Task { let url = conn.url; if (conn.port.length > 0) url += ':' + conn.port; - await Task.fetchAPI(`${url}${sendPath}`, fetchAssets).then((assets) => { + + transactionWrap.assetInfo = { + unitName: '', + displayAmount: '', + }; + + await Task.fetchAPI(`${url}${sendPath}`, fetchAssets).then((asset) => { const assetInfo: any = {}; - const params = assets['asset']['params']; + const params = asset['params']; // Get relevant data from asset params const decimals = params['decimals']; @@ -155,6 +161,8 @@ export class Task { } transactionWrap.assetInfo = assetInfo; + }).catch((e) => { + logging.log(e.message); }); } } @@ -601,6 +609,11 @@ export class Task { reject(d); return; } + + // If the whole group is provided and verified, we mark the group field as valid instead of dangerous + transactionWraps.forEach((wrap) => { + wrap.validityObject['group'] = new ValidationResponse({ status: ValidationStatus.Valid }); + }); } else { const wrap = transactionWraps[0]; if ( @@ -626,7 +639,7 @@ export class Task { { url: extensionBrowser.runtime.getURL('index.html#/sign-v2-transaction'), ...popupProperties, - height: popupProperties.height + (transactionWraps.length > 1 ? 80 : 0), + height: popupProperties.height + 80, }, function (w) { if (w) { @@ -808,6 +821,7 @@ export class Task { const { passphrase, responseOriginTabID } = request.body.params; const auth = Task.requests[responseOriginTabID]; const message = auth.message; + let holdResponse = false; const { from, @@ -847,7 +861,11 @@ export class Task { } } - const recoveredAccount = algosdk.mnemonicToSecretKey(account.mnemonic); + // If the account is not a hardware account we need to get the mnemonic + let recoveredAccount; + if (!account.isHardware) { + recoveredAccount = algosdk.mnemonicToSecretKey(account.mnemonic); + } const txn = { ...message.body.params.transaction }; removeEmptyFields(txn); @@ -895,25 +913,48 @@ export class Task { try { // This step transitions a raw object into a transaction style object const builtTx = buildTransaction(txn); - // We are combining the tx id get and sign into one step/object because of legacy, - // this may not need to be the case any longer. - const signedTxn = { - txID: builtTx.txID().toString(), - blob: builtTx.signTxn(recoveredAccount.sk), - }; - const b64Obj = Buffer.from(signedTxn.blob).toString('base64'); - - message.response = { - txID: signedTxn.txID, - blob: b64Obj, - }; + + if (recoveredAccount) { + // We recovered an account from within the saved extension data and can sign with it + const txblob = builtTx.signTxn(recoveredAccount.sk); + + // We are combining the tx id get and sign into one step/object because of legacy, + // this may not need to be the case any longer. + const signedTxn = { + txID: builtTx.txID().toString(), + blob: txblob, + }; + + const b64Obj = Buffer.from(signedTxn.blob).toString('base64'); + + message.response = { + txID: signedTxn.txID, + blob: b64Obj, + }; + } else if (account.isHardware) { + // The account is hardware based. We need to open the extension in tab to connect. + // We will need to hold the response to dApps + holdResponse = true; + InternalMethods[JsonRpcMethod.LedgerSignTransaction](message, (response) => { + // We only have to worry about possible errors here + if ('error' in response) { + // Cancel the hold response since errors needs to be returned + holdResponse = false; + message.error = response.error; + } + }); + } } catch (e) { message.error = e.message; } // Clean class saved request delete Task.requests[responseOriginTabID]; - MessageApi.send(message); + + // Hardware signing will defer the response + if (!holdResponse) { + MessageApi.send(message); + } }); } catch { // On error we should remove the task @@ -1299,6 +1340,40 @@ export class Task { [JsonRpcMethod.GetLedgers]: (request: any, sendResponse: Function) => { return InternalMethods[JsonRpcMethod.GetLedgers](request, sendResponse); }, + [JsonRpcMethod.LedgerLinkAddress]: (request: any, sendResponse: Function) => { + return InternalMethods[JsonRpcMethod.LedgerLinkAddress](request, sendResponse); + }, + [JsonRpcMethod.LedgerGetSessionTxn]: (request: any, sendResponse: Function) => { + return InternalMethods[JsonRpcMethod.LedgerGetSessionTxn](request, sendResponse); + }, + [JsonRpcMethod.LedgerSendTxnResponse]: (request: any, sendResponse: Function) => { + InternalMethods[JsonRpcMethod.LedgerSendTxnResponse](request, function (response) { + logging.log( + `Task method - LedgerSendTxnResponse - Returning: ${JSON.stringify(response)}`, + 2 + ); + + // Message indicates that this response will go to the DApp + if ('message' in response) { + // Send the response back to the origniating page + MessageApi.send(response.message); + // Also pass back the blob response to the caller + sendResponse(response.message.response); + } else { + // Send repsonse to the calling function + sendResponse(response); + } + }); + return true; + }, + [JsonRpcMethod.LedgerSaveAccount]: (request: any, sendResponse: Function) => { + try { + return InternalMethods[JsonRpcMethod.LedgerSaveAccount](request, sendResponse); + } catch { + sendResponse({ error: 'Account parameters invalid.' }); + return false; + } + }, }, }; } diff --git a/packages/extension/src/background/transaction/acfgCreateTransaction.ts b/packages/extension/src/background/transaction/acfgCreateTransaction.ts index b898d74a..a61c3598 100644 --- a/packages/extension/src/background/transaction/acfgCreateTransaction.ts +++ b/packages/extension/src/background/transaction/acfgCreateTransaction.ts @@ -6,7 +6,7 @@ import { BaseValidatedTxnWrap } from './baseValidatedTxnWrap'; /// class AssetCreateTx implements IAssetCreateTx { assetTotal: number = undefined; - assetDecimals: number = undefined; + assetDecimals?: number = null; type: string = undefined; from: string = undefined; fee: number = undefined; diff --git a/packages/extension/src/background/transaction/actions.test.ts b/packages/extension/src/background/transaction/actions.test.ts index 2fae6f46..aac3c81a 100644 --- a/packages/extension/src/background/transaction/actions.test.ts +++ b/packages/extension/src/background/transaction/actions.test.ts @@ -124,7 +124,6 @@ test('Validate pay transaction required fields', () => { expect(errorMessage).toContain('genesisHash'); expect(errorMessage).toContain('to'); expect(errorMessage).toContain('from'); - expect(errorMessage).toContain('amount'); }); test('Validate clawback transaction required fields', () => { const preTransaction = { @@ -180,7 +179,6 @@ test('Validate create transaction required fields', () => { expect(errorMessage).toContain('genesisHash'); expect(errorMessage).toContain('from'); expect(errorMessage).toContain('assetTotal'); - expect(errorMessage).toContain('assetDecimals'); }); test('Validate destroy transaction required fields', () => { const preTransaction = { diff --git a/packages/extension/src/background/transaction/baseValidatedTxnWrap.ts b/packages/extension/src/background/transaction/baseValidatedTxnWrap.ts index 5f1d53d1..474127bb 100644 --- a/packages/extension/src/background/transaction/baseValidatedTxnWrap.ts +++ b/packages/extension/src/background/transaction/baseValidatedTxnWrap.ts @@ -30,6 +30,13 @@ export class BaseValidatedTxnWrap { const missingFields = []; const extraFields = []; + // if(v1Validations) { + // this.validityObject['v1'] = new ValidationResponse({ + // status: ValidationStatus.Warning, + // info: 'Version 1 transactions have been deprecated.', + // }); + // } + // Cycle base transaction fields for this type of transaction to verify require fields are present. // Nullable type fields are being initialized to null instead of undefined. Object.entries(this.transaction).forEach(([key, value]) => { diff --git a/packages/extension/src/background/transaction/payTransaction.ts b/packages/extension/src/background/transaction/payTransaction.ts index 0aefaaec..f0184849 100644 --- a/packages/extension/src/background/transaction/payTransaction.ts +++ b/packages/extension/src/background/transaction/payTransaction.ts @@ -6,11 +6,11 @@ import { BaseValidatedTxnWrap } from './baseValidatedTxnWrap'; /// class PaymentTx implements IPaymentTx { type: string = undefined; + amount: number = 0; + from: string = undefined; to: string = undefined; - amount: number = undefined; closeRemainderTo?: string = null; reKeyTo?: any = null; - from: string = undefined; fee: number = undefined; firstRound: number = undefined; lastRound: number = undefined; diff --git a/packages/extension/src/background/utils/session.ts b/packages/extension/src/background/utils/session.ts index 995dfe11..80e34b2f 100644 --- a/packages/extension/src/background/utils/session.ts +++ b/packages/extension/src/background/utils/session.ts @@ -2,6 +2,7 @@ export default class Session { private _wallet: any; private _ledger: any; private _availableLedgers: any; + private _txnWrap: any; public set wallet(v: any) { this._wallet = v; @@ -19,6 +20,15 @@ export default class Session { return this._ledger; } + public set txnWrap(w: any) { + this._txnWrap = w; + } + + public get txnWrap(): any { + const w = this._txnWrap; + return w; + } + public set availableLedgers(v: any) { this._availableLedgers = v; } @@ -36,6 +46,7 @@ export default class Session { wallet: this._wallet, ledger: this._ledger, availableLedgers: this._availableLedgers || [], + txnWrap: this._txnWrap, }; } @@ -43,5 +54,6 @@ export default class Session { this._wallet = undefined; this._ledger = undefined; this._availableLedgers = undefined; + this._txnWrap = undefined; } } diff --git a/packages/extension/src/background/utils/validator.test.ts b/packages/extension/src/background/utils/validator.test.ts index 53738693..64836054 100644 --- a/packages/extension/src/background/utils/validator.test.ts +++ b/packages/extension/src/background/utils/validator.test.ts @@ -1,70 +1,73 @@ -import { Validate, ValidationStatus } from "./validator"; +import { Validate, ValidationStatus } from './validator'; test('Validate correct to address', () => { - let result = Validate("to", "NM2MBC673SL7TQIKUXD4JOBR3XQITDCHIMIEODQBUGFMAN54QV2VUYWZNQ"); - expect(result.status).toBe(ValidationStatus.Valid); + const result = Validate('to', 'NM2MBC673SL7TQIKUXD4JOBR3XQITDCHIMIEODQBUGFMAN54QV2VUYWZNQ'); + expect(result.status).toBe(ValidationStatus.Valid); }); test('Validate invalid to address', () => { - let result = Validate("to", "12345"); - expect(result.status).toBe(ValidationStatus.Invalid); + const result = Validate('to', '12345'); + expect(result.status).toBe(ValidationStatus.Invalid); }); test('Validate correct amount', () => { - let result = Validate("amount", 1); - expect(result.status).toBe(ValidationStatus.Valid); + const result = Validate('amount', 1); + expect(result.status).toBe(ValidationStatus.Valid); }); test('Validate invalid amount', () => { - let result = Validate("amount", 9999999999999999999999999); - expect(result.status).toBe(ValidationStatus.Invalid); + const result = Validate('amount', 9999999999999999999999999); + expect(result.status).toBe(ValidationStatus.Invalid); }); test('Validate normal fee', () => { - let result = Validate("fee", 1000); - expect(result.status).toBe(ValidationStatus.Valid); + const result = Validate('fee', 1000); + expect(result.status).toBe(ValidationStatus.Valid); }); test('Validate elevated fee', () => { - let result = Validate("fee", 100000); - expect(result.status).toBe(ValidationStatus.Warning); + const result = Validate('fee', 100000); + expect(result.status).toBe(ValidationStatus.Warning); }); test('Validate very high fee', () => { - let result = Validate("fee", 10000000); - expect(result.status).toBe(ValidationStatus.Dangerous); + const result = Validate('fee', 10000000); + expect(result.status).toBe(ValidationStatus.Dangerous); }); -test('Validate closeRemainderTo empty', () => { - let result = Validate("closeRemainderTo", ""); - expect(result.status).toBe(ValidationStatus.Valid); +test('Validate closeRemainderTo is a valid address', () => { + const result = Validate('closeRemainderTo', 'AAAAAAAAAAAANOTAVALIDADDRESSAAAAAAAAAAAA'); + expect(result.status).toBe(ValidationStatus.Invalid); }); -test('Validate closeRemainderTo', () => { - let result = Validate("closeRemainderTo", "NM2MBC673SL7TQIKUXD4JOBR3XQITDCHIMIEODQBUGFMAN54QV2VUYWZNQ"); - expect(result.status).toBe(ValidationStatus.Dangerous); +test('Validate closeRemainderTo is dangerous', () => { + const result = Validate( + 'closeRemainderTo', + 'NM2MBC673SL7TQIKUXD4JOBR3XQITDCHIMIEODQBUGFMAN54QV2VUYWZNQ' + ); + expect(result.status).toBe(ValidationStatus.Dangerous); }); test('Validate correct assetIndex', () => { - let result = Validate("assetIndex", 1); - expect(result.status).toBe(ValidationStatus.Valid); + const result = Validate('assetIndex', 1); + expect(result.status).toBe(ValidationStatus.Valid); }); test('Validate invalid assetIndex', () => { - let result = Validate("assetIndex", 9999999999999999999999999); - expect(result.status).toBe(ValidationStatus.Invalid); + const result = Validate('assetIndex', 9999999999999999999999999); + expect(result.status).toBe(ValidationStatus.Invalid); }); test('Validate correct rounds', () => { - let resultFirst = Validate("firstRound", 1); - let resultLast = Validate("lastRound", 1); - expect(resultFirst.status).toBe(ValidationStatus.Valid); - expect(resultLast.status).toBe(ValidationStatus.Valid); + const resultFirst = Validate('firstRound', 1); + const resultLast = Validate('lastRound', 1); + expect(resultFirst.status).toBe(ValidationStatus.Valid); + expect(resultLast.status).toBe(ValidationStatus.Valid); }); test('Validate invalid rounds', () => { - let resultFirst = Validate("firstRound", 9999999999999999999999999); - let resultLast = Validate("lastRound", 9999999999999999999999999); - expect(resultFirst.status).toBe(ValidationStatus.Invalid); - expect(resultLast.status).toBe(ValidationStatus.Invalid); -}); \ No newline at end of file + const resultFirst = Validate('firstRound', 9999999999999999999999999); + const resultLast = Validate('lastRound', 9999999999999999999999999); + expect(resultFirst.status).toBe(ValidationStatus.Invalid); + expect(resultLast.status).toBe(ValidationStatus.Invalid); +}); diff --git a/packages/extension/src/background/utils/validator.ts b/packages/extension/src/background/utils/validator.ts index 1d909475..218c4aea 100644 --- a/packages/extension/src/background/utils/validator.ts +++ b/packages/extension/src/background/utils/validator.ts @@ -51,13 +51,22 @@ export function Validate(field: any, value: any): ValidationResponse { // Validate the addresses are accurate case 'to': case 'from': + case 'closeRemainderTo': if (!algosdk.isValidAddress(value)) { return new ValidationResponse({ status: ValidationStatus.Invalid, info: 'Address does not adhere to a valid structure.', }); } else { - return new ValidationResponse({ status: ValidationStatus.Valid }); + // Close to types should issue a Dangerous validation warning if they contain values. + if (field === 'closeRemainderTo') { + return new ValidationResponse({ + status: ValidationStatus.Dangerous, + info: `A 'close to' address is associated to this transaction.`, + }); + } else { + return new ValidationResponse({ status: ValidationStatus.Valid }); + } } // Safety checks for numbers case 'amount': @@ -85,7 +94,16 @@ export function Validate(field: any, value: any): ValidationResponse { (typeof value === 'string' || value instanceof String) && value.length < STRING_MAX_LENGTH ) { - return new ValidationResponse({ status: ValidationStatus.Valid }); + // Group transactions are dangerous unless the whole group is provided. + // v2 flow handles this when the group is indeed provided. + if (field === 'group') { + return new ValidationResponse({ + status: ValidationStatus.Dangerous, + info: `This is an atomic transaction that's part of an unknown group.`, + }); + } else { + return new ValidationResponse({ status: ValidationStatus.Valid }); + } } else { return new ValidationResponse({ status: ValidationStatus.Invalid, @@ -122,17 +140,6 @@ export function Validate(field: any, value: any): ValidationResponse { }); } - // Close to types should issue a Dangerous validation warning if they contain values. - case 'closeRemainderTo': - if (value) { - return new ValidationResponse({ - status: ValidationStatus.Dangerous, - info: 'A close to address is associated to this transaction.', - }); - } else { - return new ValidationResponse({ status: ValidationStatus.Valid }); - } - case 'reKeyTo': if (value) { return new ValidationResponse({ diff --git a/packages/extension/src/errors/transactionSign.ts b/packages/extension/src/errors/transactionSign.ts index 8e2ac111..78a13ac9 100644 --- a/packages/extension/src/errors/transactionSign.ts +++ b/packages/extension/src/errors/transactionSign.ts @@ -1,6 +1,6 @@ export class NoAccountMatch extends Error { - constructor(address: string) { - super(`No matching account found on AlgoSigner for address: "${address}".`); + constructor(address: string, ledger: string) { + super(`No matching account found on AlgoSigner for address: "${address}" on network ${ledger}.`); this.name = 'NoAccountMatch'; Error.captureStackTrace(this, NoAccountMatch); } diff --git a/packages/storage/package.json b/packages/storage/package.json index d0999223..314f0f15 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,7 +1,9 @@ { "name": "algosigner-storage", - "version": "1.5.2", + "version": "1.6.0", "author": "https://developer.purestake.io", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "description": "Storage wrapper for saving and retrieving extention information in Algosigner.", "devDependencies": { "ts-loader": "^7.0.5", diff --git a/packages/storage/webpack.config.js b/packages/storage/webpack.config.js index cf43272c..95e638c4 100644 --- a/packages/storage/webpack.config.js +++ b/packages/storage/webpack.config.js @@ -24,7 +24,7 @@ module.exports = { //devtool: 'source-map', optimization: { minimize: false, - namedModules: true, + moduleIds: 'named', }, module: { rules: [ diff --git a/packages/test-project/package.json b/packages/test-project/package.json index e7894237..8cbed156 100644 --- a/packages/test-project/package.json +++ b/packages/test-project/package.json @@ -1,10 +1,12 @@ { "name": "algorand-test-project", - "version": "1.5.2", + "version": "1.6.0", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "description": "Repository for tests", "devDependencies": { "algosdk": "1.8.1", - "jest": "^27.0.0", + "jest": "27.0.0", "jest-runner-groups": "^2.0.1", "puppeteer": "^5.5.0", "ts-jest": "^27.0.0" @@ -12,11 +14,14 @@ "scripts": { "github": "jest -i --group=github", "basic-dapp": "jest --group=basic-dapp", - "basic-ui": "jest --group=basic-ui", + "ui": "jest --group=ui", + "ui/accounts": "jest --group=ui/accounts", + "ui/transactions": "jest --group=ui/transactions", "app-dapp": "jest --group=app-dapp", + "dapp": "jest --group=dapp --group=-dapp-storage", "dapp/multisig": "jest --group=dapp/multisig", - "dapp": "jest --group=dapp", - "coveragetest": "jest --coverage=true --coverageDirectory ../test-project/coverage --projects ../crypto ../extension ../storage ../common ../dapp --runInBand && echo \"Test info at: ./test-project/coverage/locv-report/index.html\"", + "dapp/signtxn": "jest --group=dapp/signtxn", + "coveragetest": "jest --coverage=true --coverageDirectory ../test-project/coverage --projects ../crypto ../extension ../storage ../common ../dapp --runInBand && bash -c \"start chrome \"$(realpath ./coverage/lcov-report/index.html\"\")", "test": "jest -i --group=-github --group=-dapp-storage" } } diff --git a/packages/test-project/tests/basic-ui-e2e.test.js b/packages/test-project/tests/basic-ui-e2e.test.js deleted file mode 100644 index 4c27320c..00000000 --- a/packages/test-project/tests/basic-ui-e2e.test.js +++ /dev/null @@ -1,414 +0,0 @@ -/** - * Basic e2e tests for the AlgoSigner UI - * - * @group basic-ui - */ - -describe('Basic Happy Path Tests', () => { - const extensionName = 'AlgoSigner'; - const extensionPopupHtml = 'index.html'; - const unsafePassword = 'c5brJp5f'; - const unsafeMenmonic = - 'grape topple reform pistol excite salute loud spike during draw drink planet naive high treat captain dutch cloth more bachelor attend attract magnet ability heavy'; - const testNetAccount = 'E2E-Tests'; - const testAccountAddress = 'MTHFSNXBMBD4U46Z2HAYAOLGD2EV6GQBPXVTL727RR3G44AJ3WVFMZGSBE'; - const amount = Math.ceil(Math.random() * 9); // txn size, modify multiplier for bulk - - let baseUrl; // set in beforeAll - let extensionPage; // set in beforeAll - let txId; // returned tx id from send txn - let txTitle; // for tx verification - - // TODO: switch tests to single page object - beforeAll(async () => { - const dummyPage = await browser.newPage(); - await dummyPage.waitForTimeout(2000); // arbitrary wait time. - const targets = await browser.targets(); - - const extensionTarget = targets.find(({ _targetInfo }) => { - return _targetInfo.title === extensionName && _targetInfo.type === 'background_page'; - }); - - const extensionUrl = extensionTarget._targetInfo.url || ''; - const [, , extensionID] = extensionUrl.split('/'); - - baseUrl = `chrome-extension://${extensionID}/${extensionPopupHtml}`; - - extensionPage = await browser.newPage(); - extensionPage.on('console', (msg) => console.log('PAGE LOG:', msg.text())); - dummyPage.close(); - await extensionPage.goto(baseUrl); - }); - - beforeEach(async () => { - jest.setTimeout(15000); - }); - - afterAll(async () => { - extensionPage.close(); - }); - - const verifyTransaction = async () => { - await extensionPage.waitForTimeout(10000); - const txSelector = `[data-transaction-id="${txId}"]`; - await extensionPage.waitForSelector(txSelector); - await extensionPage.click(txSelector); - await expect(extensionPage.$eval('#txTitle', (e) => e.innerText)).resolves.toBe(txTitle); - await expect( - extensionPage.$eval( - '.modal.is-active [data-transaction-id]', - (e) => e.dataset['transactionId'] - ) - ).resolves.toBe(txId); - await expect( - extensionPage.$eval( - '.modal.is-active [data-transaction-sender]', - (e) => e.dataset['transactionSender'] - ) - ).resolves.toBe(testAccountAddress); - await closeModal(); - }; - - test('Welcome Page Title', async () => { - await expect(extensionPage.title()).resolves.toMatch(extensionName); - }); - - test('Create New Wallet', async () => { - await extensionPage.waitForSelector('#setPassword'); - await extensionPage.click('#setPassword'); - }); - - test('Set new wallet password', async () => { - await expect(extensionPage.$eval('.mt-2', (e) => e.innerText)).resolves.toMatch( - 'my_1st_game_was_GALAGA!' - ); - await extensionPage.waitForSelector('#createWallet'); - await extensionPage.type('#setPassword', unsafePassword); - await extensionPage.type('#confirmPassword', unsafePassword); - await extensionPage.waitForTimeout(2000); - await extensionPage.waitForSelector('#createWallet'); - await extensionPage.click('#createWallet'); - }); - - test('Switch Ledger', async () => { - await extensionPage.waitForTimeout(4000); - await extensionPage.screenshot({ - path: 'screenshots/test_waiting_for_page.png', - }); - await extensionPage.click('#selectLedger'); - await extensionPage.waitForTimeout(500); - await extensionPage.click('#selectTestNet'); - }); - - test('Import Account', async () => { - await extensionPage.waitForSelector('#addAccount'); - await extensionPage.click('#addAccount'); - await extensionPage.waitForSelector('#importAccount'); - await extensionPage.click('#importAccount'); - await extensionPage.waitForSelector('#accountName'); - await extensionPage.type('#accountName', testNetAccount); - await extensionPage.waitForTimeout(100); - await extensionPage.type('#enterMnemonic', unsafeMenmonic); - await extensionPage.waitForTimeout(100); - await extensionPage.click('#nextStep'); - await extensionPage.waitForSelector('#enterPassword'); - await extensionPage.type('#enterPassword', unsafePassword); - await extensionPage.waitForTimeout(200); - await extensionPage.click('#authButton'); - // Loading the account takes time - await extensionPage.waitForTimeout(3000); - }); - - test('Load Account Info', async () => { - await selectAccount(); - await expect(extensionPage.$eval('#accountName', (e) => e.innerText)).resolves.toBe( - testNetAccount - ); - }); - - test('Send Algos Transaction', async () => { - await extensionPage.click('#sendTransfer'); - await extensionPage.waitForTimeout(100); - await extensionPage.type('#transferAmount', amount.toString()); - await extensionPage.type('#toAddress', testAccountAddress); - await extensionPage.type('#note', 'AutoTest Send Algo'); - await extensionPage.click('#submitTransfer'); - await extensionPage.waitForSelector('#enterPassword'); - await extensionPage.type('#enterPassword', unsafePassword); - await extensionPage.waitForSelector('#authButton'); - await extensionPage.click('#authButton'); - await extensionPage.waitForSelector('#txId'); - // setup tx details for next test - txId = await extensionPage.$eval('#txId', (e) => e.innerText); - txTitle = 'Payment'; - await returnToAccount(); - }); - - test('Verify transaction', verifyTransaction); - - test('Check Asset details', async () => { - const assetSelector = '[data-asset-id]'; - await extensionPage.waitForSelector(assetSelector); - const assetId = await extensionPage.$eval(assetSelector, (e) => e.dataset['assetId']); - const assetBalance = await extensionPage.$eval(assetSelector, (e) => e.dataset['assetBalance']); - await extensionPage.click(assetSelector); - await expect( - extensionPage.$eval(`.modal ${assetSelector}`, (e) => e.dataset['assetId']) - ).resolves.toBe(assetId); - await expect( - extensionPage.$eval(`.modal ${assetSelector}`, (e) => e.dataset['assetBalance']) - ).resolves.toBe(assetBalance); - await closeModal(); - const thereIsFullAssetList = await extensionPage.$('#showAssets'); - if (thereIsFullAssetList) { - await extensionPage.click('#showAssets'); - await extensionPage.waitForSelector(`.modal [data-asset-id="${assetId}"]`); - await closeModal(); - } - await goBack(); - }); - - test('Send Asset Transaction', async () => { - await selectAccount(); - await extensionPage.click('#sendTransfer'); - await extensionPage.waitForTimeout(100); - await extensionPage.click('#selectAsset'); - await extensionPage.waitForTimeout(100); - await extensionPage.waitForSelector('#asset-13169404'); - await extensionPage.click('#asset-13169404'); - await extensionPage.waitForTimeout(500); - // Test correct decimal handling - await extensionPage.type('#transferAmount', `0.000000000${amount}`); - const actualAmount = await extensionPage.$eval('#transferAmount', (e) => { - const inputValue = e.value; - e.value = ''; - return inputValue; - }); - expect(actualAmount).toMatch('0.000000'); - // Test actual transfer - await extensionPage.type('#transferAmount', `0.00000${amount}`); - await extensionPage.type('#toAddress', testAccountAddress); - await extensionPage.type('#note', 'AutoTest Send E2E Asset'); - await extensionPage.click('#submitTransfer'); - await extensionPage.waitForSelector('#enterPassword'); - await extensionPage.type('#enterPassword', unsafePassword); - await extensionPage.waitForSelector('#authButton'); - await extensionPage.click('#authButton'); - await extensionPage.waitForSelector('#txId'); - txId = await extensionPage.$eval('#txId', (e) => e.innerText); - txTitle = 'Asset transfer'; - await returnToAccount(); - }); - - test('Verify transaction', verifyTransaction); - - test('Transaction Errors: OverSpend', async () => { - await extensionPage.click('#sendTransfer'); - await extensionPage.waitForSelector('#transferAmount'); - await extensionPage.type('#transferAmount', '900000'); - await extensionPage.type('#toAddress', testAccountAddress); - await extensionPage.type('#note', 'AutoTest Overspend Algo'); - await extensionPage.click('#submitTransfer'); - await extensionPage.waitForSelector('#enterPassword'); - await extensionPage.type('#enterPassword', unsafePassword); - await extensionPage.waitForSelector('#authButton'); - await extensionPage.click('#authButton'); - await extensionPage.waitForSelector('#tx-error'); - - let pageError = await extensionPage.$eval('#tx-error', (e) => e.innerText); - await expect(pageError).toMatch( - "Overspending. Your account doesn't have sufficient funds." - ); - - await closeModal(); - await extensionPage.waitForTimeout(200); - await goBack(); - }); - - test('Transaction Errors: Invalid Field - Amount', async () => { - await extensionPage.click('#sendTransfer'); - await extensionPage.waitForSelector('#transferAmount'); - await extensionPage.type('#transferAmount', '9999999999.999999'); - await extensionPage.type('#toAddress', testAccountAddress); - await extensionPage.type('#note', 'AutoTest Invalid Amount'); - await extensionPage.click('#submitTransfer'); - await extensionPage.waitForSelector('#enterPassword'); - await extensionPage.type('#enterPassword', unsafePassword); - await extensionPage.waitForSelector('#authButton'); - await extensionPage.click('#authButton'); - await extensionPage.waitForSelector('#tx-error'); - - let pageError = await extensionPage.$eval('#tx-error', (e) => e.innerText); - expect(pageError).toMatch('One or more fields are not valid. Please check and try again.'); - - await closeModal(); - await extensionPage.waitForTimeout(200); - await goBack(); - }); - - test('Load Account Details', async () => { - await extensionPage.click('#showDetails'); - await extensionPage.waitForSelector('#accountAddress'); - await expect(extensionPage.$eval('#accountAddress', (e) => e.innerText)).resolves.toBe( - testAccountAddress - ); - await expect(extensionPage.$eval('#accountName', (e) => e.innerText)).resolves.toBe( - testNetAccount - ); - }); - - test('Delete Account', async () => { - await extensionPage.click('#deleteAccount'); - await extensionPage.type('#enterPassword', unsafePassword); - await extensionPage.waitForTimeout(200); - await extensionPage.click('#authButton'); - await extensionPage.waitForTimeout(2000); - }); - - test('Verify Account Deleted', async () => { - await extensionPage.waitForSelector('#addAccount'); - await expect(extensionPage.select('#accountAddress')).rejects.toThrow(); - await expect(extensionPage.select('#accountName')).rejects.toThrow(); - }); - - const selectAccount = async () => { - const accountSelector = '#account_' + testNetAccount; - await extensionPage.waitForSelector(accountSelector); - await extensionPage.click(accountSelector); - await extensionPage.waitForTimeout(1000); - return; - }; - - const returnToAccount = async () => { - await extensionPage.waitForTimeout(2000); - await extensionPage.click('#backToWallet'); - await extensionPage.waitForTimeout(2000); - return; - }; - - const goBack = async () => { - await extensionPage.click('#goBack'); - await extensionPage.waitForTimeout(500); - return; - }; - - const closeModal = async () => { - await extensionPage.click('.modal.is-active button.modal-close'); - await extensionPage.waitForTimeout(500); - return; - }; -}); - -// Create a new account in AlgoSigner -describe('Create Account', () => { - const extensionName = 'AlgoSigner'; - const extensionPopupHtml = 'index.html'; - const unsafePassword = 'c5brJp5f'; - const testNetAccount = 'Created-Account'; - - let baseUrl; // set in beforeAll - let extensionPage; // set in beforeAll - let createdAccountAddress; // returned tx id from send txn - let mnemonicArray = []; - - beforeAll(async () => { - const dummyPage = await browser.newPage(); - await dummyPage.waitForTimeout(2000); // arbitrary wait time. - const targets = await browser.targets(); - - const extensionTarget = targets.find(({ _targetInfo }) => { - return _targetInfo.title === extensionName && _targetInfo.type === 'background_page'; - }); - - const extensionUrl = extensionTarget._targetInfo.url || ''; - const [, , extensionID] = extensionUrl.split('/'); - - baseUrl = `chrome-extension://${extensionID}/${extensionPopupHtml}`; - - extensionPage = await browser.newPage(); - await extensionPage.goto(baseUrl); - }); - - afterAll(async () => { - extensionPage.close(); - }); - - beforeEach(async () => { - jest.setTimeout(10000); - }); - - test('Create An Account, Step 1 - Enter Account Name', async () => { - await extensionPage.waitForSelector('#addAccount'); - await extensionPage.click('#addAccount'); - await extensionPage.waitForSelector('#createAccount'); - await extensionPage.click('#createAccount'); - await extensionPage.type('#setAccountName', testNetAccount); - await extensionPage.click('#nextStep'); - }); - - test('Create An Account, Step 2 - Get Mnemonic', async () => { - await extensionPage.click('#accountAddress'); - createdAccountAddress = await extensionPage.$eval('#accountAddress', (e) => e.innerText); // setup for another test - - for (let i = 1; i <= 25; i++) { - mnemonicArray[i] = await extensionPage.$eval( - `[data-key-index="${i}"]`, - (e) => e.dataset['word'] - ); - } - await extensionPage.waitForSelector('#recordCheckbox'); - await extensionPage.click('#recordCheckbox'); - await extensionPage.click('#nextStep'); - }); - - test('Create An Account, Step 3 - Use Mnemonic', async () => { - await extensionPage.waitForSelector('#enterMnemonic'); - - for (let i = 1; i <= 25; i++) { - await extensionPage.waitForSelector('#' + mnemonicArray[i]); - await extensionPage.click(`#${mnemonicArray[i]}:not([disabled])`); - } - await extensionPage.click('#nextStep'); - }); - - test('Create an Account, Step 4 - Write Account into Storage', async () => { - await extensionPage.waitForSelector('#enterPassword'); - await extensionPage.type('#enterPassword', unsafePassword); - await extensionPage.waitForTimeout(200); - await extensionPage.click('#authButton'); - }); - - test('Verify Account is Created', async () => { - await extensionPage.waitForSelector('#account_' + testNetAccount); - await extensionPage.click('#account_' + testNetAccount); - await extensionPage.waitForTimeout(500); - await expect(extensionPage.$eval('#accountName', (e) => e.innerText)).resolves.toBe( - testNetAccount - ); - }); - - test('Load Account Details', async () => { - await extensionPage.click('#showDetails'); - await extensionPage.waitForSelector('#accountAddress'); - await expect(extensionPage.$eval('#accountAddress', (e) => e.innerText)).resolves.toBe( - createdAccountAddress - ); - await expect(extensionPage.$eval('#accountName', (e) => e.innerText)).resolves.toBe( - testNetAccount - ); - }); - - test('Delete Account', async () => { - await extensionPage.click('#deleteAccount'); - await extensionPage.type('#enterPassword', unsafePassword); - await extensionPage.waitForTimeout(200); - await extensionPage.click('#authButton'); - await extensionPage.waitForTimeout(2000); - }); - - test('Verify Account Deleted', async () => { - await extensionPage.waitForSelector('#addAccount'); - await expect(extensionPage.select('#accountAddress')).rejects.toThrow(); - await expect(extensionPage.select('#accountName')).rejects.toThrow(); - }); -}); diff --git a/packages/test-project/tests/common/helpers.js b/packages/test-project/tests/common/helpers.js index b218e477..9775e8ec 100644 --- a/packages/test-project/tests/common/helpers.js +++ b/packages/test-project/tests/common/helpers.js @@ -26,29 +26,40 @@ async function selectAccount(account) { await extensionPage.waitForTimeout(500); } +async function openAccountDetails(account) { + await selectAccount(account); + await extensionPage.waitForSelector('#accountName'); + await expect(extensionPage.$eval('#accountName', (e) => e.innerText)).resolves.toBe(account.name); + await extensionPage.click('#showDetails'); +} + async function goBack() { await extensionPage.click('#goBack'); - await extensionPage.waitForTimeout(500); + await extensionPage.waitForTimeout(250); } async function closeModal() { const modalSelector = '.modal.is-active button.modal-close'; await extensionPage.waitForSelector(modalSelector); await extensionPage.click(modalSelector); + await extensionPage.waitForTimeout(250); } // Dapp Helpers async function getPopup() { await dappPage.waitForTimeout(1500); const pages = await browser.pages(); - return pages[pages.length - 1]; + const popup = pages[pages.length - 1]; + + popup.on('console', (msg) => console.log('POPUP PAGE LOG:', msg.text())); + return popup; } -async function getLedgerParams() { - const params = await dappPage.evaluate(() => { +async function getLedgerSuggestedParams(ledger = 'TestNet') { + const params = await dappPage.evaluate((ledger) => { return Promise.resolve( AlgoSigner.algod({ - ledger: 'TestNet', + ledger: ledger, path: '/v2/transactions/params', }) .then((data) => { @@ -58,7 +69,7 @@ async function getLedgerParams() { return error; }) ); - }); + }, ledger); expect(params).toHaveProperty('consensus-version'); expect(params).toHaveProperty('fee'); @@ -67,24 +78,15 @@ async function getLedgerParams() { expect(params).toHaveProperty('genesis-hash'); expect(params).toHaveProperty('genesis-id'); expect(params).toHaveProperty('last-round'); - return params; -} -async function signTransaction(transaction) { - const signedTransaction = await dappPage.evaluate(async (transaction) => { - const signPromise = AlgoSigner.signMultisig(transaction) - .then((data) => { - return data; - }) - .catch((error) => { - return error; - }); - await window.authorizeSign(); - return await Promise.resolve(signPromise); - }, transaction); - await expect(signedTransaction).toHaveProperty('txID'); - await expect(signedTransaction).toHaveProperty('blob'); - return signedTransaction; + return { + fee: params['fee'], + flatFee: true, + firstRound: params['last-round'], + lastRound: params['last-round'] + 1000, + genesisID: params['genesis-id'], + genesisHash: params['genesis-hash'], + }; } async function sendTransaction(blob) { @@ -155,11 +157,11 @@ function appendSignToMultisigTransaction(partialTransaction, msigParams, mnemoni module.exports = { openExtension, selectAccount, + openAccountDetails, goBack, closeModal, getPopup, - getLedgerParams, - signTransaction, + getLedgerSuggestedParams, sendTransaction, base64ToByteArray, byteArrayToBase64, diff --git a/packages/test-project/tests/common/tests.js b/packages/test-project/tests/common/tests.js index cc2db8b7..efa66992 100644 --- a/packages/test-project/tests/common/tests.js +++ b/packages/test-project/tests/common/tests.js @@ -1,5 +1,5 @@ const { wallet, extension } = require('./constants'); -const { selectAccount, goBack, closeModal, getPopup } = require('./helpers'); +const { openAccountDetails, goBack, closeModal, getPopup } = require('./helpers'); // Common Tests async function WelcomePage() { @@ -55,13 +55,12 @@ async function ImportAccount(account) { await extensionPage.click('#authButton'); }); + VerifyAccount(account); +} + +async function VerifyAccount(account) { test(`Verify Account Info (${account.name})`, async () => { - await selectAccount(account); - await extensionPage.waitForSelector('#accountName'); - await expect(extensionPage.$eval('#accountName', (e) => e.innerText)).resolves.toBe( - account.name - ); - await extensionPage.click('#showDetails'); + await openAccountDetails(account); await expect(extensionPage.$eval('#accountAddress', (e) => e.innerText)).resolves.toBe( account.address ); @@ -70,6 +69,22 @@ async function ImportAccount(account) { }); } +async function DeleteAccount(account) { + test(`Delete Account (${account.name})`, async () => { + await openAccountDetails(account); + await extensionPage.click('#deleteAccount'); + await extensionPage.type('#enterPassword', wallet.password); + await extensionPage.waitForTimeout(200); + await extensionPage.click('#authButton'); + }); + + test('Verify Account Deleted', async () => { + await extensionPage.waitForSelector('#addAccount'); + const accountSelector = '#account_' + account.name.replace(/\s/g, ''); + await expect(extensionPage.select(accountSelector)).rejects.toThrow(); + }); +} + // Dapp Tests async function ConnectAlgoSigner() { test('Expose Authorize Functions', async () => { @@ -90,6 +105,31 @@ async function ConnectAlgoSigner() { await popup.click('#authButton'); } await dappPage.exposeFunction('authorizeSign', authorizeSign); + + async function authorizeSignTxn() { + const popup = await getPopup(); + + // Atomic txs Approval + try { + await popup.waitForTimeout(500); + const txAmount = await popup.$eval('.dropdown-trigger span', (e) => +e.innerText.slice(-1)); + + for (let i = 0; i < txAmount; i++) { + await popup.click('#toggleApproval'); + await popup.waitForTimeout(250); + } + } catch (e) { + // Single transaction + } + + await popup.waitForSelector('#approveTx'); + await popup.click('#approveTx'); + await popup.waitForSelector('#enterPassword'); + await popup.type('#enterPassword', wallet.password); + await popup.waitForSelector('#authButton'); + await popup.click('#authButton'); + } + await dappPage.exposeFunction('authorizeSignTxn', authorizeSignTxn); }); test('Connect Dapp through content.js', async () => { @@ -108,5 +148,7 @@ module.exports = { SelectTestNetLedger, CreateWallet, ImportAccount, + VerifyAccount, + DeleteAccount, ConnectAlgoSigner, }; diff --git a/packages/test-project/tests/dapp-multisig.test.js b/packages/test-project/tests/dapp-multisig.test.js index e0b7d61b..d54d1ca2 100644 --- a/packages/test-project/tests/dapp-multisig.test.js +++ b/packages/test-project/tests/dapp-multisig.test.js @@ -7,8 +7,7 @@ const { accounts } = require('./common/constants'); const { openExtension, - getLedgerParams, - signTransaction, + getLedgerSuggestedParams, sendTransaction, decodeBase64Blob, byteArrayToBase64, @@ -24,7 +23,22 @@ let ledgerParams; let multisigTransaction; let signedTransactions = []; -jest.setTimeout(10000); +async function signTransaction(transaction) { + const signedTransaction = await dappPage.evaluate(async (transaction) => { + const signPromise = AlgoSigner.signMultisig(transaction) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + await window.authorizeSign(); + return await Promise.resolve(signPromise); + }, transaction); + await expect(signedTransaction).toHaveProperty('txID'); + await expect(signedTransaction).toHaveProperty('blob'); + return signedTransaction; +} describe('Wallet Setup', () => { beforeAll(async () => { @@ -38,8 +52,7 @@ describe('dApp Setup', () => { ConnectAlgoSigner(); test('Get TestNet params', async () => { - ledgerParams = await getLedgerParams(); - console.log(`TestNet transaction params: [last-round: ${ledgerParams['last-round']}, ...]`); + ledgerParams = await getLedgerSuggestedParams(); multisigTransaction = { msig: { subsig: msigAccount.subaccounts.map((acc) => { @@ -53,11 +66,7 @@ describe('dApp Setup', () => { from: msigAccount.address, to: accounts.ui.address, amount: Math.ceil(Math.random() * 1000), - fee: ledgerParams['fee'], - firstRound: ledgerParams['last-round'], - lastRound: ledgerParams['last-round'] + 1000, - genesisID: ledgerParams['genesis-id'], - genesisHash: ledgerParams['genesis-hash'], + ...ledgerParams, }, }; }); diff --git a/packages/test-project/tests/dapp-signtxn.test.js b/packages/test-project/tests/dapp-signtxn.test.js new file mode 100644 index 00000000..e5accff6 --- /dev/null +++ b/packages/test-project/tests/dapp-signtxn.test.js @@ -0,0 +1,265 @@ +/** + * dapp e2e tests for the AlgoSigner V2 Signing functionality + * + * @group dapp/signtxn + */ + +const algosdk = require('algosdk'); +const { accounts } = require('./common/constants'); +const { + openExtension, + getPopup, + getLedgerSuggestedParams, + byteArrayToBase64, + decodeBase64Blob, +} = require('./common/helpers'); +const { CreateWallet, ConnectAlgoSigner, ImportAccount } = require('./common/tests'); + +const msigAccount = accounts.multisig; +const account1 = msigAccount.subaccounts[0]; +const account2 = msigAccount.subaccounts[1]; + +let ledgerParams; +let unsignedTransactions = []; + +const buildSdkTx = (tx) => { + return new algosdk.Transaction(tx); +}; + +const prepareWalletTx = (tx) => { + return { + txn: byteArrayToBase64(tx.toByte()), + }; +}; + +async function signTxn(transactions, testFunction) { + const timestampedName = `popupTest-${new Date().getTime().toString()}`; + if (testFunction) { + await dappPage.exposeFunction(timestampedName, async () => { + try { + await testFunction(); + } catch (e) { + console.log(e); + } + }); + } + + await dappPage.waitForTimeout(2000); + const signedTransactions = await dappPage.evaluate( + async (transactions, testFunction, testTimestamp) => { + const signPromise = AlgoSigner.signTxn(transactions) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + + if (testFunction) { + await window[testTimestamp](); + } + await window.authorizeSignTxn(); + return await Promise.resolve(signPromise); + }, + transactions, + !!testFunction, + timestampedName + ); + for (let i = 0; i < signedTransactions.length; i++) { + const signedTx = signedTransactions[i]; + if (signedTx) { + await expect(signedTx).toHaveProperty('txID'); + await expect(signedTx).toHaveProperty('blob'); + } + } + return signedTransactions; +} + +describe('Wallet Setup', () => { + beforeAll(async () => { + await openExtension(); + }); + + CreateWallet(); + + ImportAccount(account1); + ImportAccount(account2); +}); + +describe('dApp Setup', () => { + ConnectAlgoSigner(); + + test('Get TestNet params', async () => { + ledgerParams = await getLedgerSuggestedParams(); + }); +}); + +describe('Single and Global Transaction Use cases', () => { + test('Error on Missing account', async () => { + const invalidAccount = accounts.ui; + const txn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: invalidAccount.address, + to: invalidAccount.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + }) + ); + unsignedTransactions = [txn]; + + await expect( + dappPage.evaluate((transaction) => { + return Promise.resolve(AlgoSigner.signTxn(transaction)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unsignedTransactions) + ).resolves.toMatchObject({ + message: expect.stringContaining('No matching account'), + }); + }); + + test('Error on Empty signers for Single transactions', async () => { + const invalidAccount = accounts.ui; + const txn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: invalidAccount.address, + to: invalidAccount.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + }) + ); + txn.signers = []; + unsignedTransactions = [txn]; + + await expect( + dappPage.evaluate((transaction) => { + return Promise.resolve(AlgoSigner.signTxn(transaction)) + .then((data) => { + return data; + }) + .catch((error) => { + return error; + }); + }, unsignedTransactions) + ).resolves.toMatchObject({ + message: expect.stringContaining('Signers array should only'), + }); + }); + + // // @TODO: Wallet Transaction Structure check tests + + test('Warning on Group ID for Single Transactions', async () => { + const txn = buildSdkTx({ + type: 'pay', + from: account1.address, + to: account1.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + }); + unsignedTransactions = [prepareWalletTx(algosdk.assignGroupID([txn])[0])]; + + await signTxn(unsignedTransactions, async () => { + const popup = await getPopup(); + await popup.waitForSelector('#txAlerts'); + await expect( + popup.$$eval('#danger-tx-list b', (arr) => arr.map((item) => item.innerText.slice(0, -1))) + ).resolves.toContain('group'); + }); + }); + + test('Sign MultiSig Transaction with All Accounts', async () => { + const multisigTxn = prepareWalletTx( + buildSdkTx({ + type: 'pay', + from: msigAccount.address, + to: msigAccount.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + }) + ); + multisigTxn.msig = { + version: 1, + threshold: 2, + addrs: msigAccount.subaccounts.map((acc) => acc.address), + }; + + unsignedTransactions = [multisigTxn]; + const signedTransactions = await signTxn(unsignedTransactions, async () => { + const popup = await getPopup(); + const tooltipText = await popup.evaluate(() => { + return getComputedStyle( + document.querySelector('[data-tooltip]'), + '::before' + ).getPropertyValue('content'); + }); + await expect(tooltipText).toContain('Multisignature'); + }); + + // Verify signature is added + const decodedTransaction = decodeBase64Blob(signedTransactions[0].blob); + expect(decodedTransaction).toHaveProperty('txn'); + expect(decodedTransaction).toHaveProperty('msig'); + expect(decodedTransaction.msig).toHaveProperty('subsig'); + expect(decodedTransaction.msig.subsig.length).toBe(3); + expect(decodedTransaction.msig.subsig[0]).toHaveProperty('s'); + expect(decodedTransaction.msig.subsig[1]).toHaveProperty('s'); + expect(decodedTransaction.msig.subsig[2]).not.toHaveProperty('s'); + }); + + test('Sign MultiSig Transaction with Specific Signer', async () => { + unsignedTransactions[0].signers = [account1.address]; + const signedTransactions = await signTxn(unsignedTransactions); + + // Verify correctsignature is added + const decodedTransaction = decodeBase64Blob(signedTransactions[0].blob); + expect(decodedTransaction).toHaveProperty('txn'); + expect(decodedTransaction).toHaveProperty('msig'); + expect(decodedTransaction.msig).toHaveProperty('subsig'); + expect(decodedTransaction.msig.subsig.length).toBe(3); + expect(decodedTransaction.msig.subsig[0]).toHaveProperty('s'); + expect(decodedTransaction.msig.subsig[1]).not.toHaveProperty('s'); + expect(decodedTransaction.msig.subsig[2]).not.toHaveProperty('s'); + }); +}); + +describe('Group Transactions Use cases', () => { + test('Group Transaction with Reference Transaction', async () => { + const tx1 = buildSdkTx({ + type: 'pay', + from: account1.address, + to: account2.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + }); + const tx2 = buildSdkTx({ + type: 'pay', + from: account2.address, + to: account1.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + }); + const tx3 = buildSdkTx({ + type: 'pay', + from: account1.address, + to: account2.address, + amount: Math.ceil(Math.random() * 1000), + ...ledgerParams, + }); + + unsignedTransactions = await algosdk.assignGroupID([tx1, tx2, tx3]); + unsignedTransactions = unsignedTransactions.map((txn) => prepareWalletTx(txn)); + unsignedTransactions[2].signers = []; + + const signedTransactions = await signTxn(unsignedTransactions); + await expect(signedTransactions[2]).toBeNull(); + await expect(signedTransactions.filter((i) => i).length).toBe(2); + }); + + // @TODO: Add errors for mismatches, incomplete groups, etc +}); diff --git a/packages/test-project/tests/ui-accounts-e2e.test.js b/packages/test-project/tests/ui-accounts-e2e.test.js new file mode 100644 index 00000000..1f4aee7e --- /dev/null +++ b/packages/test-project/tests/ui-accounts-e2e.test.js @@ -0,0 +1,91 @@ +/** + * Basic e2e tests for the AlgoSigner UI + * + * @group ui/accounts + */ + +const { wallet } = require('./common/constants'); +const { openExtension } = require('./common/helpers'); +const { CreateWallet, VerifyAccount, DeleteAccount } = require('./common/tests'); + +describe('Wallet Setup', () => { + beforeAll(async () => { + await openExtension(); + }); + + CreateWallet(); +}); + +// Create a new account in AlgoSigner +describe('Create Account', () => { + const createdAccount = { + name: 'Created-Account', + }; + let mnemonicArray = []; + + test('Create An Account, Step 1 - Enter Account Name', async () => { + await extensionPage.waitForSelector('#addAccount'); + await extensionPage.click('#addAccount'); + await extensionPage.waitForSelector('#createAccount'); + await extensionPage.click('#createAccount'); + await extensionPage.type('#setAccountName', createdAccount.name); + await extensionPage.click('#nextStep'); + }); + + test('Create An Account, Step 2 - Get Mnemonic', async () => { + await extensionPage.click('#accountAddress'); + createdAccount.address = await extensionPage.$eval('#accountAddress', (e) => e.innerText); // setup for another test + + for (let i = 1; i <= 25; i++) { + mnemonicArray[i - 1] = await extensionPage.$eval( + `[data-key-index="${i}"]`, + (e) => e.dataset['word'] + ); + } + await extensionPage.waitForSelector('#recordCheckbox'); + await extensionPage.click('#recordCheckbox'); + await extensionPage.click('#nextStep'); + }); + + test('Create An Account, Step 3 - Use Mnemonic', async () => { + // Wait for mnemonic word buttons to be available + await extensionPage.waitForSelector('[data-index]:not([disabled])'); + + // We test the remove word functionality by first filling with random words + const randomWords = []; + for (let i = 0; i < 5; i++) { + randomWords.push(mnemonicArray[i * 5 + Math.round(Math.random() * 4)]); + await extensionPage.click(`#${randomWords[i]}:not([disabled]):not(.is-link)`); + } + await expect(extensionPage.$eval('#enterMnemonic', (e) => e.value)).resolves.toBe( + randomWords.join(' ') + ); + await extensionPage.waitForTimeout(200); + for (let i = 0; i < 5; i++) { + await extensionPage.click(`button.is-small.is-link`); + } + await expect(extensionPage.$eval('#enterMnemonic', (e) => e.value)).resolves.toBe(''); + await extensionPage.waitForTimeout(200); + + // We build the actual mnemonic + for (let i = 0; i < 25; i++) { + await extensionPage.click(`#${mnemonicArray[i]}:not([disabled]):not(.is-link)`); + } + await expect(extensionPage.$eval('#enterMnemonic', (e) => e.value)).resolves.toBe( + mnemonicArray.join(' ') + ); + + await extensionPage.click('#nextStep'); + }); + + test('Create an Account, Step 4 - Write Account into Storage', async () => { + await extensionPage.waitForSelector('#enterPassword'); + await extensionPage.type('#enterPassword', wallet.password); + await extensionPage.waitForTimeout(200); + await extensionPage.click('#authButton'); + }); + + VerifyAccount(createdAccount); + + DeleteAccount(createdAccount); +}); diff --git a/packages/test-project/tests/ui-transactions-e2e.test.js b/packages/test-project/tests/ui-transactions-e2e.test.js new file mode 100644 index 00000000..577cf923 --- /dev/null +++ b/packages/test-project/tests/ui-transactions-e2e.test.js @@ -0,0 +1,184 @@ +/** + * Basic e2e tests for the AlgoSigner UI + * + * @group ui/transactions + */ + +const { accounts, wallet } = require('./common/constants'); +const { openExtension, selectAccount, closeModal, goBack } = require('./common/helpers'); +const { CreateWallet, ImportAccount, DeleteAccount } = require('./common/tests'); + +describe('Wallet Setup', () => { + beforeAll(async () => { + await openExtension(); + }); + + CreateWallet(); + + ImportAccount(accounts.ui); +}); + +describe('UI Transactions Tests', () => { + const amount = Math.ceil(Math.random() * 9); // txn size, modify multiplier for bulk + + let txId; // returned tx id from send txn + let txTitle; // for tx verification + + beforeEach(async () => { + jest.setTimeout(15000); + }); + + afterAll(async () => { + extensionPage.close(); + }); + + const verifyTransaction = async () => { + const txSelector = `[data-transaction-id="${txId}"]`; + await extensionPage.waitForSelector(txSelector); + await extensionPage.click(txSelector); + await expect(extensionPage.$eval('#txTitle', (e) => e.innerText)).resolves.toBe(txTitle); + await expect( + extensionPage.$eval( + '.modal.is-active [data-transaction-id]', + (e) => e.dataset['transactionId'] + ) + ).resolves.toBe(txId); + await expect( + extensionPage.$eval( + '.modal.is-active [data-transaction-sender]', + (e) => e.dataset['transactionSender'] + ) + ).resolves.toBe(accounts.ui.address); + await closeModal(); + }; + + const returnToAccount = async () => { + await extensionPage.waitForTimeout(1000); + await extensionPage.click('#backToAccount'); + await extensionPage.waitForTimeout(1000); + return; + }; + + test('Send Algos Transaction', async () => { + await selectAccount(accounts.ui); + + await extensionPage.click('#sendTransfer'); + await extensionPage.waitForTimeout(100); + await extensionPage.type('#transferAmount', amount.toString()); + await extensionPage.type('#toAddress', accounts.ui.address); + await extensionPage.type('#note', 'AutoTest Send Algo'); + await extensionPage.click('#submitTransfer'); + await extensionPage.waitForSelector('#enterPassword'); + await extensionPage.type('#enterPassword', wallet.password); + await extensionPage.waitForSelector('#authButton'); + await extensionPage.click('#authButton'); + await extensionPage.waitForSelector('#txId'); + // setup tx details for next test + txId = await extensionPage.$eval('#txId', (e) => e.innerText); + txTitle = 'Payment'; + await returnToAccount(); + }); + + test('Verify transaction', verifyTransaction); + + test('Check Asset details', async () => { + const assetSelector = '[data-asset-id]'; + await extensionPage.waitForSelector(assetSelector); + const assetId = await extensionPage.$eval(assetSelector, (e) => e.dataset['assetId']); + const assetBalance = await extensionPage.$eval(assetSelector, (e) => e.dataset['assetBalance']); + await extensionPage.click(assetSelector); + await expect( + extensionPage.$eval(`.modal ${assetSelector}`, (e) => e.dataset['assetId']) + ).resolves.toBe(assetId); + await expect( + extensionPage.$eval(`.modal ${assetSelector}`, (e) => e.dataset['assetBalance']) + ).resolves.toBe(assetBalance); + await closeModal(); + const thereIsFullAssetList = await extensionPage.$('#showAssets'); + if (thereIsFullAssetList) { + await extensionPage.click('#showAssets'); + await extensionPage.waitForSelector(`.modal [data-asset-id="${assetId}"]`); + await closeModal(); + } + await goBack(); + }); + + test('Send Asset Transaction', async () => { + await selectAccount(accounts.ui); + + await extensionPage.click('#sendTransfer'); + await extensionPage.waitForTimeout(100); + await extensionPage.click('#selectAsset'); + await extensionPage.waitForTimeout(100); + await extensionPage.waitForSelector('#asset-13169404'); + await extensionPage.click('#asset-13169404'); + await extensionPage.waitForTimeout(500); + // Test correct decimal handling + await extensionPage.type('#transferAmount', `0.000000000${amount}`); + const actualAmount = await extensionPage.$eval('#transferAmount', (e) => { + const inputValue = e.value; + e.value = ''; + return inputValue; + }); + expect(actualAmount).toMatch('0.000000'); + // Test actual transfer + await extensionPage.type('#transferAmount', `0.00000${amount}`); + await extensionPage.type('#toAddress', accounts.ui.address); + await extensionPage.type('#note', 'AutoTest Send E2E Asset'); + await extensionPage.click('#submitTransfer'); + await extensionPage.waitForSelector('#enterPassword'); + await extensionPage.type('#enterPassword', wallet.password); + await extensionPage.waitForSelector('#authButton'); + await extensionPage.click('#authButton'); + await extensionPage.waitForSelector('#txId'); + txId = await extensionPage.$eval('#txId', (e) => e.innerText); + txTitle = 'Asset transfer'; + await returnToAccount(); + }); + + test('Verify transaction', verifyTransaction); + + test('Transaction Errors: OverSpend', async () => { + await extensionPage.click('#sendTransfer'); + await extensionPage.waitForSelector('#transferAmount'); + await extensionPage.type('#transferAmount', '900000'); + await extensionPage.type('#toAddress', accounts.ui.address); + await extensionPage.type('#note', 'AutoTest Overspend Algo'); + await extensionPage.click('#submitTransfer'); + await extensionPage.waitForSelector('#enterPassword'); + await extensionPage.type('#enterPassword', wallet.password); + await extensionPage.waitForSelector('#authButton'); + await extensionPage.click('#authButton'); + await extensionPage.waitForSelector('#tx-error'); + + let pageError = await extensionPage.$eval('#tx-error', (e) => e.innerText); + await expect(pageError).toMatch("Overspending. Your account doesn't have sufficient funds."); + + await closeModal(); + await goBack(); + }); + + test('Transaction Errors: Invalid Field - Amount', async () => { + await extensionPage.click('#sendTransfer'); + await extensionPage.waitForSelector('#transferAmount'); + await extensionPage.type('#transferAmount', '9999999999.999999'); + await extensionPage.type('#toAddress', accounts.ui.address); + await extensionPage.type('#note', 'AutoTest Invalid Amount'); + await extensionPage.click('#submitTransfer'); + await extensionPage.waitForSelector('#enterPassword'); + await extensionPage.type('#enterPassword', wallet.password); + await extensionPage.waitForSelector('#authButton'); + await extensionPage.click('#authButton'); + await extensionPage.waitForSelector('#tx-error'); + + let pageError = await extensionPage.$eval('#tx-error', (e) => e.innerText); + expect(pageError).toMatch('One or more fields are not valid. Please check and try again.'); + + await closeModal(); + await goBack(); + // Extra goBack to main screen + await goBack(); + }); + + DeleteAccount(accounts.ui); +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index ef09fef9..11cfafc8 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,7 +1,9 @@ { "name": "algosigner-ui", - "version": "1.5.2", + "version": "1.6.0", "author": "https://developer.purestake.io", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "description": "User interface built for AlgoSigner.", "private": true, "scripts": { @@ -12,11 +14,14 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^5.15.0", + "@ledgerhq/hw-app-algorand": "^5.51.1", + "@ledgerhq/hw-transport-webhid": "^5.46.0", + "algosdk": "1.8.1", "history": "^5.0.0", "htm": "^3.0.4", "mobx": "^5.15.6", "mobx-react-lite": "^2.2.2", - "preact": "^10.4.8", + "preact": "10.5.13", "preact-router": "^3.2.1", "qrcode-generator": "^1.4.4", "zxcvbn": "^4.4.2" @@ -33,16 +38,16 @@ "file-loader": "^6.1.0", "html-webpack-plugin": "^4.4.1", "identity-obj-proxy": "^3.0.0", - "jest": "^26.4.2", + "jest": "27.0.0", "jest-webextension-mock": "^3.6.1", "mini-css-extract-plugin": "^1.6.0", "sass": "^1.26.10", "sass-loader": "^8.0.2", - "ts-jest": "^26.3.0", + "ts-jest": "^27.0.0", "ts-loader": "^7.0.1", "typescript": "^3.9.7", - "webpack": "^5.39.1", - "webpack-cli": "^4.7.2", + "webpack": "^4.44.1", + "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.11.2" } } diff --git a/packages/ui/src/components/Account/AccountDetails.test.ts b/packages/ui/src/components/Account/AccountDetails.test.ts index ce867684..8f0a3b94 100644 --- a/packages/ui/src/components/Account/AccountDetails.test.ts +++ b/packages/ui/src/components/Account/AccountDetails.test.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { shallow } from 'enzyme'; import { html } from 'htm/preact'; import AccountDetails from './AccountDetails'; diff --git a/packages/ui/src/components/Account/AddAssetConfirm.ts b/packages/ui/src/components/Account/AddAssetConfirm.ts index 4ccb1c83..1fa729da 100644 --- a/packages/ui/src/components/Account/AddAssetConfirm.ts +++ b/packages/ui/src/components/Account/AddAssetConfirm.ts @@ -1,17 +1,15 @@ -import { FunctionalComponent } from "preact"; +import { FunctionalComponent } from 'preact'; import { html } from 'htm/preact'; -import { useContext, useState } from 'preact/hooks'; +import { useState } from 'preact/hooks'; import { route } from 'preact-router'; import { JsonRpcMethod } from '@algosigner/common/messaging/types'; -import { sendMessage } from 'services/Messaging' -import { StoreContext } from 'services/StoreContext' +import { sendMessage } from 'services/Messaging'; -import Authenticate from 'components/Authenticate' +import Authenticate from 'components/Authenticate'; const AddAssetConfirm: FunctionalComponent = (props: any) => { const { asset, ledger, address, accountsAssetsIDs } = props; - const store:any = useContext(StoreContext); const [askAuth, setAskAuth] = useState(false); const [loading, setLoading] = useState(false); const [authError, setAuthError] = useState(''); @@ -26,21 +24,21 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => { passphrase: pwd, address: address, txnParams: { - type: "axfer", + type: 'axfer', assetIndex: asset['asset_id'], from: address, to: address, - amount: 0 - } + amount: 0, + }, }; setLoading(true); setAuthError(''); - sendMessage(JsonRpcMethod.SignSendTransaction, params, function(response) { - if ('error' in response) { + sendMessage(JsonRpcMethod.SignSendTransaction, params, function (response) { + if (response && 'error' in response) { setLoading(false); switch (response.error) { - case "Login Failed": + case 'Login Failed': setAuthError('Wrong passphrase'); break; default: @@ -56,7 +54,10 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => { }; return html` - ${ !askAuth && txId.length === 0 && !error && html` + ${!askAuth && + txId.length === 0 && + !error && + html`
Adding Asset @@ -64,17 +65,17 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => { ${asset['asset_id']}
- - ${ asset.name && html` + ${asset.name && + html`
Asset name ${asset.name}
`} - ${ asset.name && asset['unit_name'] && html` -
- `} - ${ asset['unit_name'] && asset['unit_name'].length > 0 && html` + ${asset.name && asset['unit_name'] && html`
`} + ${asset['unit_name'] && + asset['unit_name'].length > 0 && + html`
Unit name ${asset['unit_name']} @@ -82,9 +83,13 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => { `} @@ -97,32 +102,37 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => { id="addAsset" class="button is-primary is-fullwidth" onClick=${() => setAskAuth(true)} - disabled=${disabled}> + disabled=${disabled} + > ${disabled ? 'You already added this asset' : 'Add asset!'}
`} - - ${txId.length > 0 && html` + ${txId.length > 0 && + html`
Transaction sent!
`} - - ${ error !== undefined && error.length > 0 && html` + ${error !== undefined && + error.length > 0 && + html`

Transaction failed with the following error: @@ -130,14 +140,9 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => {

${error}

`} - - ${askAuth && html` - <${Authenticate} - error=${authError} - loading=${loading} - nextStep=${addAsset} /> - `} - ` + ${askAuth && + html` <${Authenticate} error=${authError} loading=${loading} nextStep=${addAsset} /> `} + `; }; -export default AddAssetConfirm; \ No newline at end of file +export default AddAssetConfirm; diff --git a/packages/ui/src/components/Account/AssetDetails.test.ts b/packages/ui/src/components/Account/AssetDetails.test.ts index f88448d1..8201adde 100644 --- a/packages/ui/src/components/Account/AssetDetails.test.ts +++ b/packages/ui/src/components/Account/AssetDetails.test.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { shallow } from 'enzyme'; import { html } from 'htm/preact'; import AssetDetails from './AssetDetails'; diff --git a/packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts b/packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts new file mode 100644 index 00000000..e2af3a93 --- /dev/null +++ b/packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts @@ -0,0 +1,136 @@ +import { FunctionalComponent } from 'preact'; +import { html } from 'htm/preact'; +import { useState } from 'preact/hooks'; +import { sendMessage } from 'services/Messaging'; +import { JsonRpcMethod } from '@algosigner/common/messaging/types'; +import Authenticate from 'components/Authenticate'; +import { ledgerActions } from './structure/ledgerActions'; + +const LedgerHardwareConnector: FunctionalComponent = (props: any) => { + const { ledger } = props; + const [name, setName] = useState(''); + const [askAuth, setAskAuth] = useState(false); + const [loading, setLoading] = useState(false); + const [isComplete, setIsComplete] = useState(false); + const [authError, setAuthError] = useState(''); + const [error, setError] = useState(''); + + // The save address requires a connection to the ledger device via a web page + // This page acts as the extension opened in a new tab + const saveLedgerAddress = (pwd) => { + setLoading(true); + setAuthError(''); + setError(''); + + // Obtain a leger address from the device + ledgerActions.getAddress().then((response) => { + // If we have an error display as normal, otherwise add the address to the saved profile + if ('error' in response) { + setLoading(false); + setAskAuth(false); + setError(`Error getting address from the Ledger hardware device. ${response['error']}`); + } else { + const params = { + passphrase: pwd, + name: name.trim(), + ledger: ledger, + hexAddress: response.message, + }; + + sendMessage(JsonRpcMethod.LedgerSaveAccount, params, function (response) { + setLoading(false); + setAuthError(''); + setError(''); + if ('error' in response) { + switch (response['error']) { + case 'Login Failed': + setAuthError('Wrong passphrase'); + break; + default: + setAskAuth(false); + setError( + `Error saving address from the Ledger hardware device. ${response['error']}` + ); + break; + } + } else { + setAskAuth(false); + setIsComplete(true); + } + }); + } + }).catch((response)=>{ + setLoading(false); + setAskAuth(false); + setError('Error getting address from the Ledger hardware device.'); + if ('error' in response) { + setError(`Error: ${response['error']}`); + } + }); + }; + + return html` +
+
+

Link ${ledger} account to AlgoSigner

+
+ + ${isComplete && + html` +
+

New account ${name} added for ${ledger}.

+

You may now close this site and relaunch AlgoSigner.

+
+ `} + ${isComplete === false && + html` +
+ setName(e.target.value)} + /> + +

+ Insert the hardware device and verify the Algorand application is open. +

+

+ ${error !== undefined && error.length > 0 && error} +

+
+
+ +
+ `} +
+ + ${askAuth && + html` + + `} + `; +}; + +export default LedgerHardwareConnector; diff --git a/packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts b/packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts new file mode 100644 index 00000000..ac267f3e --- /dev/null +++ b/packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts @@ -0,0 +1,211 @@ +import { FunctionalComponent } from 'preact'; +import { html } from 'htm/preact'; +import { useContext, useEffect, useState } from 'preact/hooks'; +import { sendMessage } from 'services/Messaging'; +import { JsonRpcMethod } from '@algosigner/common/messaging/types'; +import { ledgerActions } from './structure/ledgerActions'; +import { getBaseSupportedLedgers } from '@algosigner/common/types/ledgers'; +import TxAcfg from 'components/SignTransaction/TxAcfg'; +import TxPay from 'components/SignTransaction/TxPay'; +import TxKeyreg from 'components/SignTransaction/TxKeyreg'; +import TxAxfer from 'components/SignTransaction/TxAxfer'; +import TxAfrz from 'components/SignTransaction/TxAfrz'; +import TxAppl from 'components/SignTransaction/TxAppl'; +import { logging } from '@algosigner/common/logging'; +import { StoreContext } from 'services/StoreContext'; + +const LedgerHardwareSign: FunctionalComponent = () => { + const store: any = useContext(StoreContext); + const [loading, setLoading] = useState(false); + const [txn, setTxn] = useState({}); + const [isComplete, setIsComplete] = useState(false); + const [error, setError] = useState(''); + const [account, setAccount] = useState(''); + const [txResponseHeader, setTxResponseHeader] = useState(''); + const [txResponseDetail, setTxResponseDetail] = useState(''); + const [ledger, setLedger] = useState(''); + + useEffect(() => { + if (txn.transaction === undefined && error === '') { + try { + sendMessage(JsonRpcMethod.LedgerGetSessionTxn, {}, function (response) { + if (response.error) { + setError(response.error); + } else { + getBaseSupportedLedgers().forEach((l) => { + if (response.transaction.genesisID === l['genesisId']) { + setLedger(l['name']); + + // Update the ledger dropdown to the signing one + sendMessage(JsonRpcMethod.ChangeLedger, { ledger: l['name'] }, function () { + store.setLedger(l['name']); + }); + } + }); + + // Update account value to the signer + setAccount(response.transaction.from); + + // Set the visible transaction + setTxn(response); + } + }); + } catch (ex) { + setError('Error retrieving transaction from AlgoSigner.'); + logging.log(`${JSON.stringify(ex)}`, 2); + } + } + }); + + const ledgerSignTransaction = () => { + setLoading(true); + setError(''); + ledgerActions.signTransaction(txn).then((lar) => { + if (lar.error) { + setError(lar.error); + setLoading(false); + return; + } + const b64Response = Buffer.from(lar.message, 'base64').toString('base64'); + sendMessage(JsonRpcMethod.LedgerSendTxnResponse, { txn: b64Response }, function (response) { + logging.log(`UI: Ledger response: ${JSON.stringify(response)}`, 2); + if (response && 'error' in response) { + setError(response['error']); + } + + if (response && 'txId' in response) { + setTxResponseHeader('Transaction sent:'); + setTxResponseDetail(response['txId']); + setIsComplete(true); + } else { + console.log(`response: ${JSON.stringify(response)}`); + setTxResponseHeader('Transaction signed. Result sent to origin tab.'); + setTxResponseDetail(JSON.stringify(response)); + setIsComplete(true); + } + + setLoading(true); + }); + }); + }; + + return html` +
+
+
+

Sign Using Ledger Device

+
+ ${isComplete && + html` +
+

${txResponseHeader}

+

${txResponseDetail}

+

You may now close this site and relaunch AlgoSigner.

+
+ `} + ${isComplete === false && + html` +
+
+

Insert the hardware device and verify the Algorand application is open before + continuing. Review data on the device for correctness. +

+
+ ${txn && + txn.transaction && + html` +
+ ${txn.transaction.type === 'pay' && + html` + <${TxPay} + tx=${txn.transaction} + vo=${txn.validityObject} + fee=${txn.estimatedFee} + account=${account} + ledger=${ledger} + /> + `} + ${txn.transaction.type === 'keyreg' && + html` + <${TxKeyreg} + tx=${txn.transaction} + vo=${txn.validityObject} + fee=${txn.estimatedFee} + account=${account} + ledger=${ledger} + /> + `} + ${txn.transaction.type === 'acfg' && + html` + <${TxAcfg} + tx=${txn.transaction} + vo=${txn.validityObject} + dt=${txn.txDerivedTypeText} + fee=${txn.estimatedFee} + account=${account} + ledger=${ledger} + /> + `} + ${txn.transaction.type === 'axfer' && + html` + <${TxAxfer} + tx=${txn.transaction} + vo=${txn.validityObject} + dt=${txn.txDerivedTypeText} + fee=${txn.estimatedFee} + da=${txn.displayAmount} + un=${txn.unitName} + account=${account} + ledger=${ledger} + /> + `} + ${txn.transaction.type === 'afrz' && + html` + <${TxAfrz} + tx=${txn.transaction} + vo=${txn.validityObject} + fee=${txn.estimatedFee} + account=${account} + ledger=${ledger} + /> + `} + ${txn.transaction.type === 'appl' && + html` + <${TxAppl} + tx=${txn.transaction} + vo=${txn.validityObject} + fee=${txn.estimatedFee} + account=${account} + ledger=${ledger} + /> + `} +
+ `} +
+ `} +
+ ${isComplete === false && + html` +
+

${error !== undefined && error.length > 0 && error}

+ +
+ `} +
+ `; +}; + +export default LedgerHardwareSign; diff --git a/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts b/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts new file mode 100644 index 00000000..59a9dc33 --- /dev/null +++ b/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts @@ -0,0 +1,204 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import transport from './ledgerTransport'; +const algosdk = require('algosdk'); +const Algorand = require('@ledgerhq/hw-app-algorand'); +import { Transaction } from 'algosdk/src/transaction'; +import LedgerActionResponse from './ledgerActionsResponse'; + +let ledgerTransport: typeof Algorand; +const _PATH = "44'/60'/0'/0/0"; + +const getDevice = async () => { + // Check for the presence of an Algorand Ledger transport and return it if one exists + if (ledgerTransport) { + return ledgerTransport; + } + + try { + // Create a transport + // TODO: Expand beyond hid, like bluetooth + const newTransport = await transport.hid.create(); + + // After obtaining the transport use it to create the Algorand Ledger transport + ledgerTransport = new Algorand.default(newTransport); + } + catch(e) { + if(e && ('message' in e)) { + throw e; + } + else { + return {'message': 'Error creating the ledger transport. Please ensure device is connected and the Algorand app is open.'} + } + } + return ledgerTransport; +}; + +// Check to see if a Ledger device is available at all +const isAvailable = async (): Promise => { + const ledgerDevice = await getDevice().catch(() => { + // If we have an error then it is not available + return false; + }); + + // If we now have a device, return true + if (ledgerDevice !== undefined) { + return true; + } + return false; +}; + +/// +// Takes an unsigned decoded transaction object and converts strings into Uint8Arrays +// for note, appArgs, approval and close programs. Then returns a transactionBuilder encoded value +/// +function cleanseBuildEncodeUnsignedTransaction(transaction: any): any { + const txn = { ...transaction }; + const errors = new Array(); + Object.keys({ ...transaction }).forEach((key) => { + if (txn[key] === undefined || txn[key] === null) { + delete txn[key]; + } + }); + + // Modify base64 encoded fields + if ('note' in txn && txn.note) { + if (JSON.stringify(txn.note) === '{}') { + // If we got here from converting a blank note Uint8 value to an object we should remove it + txn.note = undefined; + } else { + txn.note = new Uint8Array(Buffer.from(txn.note)); + } + } + + // Application transactions only + if (txn.type == 'appl') { + if ('appApprovalProgram' in txn) { + try { + txn.appApprovalProgram = Uint8Array.from(Buffer.from(txn.appApprovalProgram, 'base64')); + } catch { + errors.push('Error trying to parse appApprovalProgram into a Uint8Array value.'); + } + } + if ('appClearProgram' in txn) { + try { + txn.appClearProgram = Uint8Array.from(Buffer.from(txn.appClearProgram, 'base64')); + } catch { + errors.push('Error trying to parse appClearProgram into a Uint8Array value.'); + } + } + if ('appArgs' in txn) { + try { + const tempArgs = new Array(); + txn.appArgs.forEach((element) => { + tempArgs.push(Uint8Array.from(Buffer.from(element, 'base64'))); + }); + txn.appArgs = tempArgs; + } catch { + errors.push('Error trying to parse appArgs into Uint8Array values.'); + } + } + } + + const builtTxn = new Transaction(txn); + + if ('group' in txn && txn['group']) { + // Remap group field lost from cast + builtTxn.group = Buffer.from(txn['group'], 'base64'); + } + + // Encode the transaction and join any errors for return + const encodedTxn = algosdk.encodeUnsignedTransaction(builtTxn); + return { transaction: encodedTxn, error: errors.join() }; +} + +const getAddress = async (): Promise => { + let lar: LedgerActionResponse = {}; + + // If we haven't connected yet, do it now. This will prompt the tab to ask for device. + if (!ledgerTransport) { + ledgerTransport = await getDevice().catch((e) => { + // If this is a known error from Ledger it will contain a message + lar = + e && 'message' in e + ? { error: e.message } + : { error: 'An unknown error has occured in connecting the Ledger device.' }; + }); + } + + // Return error if we have one + if(lar.error) { + return lar; + } + + // Now attempt to get the default Algorand address + await ledgerTransport + .getAddress(_PATH) + .then((o: any) => { + lar = { message: o.publicKey }; + }) + .catch((e) => { + // If this is a known error from Ledger it will contain a message + lar = + e && 'message' in e + ? { error: e.message } + : { error: 'An unknown error has occured in connecting the Ledger device.' }; + }); + + return lar; +}; + +const signTransaction = async (txn: any): Promise => { + let lar: LedgerActionResponse = {}; + + // If we haven't connected yet, do it now. This will prompt the tab to ask for device. + if (!ledgerTransport) { + ledgerTransport = await getDevice().catch((e) => { + // If this is a known error from Ledger it will contain a message + lar = + e && 'message' in e + ? { error: e.message } + : { error: 'An unknown error has occured in connecting the Ledger device.' }; + }); + } + + // Return error if we have one + if(lar.error) { + return lar; + } + + // Sign method accesps a message that is "hex" format, need to convert + // and remove any empty fields before the conversion + const txnResponse = cleanseBuildEncodeUnsignedTransaction(txn.transaction); + const message = Buffer.from(txnResponse.transaction).toString('hex'); + + // Send the hex transaction to the Ledger device for signing + await ledgerTransport + .sign(_PATH, message) + .then((o: any) => { + // The device responds with a signature only. We need to build the typical signed transaction + const txResponse = { + sig: o.signature, + txn: algosdk.decodeObj(txnResponse.transaction), + }; + + // Convert to binary for return + lar = { message: new Uint8Array(algosdk.encodeObj(txResponse)) }; + }) + .catch((e) => { + // If this is a known error from Ledger it will contain a message + lar = + e && 'message' in e + ? { error: e.message } + : { error: 'An unknown error has occured in connecting the Ledger device.' }; + }); + + return lar; +}; + +export const ledgerActions = { + isAvailable, + getAddress, + signTransaction, +}; + +export default ledgerActions; diff --git a/packages/ui/src/components/LedgerDevice/structure/ledgerActionsResponse.ts b/packages/ui/src/components/LedgerDevice/structure/ledgerActionsResponse.ts new file mode 100644 index 00000000..d4443524 --- /dev/null +++ b/packages/ui/src/components/LedgerDevice/structure/ledgerActionsResponse.ts @@ -0,0 +1,5 @@ +// Generalized return type for Ledger actions +export default interface LedgerActionResponse { + message?: any; + error?: string; +} diff --git a/packages/ui/src/components/LedgerDevice/structure/ledgerTransport.ts b/packages/ui/src/components/LedgerDevice/structure/ledgerTransport.ts new file mode 100644 index 00000000..4219e69a --- /dev/null +++ b/packages/ui/src/components/LedgerDevice/structure/ledgerTransport.ts @@ -0,0 +1,8 @@ +// Transport connection methods +import TransportWebHID from '@ledgerhq/hw-transport-webhid'; + +const transport = { + hid: TransportWebHID, +}; + +export default transport; diff --git a/packages/ui/src/components/SettingsMenu.ts b/packages/ui/src/components/SettingsMenu.ts index 5c14e7f6..035c5ef7 100644 --- a/packages/ui/src/components/SettingsMenu.ts +++ b/packages/ui/src/components/SettingsMenu.ts @@ -44,7 +44,7 @@ const SettingsMenu: FunctionalComponent = () => { }; return html` -
+