From 5e547044b633ca0b2e81a8ba582835b0da823ca3 Mon Sep 17 00:00:00 2001 From: Brent Date: Fri, 3 Dec 2021 10:26:19 -0500 Subject: [PATCH 01/10] Ledger minFee, v2 support, multi accounts --- .../background/messaging/internalMethods.ts | 2 +- .../src/background/messaging/task.ts | 48 +--- .../LedgerDevice/LedgerHardwareConnector.ts | 150 +++++++---- .../LedgerDevice/LedgerHardwareSign.ts | 17 +- .../LedgerDevice/structure/ledgerActions.ts | 251 +++++++++++------- 5 files changed, 270 insertions(+), 198 deletions(-) diff --git a/packages/extension/src/background/messaging/internalMethods.ts b/packages/extension/src/background/messaging/internalMethods.ts index 0f863b23..d3371f6d 100644 --- a/packages/extension/src/background/messaging/internalMethods.ts +++ b/packages/extension/src/background/messaging/internalMethods.ts @@ -325,7 +325,7 @@ 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 diff --git a/packages/extension/src/background/messaging/task.ts b/packages/extension/src/background/messaging/task.ts index f97a2fbb..204b408b 100644 --- a/packages/extension/src/background/messaging/task.ts +++ b/packages/extension/src/background/messaging/task.ts @@ -1297,9 +1297,14 @@ export class Task { } // 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; @@ -1517,46 +1522,7 @@ export class Task { }, [JsonRpcMethod.LedgerGetSessionTxn]: (request: any, sendResponse: Function) => { InternalMethods[JsonRpcMethod.LedgerGetSessionTxn](request, (internalResponse) => { - if (internalResponse.error) { - 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; - } - - // 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); - }); - } + sendResponse(internalResponse); }); return true; }, diff --git a/packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts b/packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts index e2af3a93..344cd988 100644 --- a/packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts +++ b/packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts @@ -1,20 +1,58 @@ import { FunctionalComponent } from 'preact'; import { html } from 'htm/preact'; -import { useState } from 'preact/hooks'; +import { useContext, 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'; +import { StoreContext } from 'services/StoreContext'; const LedgerHardwareConnector: FunctionalComponent = (props: any) => { const { ledger } = props; + const store: any = useContext(StoreContext); 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(''); + const [accountsRetrieved, setAccountsRetrieved] = useState(false); + const [accounts, setAccounts] = useState>([{'ledgerIndex':'-1', publicKey: 'Select...'}]); + const [selectedAccount, setSelectedAccount] = useState('-1'); + const handleAccountChange = async (e) => { + setSelectedAccount(e.target.value); + } + + const getAllLedgerAddresses = async () => { + setLoading(true); + setAuthError(''); + setError(''); + + const ddItems = new Array(); + const storeLedgerAddresses = new Array(); + + for(let i=0;i { + if ('error' in response) { + setError(`Unable to obtain list of addresses. Verify the Ledger hardware device is connected and unlocked. ${response['error']}`); + } else { + for (let i=0; i < response.message?.length; i++){ + if(!(storeLedgerAddresses?.includes(`${response.message[i].publicAddress}`))){ + ddItems.push(response.message[i]); + } + } + setAccounts(ddItems); + setAccountsRetrieved(true); + } + }).catch(e => { + setError(`Error: ${JSON.stringify(e)}`); + }).finally(() => setLoading(false)); + } + // 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) => { @@ -22,19 +60,16 @@ const LedgerHardwareConnector: FunctionalComponent = (props: any) => { 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 selectedHexAddress = accounts[selectedAccount]['hex']; + + if(!selectedHexAddress) { + setError('A public address was not selected.'); + } else { const params = { passphrase: pwd, name: name.trim(), ledger: ledger, - hexAddress: response.message, + hexAddress: selectedHexAddress, }; sendMessage(JsonRpcMethod.LedgerSaveAccount, params, function (response) { @@ -59,61 +94,66 @@ const LedgerHardwareConnector: FunctionalComponent = (props: any) => { } }); } - }).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

+
+

Adding Hardware Account for ${ledger}

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

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

+

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} -

-
-
- -
- `} + ${!isComplete && + html` +
+

+ Insert and unlock the hardware device, verify the Algorand application is open during this process. +

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

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

+
+
+ ${ accountsRetrieved && + html`` + } + ${ !accountsRetrieved && + html`` + } +
+ ` + }
${askAuth && diff --git a/packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts b/packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts index 44a4ca28..669188af 100644 --- a/packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts +++ b/packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts @@ -24,6 +24,7 @@ const LedgerHardwareSign: FunctionalComponent = () => { const [txResponseHeader, setTxResponseHeader] = useState(''); const [txResponseDetail, setTxResponseDetail] = useState(''); const [ledger, setLedger] = useState(''); + const [sessionTxnObj, setSessionTxnObj] = useState({}); useEffect(() => { if (txn.transaction === undefined && error === '') { @@ -32,8 +33,9 @@ const LedgerHardwareSign: FunctionalComponent = () => { if (response.error) { setError(response.error); } else { + const primaryTx = response.transactionWraps[0]; getBaseSupportedLedgers().forEach((l) => { - if (response.genesisID === l['genesisId']) { + if (primaryTx.genesisID === l['genesisId']) { setLedger(l['name']); // Update the ledger dropdown to the signing one @@ -44,9 +46,10 @@ const LedgerHardwareSign: FunctionalComponent = () => { }); // Update account value to the signer - setAccount(response.from); + setAccount(primaryTx.from); - setTxn(response); + setSessionTxnObj(response); + setTxn(primaryTx); } }); } catch (ex) { @@ -59,7 +62,7 @@ const LedgerHardwareSign: FunctionalComponent = () => { const ledgerSignTransaction = () => { setLoading(true); setError(''); - ledgerActions.signTransaction(txn).then((lar) => { + ledgerActions.signTransaction(sessionTxnObj).then((lar) => { if (lar.error) { setError(lar.error); setLoading(false); @@ -93,10 +96,10 @@ const LedgerHardwareSign: FunctionalComponent = () => { class="main-view" style="flex-direction: column; justify-content: space-between; overflow: hidden;" > +
+

Sign Using Ledger Device

+
-
-

Sign Using Ledger Device

-
${isComplete && html`
diff --git a/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts b/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts index fc59332f..3c60c77c 100644 --- a/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts +++ b/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts @@ -3,9 +3,20 @@ import transport from './ledgerTransport'; const algosdk = require('algosdk'); const Algorand = require('@ledgerhq/hw-app-algorand'); import LedgerActionResponse from './ledgerActionsResponse'; +import { WalletTransaction } from '@algosigner/common/types'; +import { EncodedSignedTransaction } from 'algosdk'; let ledgerTransport: typeof Algorand; -const _PATH = "44'/60'/0'/0/0"; + +const _PATH = { + pathIndex: 0, + get primary() { + { return `44'/60'/0'/0/0`; } + }, + get current() { + { return `44'/60'/${this.pathIndex}'/0/0`; } + } +} const getDevice = async () => { // Check for the presence of an Algorand Ledger transport and return it if one exists @@ -48,90 +59,128 @@ const isAvailable = async (): Promise => { }; /// -// Takes an unsigned decoded transaction object and converts strings into Uint8Arrays -// for note, appArgs, approval and close programs. Then returns a transactionBuilder encoded value +// Takes the account public address and tries to find the hex representation to get the index +// Returns the matching index or 0 if index is not found /// -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]; - } - }); +const findAccountIndex = async (fromAccount: string): Promise => { + let foundIndex = 0; + let foundAccount = false; + let hasError = false; + const maxAccounts = 8; // Arbitrary - to prevent infinite loops + + // Reset path index to get all accounts for loop + _PATH.pathIndex = 0; - // 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)); - } - } + // Convert fromAccount public address to hex publicKey + const fromPubKey = Buffer.from(algosdk.decodeAddress(fromAccount).publicKey).toString('hex'); - // 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.'); + while(!foundAccount && !hasError && _PATH.pathIndex < maxAccounts) { + await ledgerTransport + .getAddress(_PATH.current) + .then((o: any) => { + if (o.publicKey === fromPubKey){ + foundIndex = _PATH.pathIndex; + foundAccount = true; } - } - 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.'); + }) + .catch((e) => { + console.log(`Error when trying to find Ledger account. ${JSON.stringify(e)}`); + // Abort on error and pass back the current foundIndex + hasError = true; + }) + .finally(() => { + if (!foundAccount) { + _PATH.pathIndex += 1; } - } + }); } + return foundIndex; +} - // Remap of BigInt values from strings creates issues in this cast - // So forcing the two affected fields (amount,assetTotal) back to numeric - if ('amount' in txn) { - const parsed = parseInt(txn['amount']); - // Soft check on the result mating the txn amount because it is an expect int to string compare - if (isNaN(parsed) || parsed != txn['amount']) { - errors.push('Ledger transaction amount must be an integer.'); - } - else { - txn['amount'] = parsed; - } +/// +// Tries to get multiple Ledger accounts +// Returns an array of publicKey addresses, encoded +/// +const getAllAddresses = async (): Promise => { + const accounts = Array(); + let errorOnIndex = false; + const maxAccounts = 8; // Arbitrary - to prevent infinite loops + let lar: LedgerActionResponse = {}; + + // Reset path index to get all + _PATH.pathIndex = 0; + + // 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.' }; + }); } - if ('assetTotal' in txn) { - const parsed = parseInt(txn['assetTotal']); - // Soft check on the result mating the txn assetTotal because it is an expect int to string compare - if (isNaN(parsed) || parsed != txn['assetTotal']) { - errors.push('Ledger transaction assetTotal must be an integer.'); - } - else { - txn['assetTotal'] = parsed; - } + + // Return error if we have one + if (lar.error) { + return lar; + } + + while(!errorOnIndex && _PATH.pathIndex < maxAccounts) { + const currentIndex = `${_PATH.pathIndex}`; + await ledgerTransport + .getAddress(_PATH.current) + .then((o: any) => { + const publicAddress: string = algosdk.encodeAddress(Buffer.from(o.publicKey, 'hex')); + const retrievedAccount = { 'ledgerIndex': currentIndex, 'hex': o.publicKey, 'publicAddress': publicAddress }; + accounts.push(retrievedAccount); + }) + .catch((e) => { + console.log(e) + // Abort on error and pass back the current foundIndex + errorOnIndex = true; + lar.error = e; + }) + .finally(_PATH.pathIndex += 1); } - const builtTxn = new algosdk.Transaction(txn); + lar.message = accounts; + return lar; +} - if ('group' in txn && txn['group']) { - // Remap group field lost from cast - builtTxn.group = Buffer.from(txn['group'], 'base64'); +/// +// 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 { groupsToSign, currentGroup, ledgerGroup } = transaction; + + // Using ledgerGroup if provided since the user may sign multiple more by the time we sign. + // Defaulting to current after, but making sure we don't go above the current length. + const txPositionInGroup = Math.min((ledgerGroup || currentGroup), groupsToSign.length - 1); + + const walletTransactions: Array = groupsToSign[txPositionInGroup]; + + const transactionObjs = walletTransactions.map((walletTx) => { + const byteWalletTxn = new Uint8Array( + Buffer.from(walletTx.txn, 'base64') + .toString('binary') + .split('') + .map((x) => x.charCodeAt(0)) + ); + return byteWalletTxn; + } + ); + + if (transactionObjs.length === 0) { + return { transaction: undefined, error: 'No signable transaction found in cached Ledger transactions.' }; } - // Encode the transaction and join any errors for return - const encodedTxn = algosdk.encodeUnsignedTransaction(builtTxn); - return { transaction: encodedTxn, error: errors.join() }; + // Currently we only allow a single transaction going into Ledger. + // TODO: To work with groups in the future this should grab the first acceptable one, not the first one overall. + const encodedTxn = transactionObjs[0]; + + return { transaction: encodedTxn, error: '' }; } const getAddress = async (): Promise => { @@ -155,7 +204,7 @@ const getAddress = async (): Promise => { // Now attempt to get the default Algorand address await ledgerTransport - .getAddress(_PATH) + .getAddress(_PATH.primary) .then((o: any) => { lar = { message: o.publicKey }; }) @@ -191,36 +240,50 @@ const signTransaction = async (txn: any): Promise => { // 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 txnResponse = cleanseBuildEncodeUnsignedTransaction(txn); + const decodedTxn = algosdk.decodeUnsignedTransaction(txnResponse.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), - }; + // Since we currently don't support groups, logic, or rekeyed accounts on Ledger sign + // we can check just the from field to get the index + const fromAccount = algosdk.encodeAddress(decodedTxn.from.publicKey); + const foundIndex = await findAccountIndex(fromAccount); + if (foundIndex === -1) { + lar.error = 'Transaction "from" field does not match any ledger account.' + return lar; + } + else { + _PATH.pathIndex = foundIndex; - // 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.' }; - }); + // Send the hex transaction to the Ledger device for signing + await ledgerTransport + .sign(_PATH.current, message) + .then((o: any) => { + const sTxn: EncodedSignedTransaction = { + sig: o.signature, + txn: decodedTxn.get_obj_for_encoding(), + }; + const encTxn = algosdk.encodeObj(sTxn); - return lar; + // Convert to base64 string for return + lar = { message: Buffer.from(encTxn, 'base64').toString('base64') } + }) + .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, + getAllAddresses, signTransaction, }; From 30622c41d90ff7eb3ddd44a905e18dc0b27cd459 Mon Sep 17 00:00:00 2001 From: Brent Date: Mon, 6 Dec 2021 14:02:30 -0500 Subject: [PATCH 02/10] Update ledger cleanse function comments --- .../ui/src/components/LedgerDevice/structure/ledgerActions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts b/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts index 3c60c77c..b636c587 100644 --- a/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts +++ b/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts @@ -149,8 +149,8 @@ const getAllAddresses = async (): Promise => { } /// -// Takes an unsigned decoded transaction object and converts strings into Uint8Arrays -// for note, appArgs, approval and close programs. Then returns a transactionBuilder encoded value +// Takes the modified transaction request which contains groupsToSign +// then from that will extract the first walletTransaction of the calculated group /// function cleanseBuildEncodeUnsignedTransaction(transaction: any): any { const { groupsToSign, currentGroup, ledgerGroup } = transaction; From 4748508eb06102db54a6e9e2d37bb6e9498234d3 Mon Sep 17 00:00:00 2001 From: Jan Marcano Date: Mon, 6 Dec 2021 11:24:00 -0300 Subject: [PATCH 03/10] Add Support for saving and using addresses as Contacts --- packages/common/src/messaging/types.ts | 3 + .../background/messaging/internalMethods.ts | 75 +++++- .../src/background/messaging/task.ts | 10 +- packages/ui/src/components/ContactList.ts | 238 ++++++++++++++++++ packages/ui/src/components/ContactPreview.ts | 22 ++ packages/ui/src/components/SettingsMenu.ts | 13 + .../src/components/SignTransaction/TxAxfer.ts | 34 +-- .../src/components/SignTransaction/TxPay.ts | 18 +- packages/ui/src/pages/SendAlgos.ts | 194 +++++++++++--- .../ui/src/pages/SignWalletTransaction.ts | 14 ++ packages/ui/src/styles.scss | 8 + 11 files changed, 568 insertions(+), 61 deletions(-) create mode 100644 packages/ui/src/components/ContactList.ts create mode 100644 packages/ui/src/components/ContactPreview.ts 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/extension/src/background/messaging/internalMethods.ts b/packages/extension/src/background/messaging/internalMethods.ts index 0f863b23..73a03537 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: [], @@ -867,14 +869,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 +956,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..4a0df124 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 { @@ -1588,6 +1587,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/ui/src/components/ContactList.ts b/packages/ui/src/components/ContactList.ts new file mode 100644 index 00000000..79c27b72 --- /dev/null +++ b/packages/ui/src/components/ContactList.ts @@ -0,0 +1,238 @@ +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` +