diff --git a/README.md b/README.md index 5fb52835..4f39c207 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,16 @@ As part of maintaining the standards set by the Algorand Foundation, we've begun - v1 Signing (`AlgoSigner.sign()` && `AlgoSigner.multisign()`) will stop being supported in the next major release. - Preliminary error codes were added to all of the errors that AlgoSigner could provide. -Also, a `Clear Cache` button was added to the Settings menu to help clear out issues mostly for developers. - -### Other changes +Other changes +- A developer-oriented `Clear Cache` button was added to the Settings menu to help out with certain issues - Fixed account name sometimes not being visible during signing. - When signing more than one group of transactions, there's now an Indicator on which group you're currently signing. +### 1.7.1 Patch + +- Added support for saving addresses as 'Contacts' for easier re-use +- Added support for importing more than one address from a same ledger device + ## New Users - Watch [Getting Started with AlgoSigner](https://youtu.be/tG-xzG8r770) diff --git a/docs/ledger.md b/docs/ledger.md index 22074fc7..d314f17a 100644 --- a/docs/ledger.md +++ b/docs/ledger.md @@ -1,7 +1,7 @@ # 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. +Allow the AlgoSigner extension to work with Ledger device accounts via HID for transaction signing on pay and asset transactions. This includes transactions originating in the extension and from DApps. It does not work with multiple transactions found in groups and multisig. It supports adding the account to your normal accounts tracking in the extension to supply both methods with the ability to sign. diff --git a/package.json b/package.json index e4d92c87..6ade33af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "algosigner", - "version": "1.7.0", + "version": "1.7.1", "author": "https://developer.purestake.io", "description": "Sign Algorand transactions in your browser with PureStake.", "repository": "https://github.com/PureStake/algosigner", diff --git a/packages/common/package.json b/packages/common/package.json index f42790c9..e8bfb24f 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@algosigner/common", - "version": "1.7.0", + "version": "1.7.1", "author": "https://developer.purestake.io", "description": "Common library functions for AlgoSigner.", "repository": "https://github.com/PureStake/algosigner", diff --git a/packages/common/src/messaging/types.ts b/packages/common/src/messaging/types.ts index d85ad85b..0436d04c 100644 --- a/packages/common/src/messaging/types.ts +++ b/packages/common/src/messaging/types.ts @@ -42,6 +42,9 @@ export enum JsonRpcMethod { CheckNetwork = 'check-network', DeleteNetwork = 'delete-network', GetLedgers = 'get-ledgers', + GetContacts = 'get-contacts', + SaveContact = 'save-contact', + DeleteContact = 'delete-contact', // Ledger Device Methods LedgerSaveAccount = 'ledger-save-account', diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 652dab4b..e9bc40fa 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -1,6 +1,6 @@ { "name": "algosigner-crypto", - "version": "1.7.0", + "version": "1.7.1", "author": "https://developer.purestake.io", "description": "Cryptographic wrapper for saving and retrieving extention information in Algosigner.", "repository": { diff --git a/packages/dapp/package.json b/packages/dapp/package.json index 04a40086..6782c45c 100644 --- a/packages/dapp/package.json +++ b/packages/dapp/package.json @@ -1,6 +1,6 @@ { "name": "@algosigner/dapp", - "version": "1.7.0", + "version": "1.7.1", "author": "https://developer.purestake.io", "repository": "https://github.com/PureStake/algosigner", "license": "MIT", diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index 03e38a09..a0c99dd7 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.7.0", + "version": "1.7.1", "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 393c71bd..3af1e20d 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "algosigner-extension", - "version": "1.7.0", + "version": "1.7.1", "author": "https://developer.purestake.io", "repository": "https://github.com/PureStake/algosigner", "license": "MIT", diff --git a/packages/extension/src/background/messaging/internalMethods.ts b/packages/extension/src/background/messaging/internalMethods.ts index 0f863b23..f1198cda 100644 --- a/packages/extension/src/background/messaging/internalMethods.ts +++ b/packages/extension/src/background/messaging/internalMethods.ts @@ -108,6 +108,8 @@ export class InternalMethods { } public static [JsonRpcMethod.CreateWallet](request: any, sendResponse: Function) { + const extensionStorage = new ExtensionStorage(); + extensionStorage.setStorage('contacts', [], null); this._encryptionWrap = new encryptionWrap(request.body.params.passphrase); const newWallet = { TestNet: [], @@ -325,14 +327,16 @@ export class InternalMethods { 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) as any; + 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; + // Set the fee to the estimate we showed on the screen for validation if there is one. + if(session.txnWrap.body.params.estimatedFee) { + 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 @@ -364,10 +368,21 @@ export class InternalMethods { //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, - }; + + // If v2 then it needs to return an array + if (session.txnWrap?.body?.params?.transactionsOrGroups) { + message.response = [{ + blob: request.body.params.txn + }]; + } + else { + 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') { @@ -867,14 +882,13 @@ export class InternalMethods { public static [JsonRpcMethod.CheckNetwork](request: any, sendResponse: Function) { try { - const networks = Settings.checkNetwork(request.body.params) + const networks = Settings.checkNetwork(request.body.params); sendResponse(networks); - } - catch (e) { + } catch (e) { sendResponse({ error: e.message }); } } - + public static [JsonRpcMethod.SaveNetwork](request: any, sendResponse: Function) { try { // If we have a passphrase then we are modifying. @@ -955,4 +969,70 @@ export class InternalMethods { return true; } + + public static [JsonRpcMethod.GetContacts](request: any, sendResponse: Function) { + const extensionStorage = new ExtensionStorage(); + extensionStorage.getStorage('contacts', (response: any) => { + let contacts = []; + if (response) { + contacts = response; + } + sendResponse(contacts); + }); + return true; + } + + public static [JsonRpcMethod.SaveContact](request: any, sendResponse: Function) { + const { name, previousName, address } = request.body.params; + + const extensionStorage = new ExtensionStorage(); + extensionStorage.getStorage('contacts', (response: any) => { + let contacts = []; + if (response) { + contacts = response; + } + const newContact = { + name: name, + address: address, + }; + const previousIndex = contacts.findIndex((contact) => contact.name === previousName); + if (previousIndex >= 0) { + contacts[previousIndex] = newContact; + } else { + contacts.push(newContact); + } + + extensionStorage.setStorage('contacts', contacts, (isSuccessful: any) => { + if (isSuccessful) { + sendResponse(contacts); + } else { + sendResponse({ error: 'Lock failed' }); + } + }); + }); + return true; + } + + public static [JsonRpcMethod.DeleteContact](request: any, sendResponse: Function) { + const { name } = request.body.params; + + const extensionStorage = new ExtensionStorage(); + extensionStorage.getStorage('contacts', (response: any) => { + let contacts = []; + if (response) { + contacts = response; + } + const contactIndex = contacts.findIndex((contact) => contact.name === name); + contacts.splice(contactIndex, 1); + + extensionStorage.setStorage('contacts', contacts, (isSuccessful: any) => { + if (isSuccessful) { + sendResponse(contacts); + } else { + sendResponse({ error: 'Lock failed' }); + } + }); + }); + return true; + } } diff --git a/packages/extension/src/background/messaging/task.ts b/packages/extension/src/background/messaging/task.ts index f97a2fbb..24166291 100644 --- a/packages/extension/src/background/messaging/task.ts +++ b/packages/extension/src/background/messaging/task.ts @@ -570,7 +570,6 @@ export class Task { 'A transaction has failed because of an inability to build the specified transaction type.' ); - if (validationError && validationError.message) { d.error = validationError; } else { @@ -990,6 +989,11 @@ export class Task { // 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; + + // Create an encoded transaction for the ledger sign + const encodedTxn = Buffer.from(algosdk.encodeUnsignedTransaction(builtTx)).toString('base64'); + message.body.params.encodedTxn = encodedTxn; + InternalMethods[JsonRpcMethod.LedgerSignTransaction](message, (response) => { // We only have to worry about possible errors here if ('error' in response) { @@ -1292,14 +1296,19 @@ export class Task { }; } else if (hardwareAccounts.some((a) => a === address)) { // Limit to single group transactions - if (!singleGroup) { + if (!singleGroup || transactionObjs.length > 1) { throw new LedgerMultipleTransactions(); } // Now that we know it is a single group adjust the transaction property to be the current wrap - // This will be where the transaction presented to the user + // This will be where the transaction presented to the user in the first Ledger popup. + // This can probably be removed in favor of mapping to the current transaction message.body.params.transaction = wrap; + // Set the ledgerGroup in the message to the current group + // Since the signing will move into the next signs we need to know what group we were supposed to sign + message.body.params.ledgerGroup = parseInt(currentGroup); + // 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; @@ -1464,45 +1473,34 @@ export class Task { return InternalMethods[JsonRpcMethod.SaveNetwork](request, sendResponse); }, [JsonRpcMethod.CheckNetwork]: (request: any, sendResponse: Function) => { - InternalMethods[JsonRpcMethod.CheckNetwork](request, async (networks) => { - let urlAlgod = networks.algod.url; - if (networks.algod.port.length > 0) urlAlgod += ':' + networks.algod.port; - let urlIndexer = networks.indexer.url; - if (networks.indexer.port.length > 0) urlIndexer += ':' + networks.indexer.port; - const sendPathAlgod = `/v2/status/`; - const sendPathIndexer = '/v2/transactions?limit=1'; - const paramsAlgod: any = { - headers: { - ...networks.algod.headers, - }, - method: 'GET', - }; - const paramsIndexer: any = { - headers: { - ...networks.indexer.headers, - }, - method: 'GET', - }; + InternalMethods[JsonRpcMethod.CheckNetwork](request, async (networks) => { + const algodClient = new algosdk.Algodv2(networks.algod.apiKey, networks.algod.url, networks.algod.port); + const indexerClient = new algosdk.Indexer(networks.indexer.apiKey, networks.indexer.url, networks.indexer.port); const responseAlgod = {}; const responseIndexer = {}; - await Task.fetchAPI(`${urlAlgod}${sendPathAlgod}`, paramsAlgod) + (async () => { + await algodClient.status().do() .then((response) => { - responseAlgod['message'] = response['message'] || response; + responseAlgod['message'] = response['message'] || response; }) .catch((error) => { - responseAlgod['error'] = error.message || error; + responseAlgod['error'] = error.message || error; }); - - await Task.fetchAPI(`${urlIndexer}${sendPathIndexer}`, paramsIndexer) - .then((response) => { - responseIndexer['message'] = response['message'] || response; + })().then(() =>{ + (async () => { + await indexerClient.searchForTransactions().limit(1).do() + .then((response) => { + responseAlgod['message'] = response['message'] || response; + }) + .catch((error) => { + responseAlgod['error'] = error.message || error; + }); + })().then(() =>{ + sendResponse({ algod: responseAlgod, indexer: responseIndexer }); }) - .catch((error) => { - responseIndexer['error'] = error.message || error; - }); - sendResponse({ algod: responseAlgod, indexer: responseIndexer }); + }) }); return true; }, @@ -1517,45 +1515,44 @@ export class Task { }, [JsonRpcMethod.LedgerGetSessionTxn]: (request: any, sendResponse: Function) => { InternalMethods[JsonRpcMethod.LedgerGetSessionTxn](request, (internalResponse) => { - if (internalResponse.error) { + // V2 transactions can just pass back + if(internalResponse.transactionsOrGroups) { sendResponse(internalResponse); - return; - } - - // V1 style transactions will only have 1 transaction and we can use the response. - // V2 style transactions will have a transaction in the response.transaction object - // and wek only need the one transaction since Ledger doesn't multisign - let txWrap = internalResponse; - if (txWrap.transaction && txWrap.transaction.transaction) { - txWrap = txWrap.transaction; } + // V1 transactions may need to have an estimated fee + else { + let txWrap = internalResponse; + if (txWrap.transaction && txWrap.transaction.transaction) { + txWrap = txWrap.transaction; + } - // Send response or grab params to calculate an estimated fee if there isn't one - if (txWrap.estimatedFee) { - sendResponse(txWrap); - } else { - const conn = Settings.getBackendParams( - getLedgerFromGenesisId(txWrap.transaction.genesisID), - API.Algod - ); - const sendPath = '/v2/transactions/params'; - const fetchParams: any = { - headers: { - ...conn.headers, - }, - method: 'GET', - }; - - let url = conn.url; - if (conn.port.length > 0) url += ':' + conn.port; - Task.fetchAPI(`${url}${sendPath}`, fetchParams).then((params) => { - if (txWrap.transaction.fee === params['min-fee']) { - // This object was built on front end and fee should be 0 to prevent higher fees. - txWrap.transaction.fee = 0; - } - calculateEstimatedFee(txWrap, params); + // Send response or grab params to calculate an estimated fee if there isn't one + if (txWrap.estimatedFee) { sendResponse(txWrap); - }); + } else { + const conn = Settings.getBackendParams( + getLedgerFromGenesisId(txWrap.transaction.genesisID), + API.Algod + ); + const sendPath = '/v2/transactions/params'; + const fetchParams: any = { + headers: { + ...conn.headers, + }, + method: 'GET', + }; + + let url = conn.url; + if (conn.port.length > 0) url += ':' + conn.port; + Task.fetchAPI(`${url}${sendPath}`, fetchParams).then((params) => { + if (txWrap.transaction.fee === params['min-fee']) { + // This object was built on front end and fee should be 0 to prevent higher fees. + txWrap.transaction.fee = 0; + } + calculateEstimatedFee(txWrap, params); + sendResponse(txWrap); + }); + } } }); return true; @@ -1588,6 +1585,15 @@ export class Task { return false; } }, + [JsonRpcMethod.GetContacts]: (request: any, sendResponse: Function) => { + return InternalMethods[JsonRpcMethod.GetContacts](request, sendResponse); + }, + [JsonRpcMethod.SaveContact]: (request: any, sendResponse: Function) => { + return InternalMethods[JsonRpcMethod.SaveContact](request, sendResponse); + }, + [JsonRpcMethod.DeleteContact]: (request: any, sendResponse: Function) => { + return InternalMethods[JsonRpcMethod.DeleteContact](request, sendResponse); + }, }, }; } diff --git a/packages/storage/package.json b/packages/storage/package.json index 7c259082..c6ab2317 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "algosigner-storage", - "version": "1.7.0", + "version": "1.7.1", "author": "https://developer.purestake.io", "repository": "https://github.com/PureStake/algosigner", "license": "MIT", diff --git a/packages/test-project/package.json b/packages/test-project/package.json index 547063f1..0d219bc4 100644 --- a/packages/test-project/package.json +++ b/packages/test-project/package.json @@ -1,6 +1,6 @@ { "name": "algorand-test-project", - "version": "1.7.0", + "version": "1.7.1", "repository": "https://github.com/PureStake/algosigner", "license": "MIT", "description": "Repository for tests", diff --git a/packages/ui/package.json b/packages/ui/package.json index e8801082..68e55d10 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "algosigner-ui", - "version": "1.7.0", + "version": "1.7.1", "author": "https://developer.purestake.io", "repository": "https://github.com/PureStake/algosigner", "license": "MIT", diff --git a/packages/ui/src/components/AccountPreview.ts b/packages/ui/src/components/AccountPreview.ts index 18f3510a..f62ae6cc 100644 --- a/packages/ui/src/components/AccountPreview.ts +++ b/packages/ui/src/components/AccountPreview.ts @@ -18,12 +18,10 @@ const AccountPreview: FunctionalComponent = (props: any) => { address: account.address, }; sendMessage(JsonRpcMethod.AccountDetails, params, function (response) { - if(response.error){ + if (response.error) { console.error(response.error); setError('Error: Account details not accessible.'); - - } - else { + } else { setResults(response); } }); @@ -40,7 +38,9 @@ const AccountPreview: FunctionalComponent = (props: any) => { id="account_${account.name.replace(/\s/g, '')}" >
-
+
${account.name}
diff --git a/packages/ui/src/components/ContactList.ts b/packages/ui/src/components/ContactList.ts new file mode 100644 index 00000000..1a7e116d --- /dev/null +++ b/packages/ui/src/components/ContactList.ts @@ -0,0 +1,250 @@ +import { FunctionalComponent } from 'preact'; +import { html } from 'htm/preact'; +import { useState, useEffect } from 'preact/hooks'; +import { JsonRpcMethod } from '@algosigner/common/messaging/types'; + +import { sendMessage } from 'services/Messaging'; + +import ContactPreview from 'components/ContactPreview'; +import algosdk from 'algosdk'; + +const ContactList: FunctionalComponent = () => { + const [savingError, setSavingError] = useState(''); + const [loading, setLoading] = useState(true); + const [contacts, setContacts] = useState>([]); + const [modalStatus, setModalStatus] = useState(''); + const [newName, setNewName] = useState(''); + const [newAddress, setNewAddress] = useState(''); + const [previousName, setPreviousName] = useState(''); + const [previousAddress, setPreviousAddress] = useState(''); + + const saveContact = () => { + let conflictingContact = contacts.find((c) => c.name === newName || c.address === newAddress); + if ( + conflictingContact && + (conflictingContact.name === previousName || conflictingContact.address === previousAddress) + ) { + conflictingContact = null; + } + if (conflictingContact) { + const errorMessage = + conflictingContact.name === newName + ? `This name is already being used for address: ${conflictingContact.address}` + : `This address has already been saved as '${conflictingContact.name}'`; + setSavingError(errorMessage); + } else { + const params = { + name: newName, + address: newAddress, + previousName: previousName, + }; + setLoading(true); + setSavingError(''); + sendMessage(JsonRpcMethod.SaveContact, params, function (response) { + setLoading(false); + if ('error' in response) { + setSavingError(response.error.message); + } else { + closeModal(); + setContacts(response); + } + }); + } + }; + + const deleteContact = () => { + const params = { + name: newName, + address: newAddress, + }; + setLoading(true); + setSavingError(''); + sendMessage(JsonRpcMethod.DeleteContact, params, function (response) { + setLoading(false); + if ('error' in response) { + setSavingError(response.error.message); + } else { + closeModal(); + setContacts(response); + } + }); + }; + + useEffect(() => { + sendMessage(JsonRpcMethod.GetContacts, {}, function (response) { + setLoading(false); + if ('error' in response) { + setSavingError(response.error.message); + } else { + setContacts(response); + } + }); + }, []); + + const disabled = loading || !newName || !algosdk.isValidAddress(newAddress); + + const openEditModal = (previousName: string = '', previousAddress = '') => { + setNewName(previousName); + setPreviousName(previousName); + setNewAddress(previousAddress); + setPreviousAddress(previousAddress); + setModalStatus(previousName ? 'edit' : 'add'); + }; + + const openDeleteModal = (name: string = '', address: string = '') => { + setNewName(name); + setNewAddress(address); + setModalStatus('delete'); + }; + + const closeModal = () => { + setNewName(''); + setNewAddress(''); + setPreviousName(''); + setPreviousAddress(''); + setModalStatus(''); + setSavingError(''); + }; + + let modalTitle = ''; + + switch (modalStatus) { + case 'add': + modalTitle = 'New Contact'; + break; + case 'edit': + modalTitle = 'Edit Contact'; + break; + case 'delete': + modalTitle = 'Delete Contact'; + break; + default: + break; + } + + return html` + ${modalStatus && + html` +