From 4dba37ee382e7057ec821b1eaedfd339c08b2a06 Mon Sep 17 00:00:00 2001 From: Charles-Edouard de la Vergne Date: Fri, 9 Feb 2024 15:45:12 +0100 Subject: [PATCH] Add Favorite Accounts feature --- doc/ethapp.adoc | 153 ++++++ glyphs/filter32px.bmp | Bin 0 -> 632 bytes glyphs/list32px.bmp | Bin 0 -> 192 bytes glyphs/plus32px.bmp | Bin 0 -> 632 bytes glyphs/trash32px.bmp | Bin 0 -> 192 bytes src/apdu_constants.h | 8 + src/main.c | 12 + .../favoriteAccounts/cmd_favoriteAccounts.c | 500 ++++++++++++++++++ .../feature_favoriteAccounts.h | 107 ++++ src_nbgl/ui_account.c | 495 +++++++++++++++++ src_nbgl/ui_idle.c | 16 +- src_nbgl/ui_nbgl.h | 2 + 12 files changed, 1286 insertions(+), 7 deletions(-) create mode 100644 glyphs/filter32px.bmp create mode 100644 glyphs/list32px.bmp create mode 100644 glyphs/plus32px.bmp create mode 100644 glyphs/trash32px.bmp create mode 100644 src_features/favoriteAccounts/cmd_favoriteAccounts.c create mode 100644 src_features/favoriteAccounts/feature_favoriteAccounts.h create mode 100644 src_nbgl/ui_account.c diff --git a/doc/ethapp.adoc b/doc/ethapp.adoc index 4e5c646e3c..2e2f1a2bfb 100644 --- a/doc/ethapp.adoc +++ b/doc/ethapp.adoc @@ -960,6 +960,159 @@ _Output data_ None +### Favorite Accounts + +#### Description + +This command allows to synchronize an Address Book between Ledger Live and the device. +This allows user-friendly and easier address verifications. +Also, the entries from the Address Book can be displayed as a QR code for easier control or sharing. + +#### Coding + +'Command' + +[width="80%"] +|============================================================================================================================== +| *CLA* | *INS* | *P1* | *P2* | *Lc* | *Le* +| E0 | 30 | 01 : delete all accounts | 00 | 00 | empty +| E0 | 30 | 02 : get account | account Nb | 00 | empty +| E0 | 30 | 03 : rename an account | old name size | variable | see below +| E0 | 30 | 04 : update an account with address | 00 | 00 | see below +| E0 | 30 | 05 : update multiple accounts with address | 00 : next data + + 01 : last data | variable | see below + +| E0 | 30 | 06 : update an account with derivation path | 00 | 00 | see below +| E0 | 30 | 07 : update multiple accounts with derivation path | 00 : next data + + 01 : last data | variable | see below +|============================================================================================================================== + +Commands summary: + + - `delete all accounts`: _Purge_ the Address Book, and remove all entries. + - `get account`: Retrieve a specific Address Book entry; the Account Number is given in parameter. + - `rename an account`: _Change_ the Name assigned to an Account. + - `update an account`: _Add_ a new account, or _Rename_ an existing one. + - `update multiple accounts`: _Add_ new accounts and/or _Rename_ existing ones. +This is a _chaining_ command, meaning several APDU must be sent successively, +and the last one should have P2 = 0x01 to trig the confirmation screens. + +> For _update account_ commands, we have each time 2 variants: +> +> - Providing the full address +> - Providing the full derivation path, allowing the device to securely derivate and compute the address. + +_Input data_ + +##### If P1 == `rename an account` + +[width="80%"] +|============================================================================================================================== +| *Description* | *Length* +| Old Account Name (up to 16 ascii characters, 32 hex values - without '0x') | variable (max 32) +| New Account Name (up to 16 ascii characters, 32 hex values - without '0x') | variable (max 32) +|============================================================================================================================== + +> The _Old Account name_ length must be written in P2. + +##### If P1 == `update an account` + +This command is used to _Add_ a new Address Book entry. If the pair (*_Address_*, *_Chain Id_*) already exists, +the command will be considered as a _Rename_. + +###### Case with Address + +[width="80%"] +|============================================================================================================================== +| *Description* | *Length* +| Chain Id (8 Bytes) | 16 +| Address (20 hex values without '0x') | 40 +| Name (up to 16 ascii characters, 32 hex values - without '0x') | variable (max 32) +|============================================================================================================================== + +###### Case with Derivation Path + +[width="80%"] +|============================================================================================================================== +| *Description* | *Length* +| Chain Id (8 Bytes) | 16 +| Derivation Path (1 Byte length + up to 5 x 4 Bytes) | variable (max 42) +| Name (up to 16 ascii characters, 32 hex values - without '0x') | variable (max 32) +|============================================================================================================================== + + +##### If P1 == `update multiple account` + +Compared to the previous `update an account`, the principle here is to chain multiple APDU +to update (_Add_/_Rename_) several Address Book entries at once. +The review/confirmation will be displayed only once when all APDU have been sent. +Thus, the last APDU must be distinguished using the P2 value. + +###### Case with Address + +[width="80%"] +|============================================================================================================================== +| *Description* | *Length* +| Chain Id (8 Bytes) | 16 +| Address (20 hex values without '0x') | 40 +| Name (up to 16 ascii characters, 32 hex values - without '0x') | variable (max 32) +|============================================================================================================================== + +###### Case with Derivation Path + +[width="80%"] +|============================================================================================================================== +| *Description* | *Length* +| Chain Id (8 Bytes) | 16 +| Derivation Path (1 Byte length + up to 5 x 4 Bytes) | variable (max 42) +| Name (up to 16 ascii characters, 32 hex values - without '0x') | variable (max 32) +|============================================================================================================================== + +_Output data_ + +##### If P1 == `get account` + +[width="80%"] +|============================================================================================================================== +| *Description* | *Length* +| Chain Id (8 Bytes) | 16 +| Address (20 hex values without '0x') | 40 +| Account Name (up to 16 ascii characters, 32 hex values - without '0x') | variable (max 32) +|============================================================================================================================== + + +_Status Word_ + +The returned Status Word depends on the command: + +##### If P1 == `update multiple account` + +For each intermediate APDU, the Status Word will contain the remaining available entries in the lower bits. +For example, it will return `0x9002` when the command is correctly completed, and there are still `2` remaining (free) entries. +Of course, after the *Last* one (with P2 = 0x01), it will be `0x9000`, indicating everything has been done and completed. + +##### Other commands + +The other commands will return `0x9000` when correctly completed. + +##### Errors + +In case of error, the _Status Word_ will be: + +[width="80%"] +|============================================================================================================================== +| *Description* | *Status Word* +| Error in P1 / P2 parameters | 0x6B00 +| Memory error: No more Address Book entries available | 0x6A80 +| Requested Data not found | 0x6A88 +| Add an account, but using an already assigned name | 0x6A80 +| Update an account, but APDU length is inconsistent | 0x6983 +| Add an account, but it is already present in the Address Book | 0x6985 +|============================================================================================================================== + + ## Transport protocol diff --git a/glyphs/filter32px.bmp b/glyphs/filter32px.bmp new file mode 100644 index 0000000000000000000000000000000000000000..e8d9679682d7ff5f9ccc4af1bca2c656935365a6 GIT binary patch literal 632 zcmZ?rtzcpRgEAng0mKSW%*en3WHB%>0p*3bAs8$GB>w;Z&v5M6F$POZOR&)6$B!8b z3kw-mty;wp8XC&b*Vo4&BO?P-PB+@g*hm1`vS4Fl6=c3~Sh5*}FO~)Z#tF=(X^9|_ zVq-8!U^X^3$Hs@~0;-4Vhw{~nQTUR^mJJXY2+2`ify~9vZZB^@R=_dYcp@@C*}TXY zg>S%y#0R<`$Oi+M`%(B}X~94~*wbMDGB6~Yr6TEPU~n=vHbpj{Q5iMtkmV?60|4%< BFoggB literal 0 HcmV?d00001 diff --git a/glyphs/list32px.bmp b/glyphs/list32px.bmp new file mode 100644 index 0000000000000000000000000000000000000000..e46ebc171f928e7e8162c900b917109ddf968c98 GIT binary patch literal 192 zcmZ?rJ-`3~c0fu4h!voik%1A&Vqj0p*3bAs8$GB>w;Z&%nyc%D~JBR0UMP24u4` tGsC3GrGu1Ebt(;2KC==EP+&sgDq-+hP~`)l!7ya~4|TtgGHQGx@c;wb6q5h| literal 0 HcmV?d00001 diff --git a/glyphs/trash32px.bmp b/glyphs/trash32px.bmp new file mode 100644 index 0000000000000000000000000000000000000000..901a8a61b3c27d7c9f2f546ba451ea9a4a15021c GIT binary patch literal 192 zcmZ?rJ-`3~c0fu4h!voik%1A&Vqj> 8) & 0xFF; + G_io_apdu_buffer[tx + 1] = sw & 0xFF; + // Send back the response, do not restart the event loop + io_exchange(CHANNEL_APDU | IO_RETURN_AFTER_TX, tx + 2); +} + +/** + * @brief Used to send back the APDU buffer + * + * @param buffer Received APDU buffer + * @param length APDU buffer length + * @param sw Status Word value + * + * @note TODO: Put such function in a global file to be widely used, and change the exception + * handling + */ +static void returnBuffer(const uint8_t *buffer, uint8_t length, uint16_t sw) { + memmove(G_io_apdu_buffer, buffer, length); + + returnResult(sw, length); +} + +/** + * @brief Cleanup the temporary update context and throw an error + * + * @param sw Status Word value + */ +static void throwError(uint16_t sw) { + // Reset the update tmp storage + memset(&accountsUpdate, 0, sizeof(AccountUpdate_t)); + THROW(sw); +} + +/** + * @brief Convert an Hex digit to a number + * + * @param in Value to convert + * + * @return Converted value + */ +static uint8_t fromHexDigit(uint8_t in) { + if (in >= '0' && (in <= '9')) { + return in - '0'; + } + if (in >= 'A' && (in <= 'F')) { + return in - 'A' + 10; + } + if (in >= 'a' && (in <= 'f')) { + return in - 'a' + 10; + } + return 0; +} + +/** + * @brief Convert an Account address string to Hex + * + * @param out Converted address + * @param in Input address string + */ +static void convertAddressHex(uint8_t *out, const uint8_t *address) { + uint8_t i; + uint8_t hi, lo; + + for (i = 0; i < ADDRESS_LENGTH; i++) { + hi = fromHexDigit(address[(i * 2)]); + lo = fromHexDigit(address[(i * 2) + 1]); + out[i] = (hi << 4) | lo; + } +} + +/** + * @brief Convert an Account chain_id string to Hex + * + * @param out Converted chain_id + * @param in Input chain_id string + */ +static void convertChainIdHex(uint8_t *out, uint64_t in) { + int i = 0; + + for (i = (int) ACCOUNT_CHAIN_ID_LENGTH; i > 0; i--) { + out[i - 1] = in & 0xFF; + in >>= 8; + } +} + +/** + * @brief Get the account index for a given name + * + * @param name Current account name + * + * @note The check is based on the same address and chain_id + */ +static uint8_t getAccountIdByName(char *name) { + uint8_t i = 0; + + for (i = 0; i < getNbFavoriteAccounts(); i++) { + if (memcmp((const char *) N_accounts.accounts[i].name, name, ACCOUNT_NAME_MAX_LENGTH) == + 0) { + // Found account name + return i; + } + } + // Not found + PRINTF("[ACCOUNT] - Name: '%s' not found!\n", name); + throwError(APDU_RESPONSE_REF_DATA_NOT_FOUND); + // Will never arrive, but makes the compiler happy + return 0xFF; +} + +/** + * @brief Get the existing account index + * + * @param account New account data + * + * @note The check is based on the same address and chain_id + * + * @return The found index, or INVALID_ACCOUNT_INDEX + */ +static uint8_t getAccountIndex(AccountData_t *account) { + bool sameChain = false; + bool sameName = false; + bool sameAddress = false; + uint8_t nbAccounts = INVALID_ACCOUNT_INDEX; + uint8_t i = 0; + + for (i = 0; i < getNbFavoriteAccounts(); i++) { + sameChain = N_accounts.accounts[i].chain_id == account->chain_id; + sameAddress = memcmp((const char *) N_accounts.accounts[i].address, + account->address, + ACCOUNT_ADDR_MAX_LENGTH) == 0; + sameName = memcmp((const char *) N_accounts.accounts[i].name, + account->name, + ACCOUNT_NAME_MAX_LENGTH) == 0; + + if (sameName == true) { + if (sameAddress == true && sameChain == true) { + // Account already exists in the Address book! + PRINTF("[ACCOUNT] - Index: Account already exist!\n"); + throwError(APDU_RESPONSE_CONDITION_NOT_SATISFIED); + } + // Name exists for another account! + PRINTF("[ACCOUNT] - Index: Name already used!\n"); + throwError(APDU_RESPONSE_INVALID_DATA); + } + if (sameAddress == true && sameChain == true) { + nbAccounts = i; + // Just need to rename of the account + break; + } + } + // New account + return nbAccounts; +} + +/** + * @brief Called when receiving an APDU to Update an account + * + * @param buffer Received APDU buffer + * @param length APDU buffer length + * @param last 'true' if it is the last buffer, to trig the NVRAM write + * @param address 'true' if the buffer contain an address; 'false' if it is a derivation path + */ +static void favoriteAccountUpdate(const uint8_t *buffer, uint8_t length, bool last, bool address) { + uint8_t *data = (uint8_t *) buffer; + uint8_t nbAccounts = 0; + uint16_t sw = APDU_RESPONSE_INVALID_DATA; + uint8_t remaining = 0; + bip32_path_t bip32; + + // Check APDU parameters (size of the different parameters) + if ((length < ACCOUNTS_MIN_APDU_LENGTH) || (length > ACCOUNTS_MAX_APDU_LENGTH)) { + PRINTF("[ACCOUNT] - Update: Invalid length: %d (%d - %d)!\n", + length, + ACCOUNTS_MIN_APDU_LENGTH, + ACCOUNTS_MAX_APDU_LENGTH); + throwError(ERR_APDU_SIZE_MISMATCH); + } + + // Check already available accounts + nbAccounts = getNbFavoriteAccounts(); + + // Check Available slots + if ((nbAccounts + accountsUpdate.nbAccounts) == ACCOUNTS_MAX) { + PRINTF("[ACCOUNT] - Update: No available slot: Current=%d, requested=%d - Max=%d)!\n", + nbAccounts, + accountsUpdate.nbAccounts, + ACCOUNTS_MAX); + throwError(APDU_RESPONSE_INSUFFICIENT_MEMORY); + } + + // Save the Account data: chain_id + accountsUpdate.accounts[accountsUpdate.nbAccounts].chain_id = + u64_from_BE(data, ACCOUNT_CHAIN_ID_LENGTH); + data += ACCOUNT_CHAIN_ID_LENGTH; + length -= ACCOUNT_CHAIN_ID_LENGTH; + if (address) { + // Get the address + snprintf(accountsUpdate.accounts[accountsUpdate.nbAccounts].address, + ACCOUNT_ADDR_MAX_LENGTH, + "0x%.*H", + ADDRESS_LENGTH, + data); + data += ADDRESS_LENGTH; + length -= ADDRESS_LENGTH; + } else { + // Get the derivation path + data = (uint8_t *) parseBip32(data, &length, &bip32); + getEthPublicKey(bip32.path, bip32.length); + + // Save the Account data: address + snprintf(accountsUpdate.accounts[accountsUpdate.nbAccounts].address, + ACCOUNT_ADDR_MAX_LENGTH, + "0x%.*s", + (ADDRESS_LENGTH * 2), + tmpCtx.publicKeyContext.address); + } + + // Save the Account data: name + memmove(accountsUpdate.accounts[accountsUpdate.nbAccounts].name, data, length); + + // Store Account update type (rename or add) + accountsUpdate.existIndex[accountsUpdate.nbAccounts] = + getAccountIndex(&accountsUpdate.accounts[accountsUpdate.nbAccounts]); + // Next index + accountsUpdate.nbAccounts++; + + if (last) { + // Last account: confirm and store + ui_account_sync(); + sw = APDU_RESPONSE_OK; + } else { + // Not the last one, returns remaining places + remaining = ACCOUNTS_MAX - nbAccounts - accountsUpdate.nbAccounts; + if (remaining == 0) { + PRINTF("[ACCOUNT] - Update: Memory: no more slots!\n"); + throwError(APDU_RESPONSE_INSUFFICIENT_MEMORY); + } + sw = APDU_RESPONSE_OK | remaining; + } + returnResult(sw, 0); +} + +/** + * @brief Called when receiving an APDU to Rename an account + * + * @param buffer Received APDU buffer + * @param length APDU buffer length + */ +static void favoriteAccountRename(const uint8_t *buffer, uint8_t length, uint8_t curNameSize) { + char curName[ACCOUNT_NAME_MAX_LENGTH] = {0}; + uint8_t *data = (uint8_t *) buffer; + uint8_t nbAccount = 0; + + // Check APDU parameters (size of the different parameters) + if ((length < (2 * ACCOUNT_NAME_MIN_LENGTH)) || (length > (2 * ACCOUNT_NAME_MAX_LENGTH))) { + PRINTF("[ACCOUNT] - Rename: Invalid length: %d (%d - %d)!\n", + length, + (2 * ACCOUNT_NAME_MIN_LENGTH), + (2 * ACCOUNT_NAME_MAX_LENGTH)); + throwError(ERR_APDU_SIZE_MISMATCH); + } + if (((length - curNameSize) < ACCOUNT_NAME_MIN_LENGTH) || + ((length - curNameSize) > ACCOUNT_NAME_MAX_LENGTH)) { + PRINTF("[ACCOUNT] - Rename: Invalid new length: %d (%d - %d)!\n", + length, + ACCOUNT_NAME_MIN_LENGTH, + ACCOUNT_NAME_MAX_LENGTH); + throwError(ERR_APDU_SIZE_MISMATCH); + } + + // Save the Account data: Current (old) name + memmove(curName, data, curNameSize); + data += curNameSize; + length -= curNameSize; + + // Get current account index + nbAccount = getAccountIdByName(curName); + // Retrieve current account + memcpy((void *) &accountsUpdate.accounts[0], + (const void *) &N_accounts.accounts[nbAccount], + sizeof(AccountData_t)); + // Save the Account data: New name + memmove(accountsUpdate.accounts[0].name, data, length); + accountsUpdate.existIndex[0] = nbAccount; + accountsUpdate.nbAccounts = 1; + + ui_account_sync(); + returnResult(APDU_RESPONSE_OK, 0); +} + +/** + * @brief Called when receiving an APDU to Get a specific account + * + * @param account Account numlber to retrieve + */ +static void favoriteAccountGet(uint8_t account) { + uint8_t buffer[ACCOUNTS_MAX_APDU_LENGTH * 2] = {0}; + uint8_t nbAccounts = 0; + uint8_t length = 0; + uint8_t nameLen = 0; + + // Check already available accounts + nbAccounts = getNbFavoriteAccounts(); + + // Check Available slots + if ((nbAccounts == 0) || (account >= nbAccounts)) { + PRINTF("[ACCOUNT] - Get: Account %d not found!\n", account); + THROW(APDU_RESPONSE_REF_DATA_NOT_FOUND); + } + + // Generate APDU Response + length = 0; + memset(buffer, 0, ACCOUNTS_MAX_APDU_LENGTH); + // Convert chain_id + convertChainIdHex(buffer, N_accounts.accounts[account].chain_id); + length += ACCOUNT_CHAIN_ID_LENGTH; + // Convert address + convertAddressHex(buffer + length, + (const uint8_t *) (N_accounts.accounts[account].address + 2)); + length += ADDRESS_LENGTH; + // Convert name + nameLen = strlen((const void *) N_accounts.accounts[account].name); + memmove(buffer + length, (const void *) N_accounts.accounts[account].name, nameLen); + length += nameLen; + + returnBuffer((const uint8_t *) buffer, length, APDU_RESPONSE_OK); +} + +/** + * @brief Called when receiving an APDU to delete all accounts + */ +static void favoriteAccountDelete(void) { + // Delete all accounts + deleteFavoriteAccounts(); + returnResult(APDU_RESPONSE_OK, 0); +} + +/********************** + * GLOBAL FUNCTIONS + **********************/ + +/** + * @brief Check for already registered accounts + * + * @return the number of found accounts + */ +uint8_t getNbFavoriteAccounts(void) { + uint8_t nbAccounts = 0; + while (N_accounts.accounts[nbAccounts].address[0] != 0) { + nbAccounts++; + } + return nbAccounts; +} + +/** + * @brief Called when receiving an APDU to delete all accounts + */ +void deleteFavoriteAccounts(void) { + AccountStorage_t empty = {0}; + // Delete all accounts + nvm_write((void *) &N_accounts, &empty, sizeof(AccountStorage_t)); +} + +/** + * @brief Called when receiving an APDU to handle favorite accounts + * + * @param p1 APDU P1 parameter + * @param p2 APDU P2 parameter + * @param buffer APDU buffer + * @param length APDU buffer length + */ +void handleFavoriteAccounts(uint8_t p1, uint8_t p2, const uint8_t *buffer, uint8_t length) { + if ((p2 != 0) && ((p1 == P1_FAVORITE_DELETE) || (p1 == P1_FAVORITE_UPDATE))) { + PRINTF("[ACCOUNT] - Favorite: Invalid P1 0x%02x / P2 0x%02x!\n", p1, p2); + throwError(APDU_RESPONSE_INVALID_P1_P2); + } + if ((p2 > 1) && ((p1 == P1_FAVORITE_UPDATE_MULTI) || (p1 == P1_FAVORITE_UPDATE_PATH_MULTI))) { + PRINTF("[ACCOUNT] - Favorite: Invalid P2 0x%02x!\n", p2); + throwError(APDU_RESPONSE_INVALID_P1_P2); + } + + if ((p1 != P1_FAVORITE_GET) && (p1 != P1_FAVORITE_DELETE) && (p1 != P1_FAVORITE_UPDATE_MULTI) && + (p1 != P1_FAVORITE_UPDATE_PATH_MULTI)) { + // Reset the update tmp storage + PRINTF("[ACCOUNT] - Favorite: Command not following update group... Resetting\n"); + memset(&accountsUpdate, 0, sizeof(AccountUpdate_t)); + } + + switch (p1) { + case P1_FAVORITE_GET: + favoriteAccountGet(p2); + break; + case P1_FAVORITE_DELETE: + favoriteAccountDelete(); + break; + case P1_FAVORITE_UPDATE_MULTI: + favoriteAccountUpdate(buffer, length, (p2 == 0x01), true); + break; + case P1_FAVORITE_UPDATE: + favoriteAccountUpdate(buffer, length, true, true); + break; + case P1_FAVORITE_UPDATE_PATH_MULTI: + favoriteAccountUpdate(buffer, length, (p2 == 0x01), false); + break; + case P1_FAVORITE_UPDATE_PATH: + favoriteAccountUpdate(buffer, length, true, false); + break; + case P1_FAVORITE_RENAME: + favoriteAccountRename(buffer, length, p2); + break; + default: + PRINTF("[ACCOUNT] - Favorite: Invalid P1 0x%02x!\n", p1); + throwError(APDU_RESPONSE_INVALID_P1_P2); + break; + } +} + +// ******************************************************** +// TEST functions - to be removed +// ******************************************************** + +void dumpAllAccounts(void) { + uint8_t i; + char chain_str[20]; + + PRINTF("[ACCOUNT] - dump ALL Accounts\n"); + for (i = 0; i < getNbFavoriteAccounts(); i++) { + u64_to_string(N_accounts.accounts[i].chain_id, chain_str, sizeof(chain_str)); + PRINTF("[ACCOUNT] - [%d]: address: %s - chain_id=%-3s (name: %s)\n", + i, + N_accounts.accounts[i].address, + chain_str, + N_accounts.accounts[i].name); + } +} + +void dumpAccount(AccountData_t *account) { + char chain_str[20]; + + PRINTF("[ACCOUNT] - dump Account\n"); + u64_to_string(account->chain_id, chain_str, sizeof(chain_str)); + PRINTF("[ACCOUNT] - address: %s - chain_id=%-3s (name: %s)\n", + account->address, + chain_str, + account->name); +} +#if 0 +void accounts_init(void) { + AccountStorage_t storage = {0}; + + // TESTS - Initialize the NVM data if required + if (getNbFavoriteAccounts() == 0) { + storage.accounts[0].chain_id = 1; + strlcpy(storage.accounts[0].name, "My main Ether", ACCOUNT_NAME_MAX_LENGTH); + strlcpy(storage.accounts[0].address, + "0x6a9f04c6C38B91ed6D6dA00F51CD37B9AaFa2648", + ACCOUNT_ADDR_MAX_LENGTH); + storage.accounts[1].chain_id = 5; + strlcpy(storage.accounts[1].name, "ETH savings", ACCOUNT_NAME_MAX_LENGTH); + strlcpy(storage.accounts[1].address, + "0x524aBfF9D799c5f5219E9354B54551fB1Ed41Ceb", + ACCOUNT_ADDR_MAX_LENGTH); + nvm_write((void *) &N_accounts, &storage, sizeof(AccountStorage_t)); + } + dumpAllAccounts(); +} +#endif diff --git a/src_features/favoriteAccounts/feature_favoriteAccounts.h b/src_features/favoriteAccounts/feature_favoriteAccounts.h new file mode 100644 index 0000000000..ff71e096fc --- /dev/null +++ b/src_features/favoriteAccounts/feature_favoriteAccounts.h @@ -0,0 +1,107 @@ +#ifndef _GET_FAVORITE_ACCOUNTS_H_ +#define _GET_FAVORITE_ACCOUNTS_H_ + +#include "shared_context.h" +#include "common_utils.h" + +/********************* + * DEFINES + *********************/ +/** + * @brief Length of the Account chain_id + */ +#define ACCOUNT_CHAIN_ID_LENGTH (sizeof(uint64_t)) + +/** + * @brief Min length of the Account name + */ +#define ACCOUNT_NAME_MIN_LENGTH (5) + +/** + * @brief Max length of the Account name (excluding '\0') + */ +#define ACCOUNT_NAME_MAX_LENGTH (16) + +/** + * @brief Max length of the Account address (including '0x' and '\0') + */ +#define ACCOUNT_ADDR_MAX_LENGTH ((ADDRESS_LENGTH + 3) * 2) // Adding '0x' + '\0' + +/** + * @brief Max number of managed Accounts + */ +#define ACCOUNTS_MAX (3) + +/** + * @brief Min length of a Add/Get buffer + */ +#define ACCOUNTS_MIN_APDU_LENGTH \ + (ACCOUNT_CHAIN_ID_LENGTH + ADDRESS_LENGTH + ACCOUNT_NAME_MIN_LENGTH) +/** + * @brief Max length of a Add/Get buffer + */ +#define ACCOUNTS_MAX_APDU_LENGTH \ + (ACCOUNT_CHAIN_ID_LENGTH + ADDRESS_LENGTH + ACCOUNT_NAME_MAX_LENGTH) + +/** + * @brief Invalid Account index in Address Book, or No Accounts + */ +#define INVALID_ACCOUNT_INDEX 0xFF +/********************** + * TYPEDEFS + **********************/ + +/** + * @brief This structure contains the individual Account data. + * + */ +typedef struct AccountData_s { + uint64_t chain_id; ///< Value representing the targetted network + char name[ACCOUNT_NAME_MAX_LENGTH + 1]; ///< Account user-friendly name (@ref + ///< ACCOUNT_NAME_MIN_LENGTH to @ref + ///< ACCOUNT_NAME_MAX_LENGTH characters) + char address[ACCOUNT_ADDR_MAX_LENGTH]; ///< Account address (@ref ACCOUNT_ADDR_MAX_LENGTH max + ///< characters) +} AccountData_t; + +/** + * @brief This structure contains the whole favorite accounts data. + * + */ +typedef struct AccountStorage_s { + AccountData_t accounts[ACCOUNTS_MAX]; ///< Accounts individual data +} AccountStorage_t; + +/** + * @brief This structure contains the temporary favorite accounts data update. + * + */ +typedef struct AccountUpdate_s { + AccountData_t accounts[ACCOUNTS_MAX]; ///< Accounts individual data + uint8_t existIndex[ACCOUNTS_MAX]; ///< Indicate if account already exist + uint8_t nbAccounts; ///< Indicate current account index +} AccountUpdate_t; + +/********************** + * GLOBAL VARIABLES + **********************/ +extern const AccountStorage_t N_accounts_real; +extern AccountUpdate_t accountsUpdate; + +/********************** + * MACROS + **********************/ +#define N_accounts (*(volatile AccountStorage_t *) PIC(&N_accounts_real)) + +/********************** + * GLOBAL PROTOTYPES + **********************/ + +extern void dumpAllAccounts(void); +extern void dumpAccount(AccountData_t *account); + +extern uint8_t getNbFavoriteAccounts(void); +extern void deleteFavoriteAccounts(void); +extern void handleFavoriteAccounts(uint8_t p1, uint8_t p2, const uint8_t *buffer, uint8_t length); + +#endif // _GET_FAVORITE_ACCOUNTS_H_ diff --git a/src_nbgl/ui_account.c b/src_nbgl/ui_account.c new file mode 100644 index 0000000000..9ddd93460f --- /dev/null +++ b/src_nbgl/ui_account.c @@ -0,0 +1,495 @@ +#include +#include "common_ui.h" +#include "ui_nbgl.h" +#include "nbgl_use_case.h" +#include "nbgl_layout.h" +#include "feature_favoriteAccounts.h" +#include "network.h" +#include "network_icons.h" +#include "ledger_assert.h" + +/********************* + * DEFINES + *********************/ +/********************** + * TYPEDEFS + **********************/ +enum { + TOKEN_ACCOUNT_BACK = FIRST_USER_TOKEN, + TOKEN_ACCOUNT_ADD, + TOKEN_ACCOUNT_DEL, + TOKEN_ACCOUNT_OPTIONS, + TOKEN_ACCOUNT_1, + TOKEN_ACCOUNT_2, + TOKEN_ACCOUNT_3, + TOKEN_ACCOUNT_4, + TOKEN_ACCOUNT_5 +}; + +/********************** + * STATIC VARIABLES + **********************/ +// contexts for background and modal pages +static nbgl_layout_t layoutCtx = {0}; + +/********************** + * STATIC FUNCTIONS + **********************/ +static void account_cb(int token, uint8_t index); + +/** + * @brief Return a short address, limited in size, with '...' in the middle. + * + * @param address Full address value + * @param maxLength Maximum nb of characters (without 0x) + * @return The new formatted string + */ +static char *formatShortAddress(const char *address, uint8_t maxLength) { + uint8_t len, nbChars, offset; + char begin[ADDRESS_LENGTH + 2] = {0}; // keeping 0x + char end[ADDRESS_LENGTH] = {0}; + static char addr_short[ADDRESS_LENGTH + 2]; // Adding 0x + + // Ensure maxLength will fit in resulting string + LEDGER_ASSERT(maxLength <= sizeof(addr_short), "Bad ShortAddress size!"); + + // Computes internal variables, for better readability + len = strlen(address); + nbChars = (maxLength - 4) / 2; // 4 corresponds to the middle '....' + offset = len - nbChars; + // Retrieve the new beginning part + memcpy(begin, address, nbChars + 2); // keeping 0x + // Retrieve the new ending part + memcpy(end, address + offset, nbChars); + // Generate the full short string + snprintf(addr_short, sizeof(addr_short), "%s....%s", begin, end); + return addr_short; +} + +/** + * @brief Callback called when 'Options' button is pressed on the 'My accounts' page + * + * @param page Selected page to populate + * @param content Output structure describing the page content to display + * @return 'true' if the page content has been filled + */ +static bool options_nav_cb(uint8_t page, nbgl_pageContent_t *content) { + UNUSED(page); + static const char *const barTexts[] = {"Import more accounts", "Remove all accounts"}; + static const uint8_t barTokens[] = {TOKEN_ACCOUNT_ADD, TOKEN_ACCOUNT_DEL}; + static nbgl_icon_details_t *icons[2] = {0}; + + // Init icons list + icons[0] = PIC(&C_plus32px); + icons[1] = PIC(&C_trash32px); + + // Init page content with bars containing both test and icons + memset(content, 0, sizeof(nbgl_pageContent_t)); + content->type = BARS_LIST_ICONS; + content->barsListIcons.barTexts = barTexts; + content->barsListIcons.tokens = barTokens; + content->barsListIcons.nbBars = (getNbFavoriteAccounts() == 0) ? 1 : ARRAYLEN(barTokens); + content->barsListIcons.barIcons = (const nbgl_icon_details_t *const *) icons; + content->barsListIcons.tuneId = TUNE_TAP_CASUAL; + return true; +} + +/** + * @brief Callback called when selecting 'Remove' on the 'Options' page + * + * @param confirm If true, means that the confirmation button has been pressed + */ +static void del_cb(bool confirm) { + if (confirm) { + deleteFavoriteAccounts(); + } + ui_menu_account(); +} + +/** + * @brief Callback called when selecting 'Import' on the 'Options' page + * + * @param token Integer identifying the touched control widget + * @param index Value of the activated widget (for radio buttons, switches...) + */ +static void add_cb(int token, uint8_t index) { + UNUSED(index); + + switch (token) { + // 'Back' button widget + case TOKEN_ACCOUNT_BACK: + // Get back on 'My accounts' page + // ui_menu_account(); + // Get back on 'Options' page + account_cb(TOKEN_ACCOUNT_OPTIONS, 0); + break; + } +} + +/** + * @brief Callback called when navigating on the 'Options' page + * + * @param token Integer identifying the touched control widget + * @param index Value of the activated widget (for radio buttons, switches...) + */ +static void options_ctrl_cb(int token, uint8_t index) { + UNUSED(index); + nbgl_layoutCenteredInfo_t centeredInfo = {0}; + nbgl_layoutDescription_t layoutDescription = {0}; + nbgl_layoutQRCode_t layoutQRCode = {0}; + int status = -1; + + switch (token) { + // Widget to 'Import more accounts' + case TOKEN_ACCOUNT_ADD: + // Add page layout + layoutDescription.onActionCallback = add_cb; + layoutDescription.modal = false; + layoutCtx = nbgl_layoutGet(&layoutDescription); + + // Add description + centeredInfo.text1 = "Import accounts"; + centeredInfo.text2 = "Scan this QR code to open Ledger Live and import more accounts"; + centeredInfo.style = LARGE_CASE_INFO; + centeredInfo.onTop = true; + nbgl_layoutAddCenteredInfo(layoutCtx, ¢eredInfo); + + // Add QR code + layoutQRCode.url = "ledger.com/start/my-ledger"; + nbgl_layoutAddQRCode(layoutCtx, &layoutQRCode); + + // Add bottom exit button + status = nbgl_layoutAddBottomButton(layoutCtx, + PIC(&C_cross32px), + TOKEN_ACCOUNT_BACK, + false, + TUNE_TAP_CASUAL); + if (status < 0) { + return; + } + // Draw the page + nbgl_layoutDraw(layoutCtx); + break; + + // Widget to 'Remove all accounts' + case TOKEN_ACCOUNT_DEL: + // Draw the confirmation page, with its callback + nbgl_useCaseChoice(&C_trash32px, + "Remove accounts from this Ledger Stax", + "If you change your mind, you'll be able " + "to synchronize them again from Ledger Live.", + "Remove accounts", + "Cancel", + del_cb); + break; + default: + break; + } +} + +/** + * @brief Callback called when navigating on a 'Account' dedicated page + * + * @param token Integer identifying the touched control widget + * @param index Value of the activated widget (for radio buttons, switches...) + */ +static void single_account_cb(int token, uint8_t index) { + UNUSED(index); + + switch (token) { + // 'Back' button widget + case TOKEN_ACCOUNT_BACK: + // Get back on 'My accounts' page + ui_menu_account(); + break; + } +} + +/** + * @brief Callback called when navigating on 'My Accounts' page + * + * @param token Integer identifying the touched control widget + * @param index Value of the activated widget (for radio buttons, switches...) + */ +static void account_cb(int token, uint8_t index) { + UNUSED(index); + nbgl_layoutCenteredInfo_t centeredInfo = {0}; + nbgl_layoutDescription_t layoutDescription = {0}; + nbgl_layoutQRCode_t layoutQRCode = {0}; + int status = -1; + uint8_t account; + static char network[64]; + char *network_name = NULL; + + switch (token) { + // 'Back' button in the top bar + case TOKEN_ACCOUNT_BACK: + // Back to main App screen + ui_idle(); + break; + + // 'Options' button in the footer bar + case TOKEN_ACCOUNT_OPTIONS: + // Start a new screen for accounts management + nbgl_useCaseSettings("My Accounts", + 0, + 1, + false, + ui_menu_account, + options_nav_cb, + options_ctrl_cb); + break; + + // 'Accounts' dedicated bars + case TOKEN_ACCOUNT_1: + case TOKEN_ACCOUNT_2: + case TOKEN_ACCOUNT_3: + case TOKEN_ACCOUNT_4: + case TOKEN_ACCOUNT_5: + // Add page layout + layoutDescription.onActionCallback = single_account_cb; + layoutDescription.modal = false; + layoutCtx = nbgl_layoutGet(&layoutDescription); + + // Add description + account = token - TOKEN_ACCOUNT_1; + centeredInfo.text1 = (const char *) N_accounts.accounts[account].name; + centeredInfo.style = LARGE_CASE_INFO; + centeredInfo.onTop = true; + nbgl_layoutAddCenteredInfo(layoutCtx, ¢eredInfo); + + // Init strings to be displayed + network_name = (char *) get_network_name_from_chain_id( + (const uint64_t *) &(N_accounts.accounts[account].chain_id)); + + // Add QR code + layoutQRCode.url = (const char *) N_accounts.accounts[account].address; + layoutQRCode.text2 = (const char *) N_accounts.accounts[account].address; + if (network_name != NULL) { + snprintf(network, sizeof(network), "Network: %s", (const char *) network_name); + layoutQRCode.text1 = (const char *) network; + layoutQRCode.largeText1 = true; + nbgl_layoutAddQRCodeIcon(layoutCtx, + &layoutQRCode, + get_network_icon_from_chain_id((const uint64_t *) &( + N_accounts.accounts[account].chain_id))); + } else { + nbgl_layoutAddQRCode(layoutCtx, &layoutQRCode); + } + + // Add bottom exit button + status = nbgl_layoutAddBottomButton(layoutCtx, + PIC(&C_cross32px), + TOKEN_ACCOUNT_BACK, + false, + TUNE_TAP_CASUAL); + if (status < 0) { + return; + } + // Draw the page + nbgl_layoutDraw(layoutCtx); + break; + default: + break; + } +} + +/** + * @brief Callback called to update several entries of the Address Book + * + * @param confirm If true, means that the confirmation button has been pressed + */ +static void sync_cb(bool confirm) { + uint8_t selAccount = 0; + uint8_t i = 0; + + if (confirm) { + for (i = 0; i < accountsUpdate.nbAccounts; i++) { + // Determine current offset to write, if it is a Raname or a Add + if (accountsUpdate.existIndex[i] != INVALID_ACCOUNT_INDEX) { + selAccount = accountsUpdate.existIndex[i]; + } else { + selAccount = getNbFavoriteAccounts(); + } + nvm_write((void *) &N_accounts.accounts[selAccount], + &accountsUpdate.accounts[i], + sizeof(AccountData_t)); + } + nbgl_useCaseStatus("ADDRESS BOOK UPDATED", true, ui_menu_account); + } + memset(&accountsUpdate, 0, sizeof(AccountUpdate_t)); + dumpAllAccounts(); + ui_idle(); +} + +/********************** + * GLOBAL FUNCTIONS + **********************/ + +/** + * @brief Called to confirm account(s) modification in the Address Book + * + */ +void ui_account_sync(void) { + static nbgl_layoutTagValue_t tagValuePair[4 * ACCOUNTS_MAX] = {0}; + nbgl_layoutTagValueList_t tagValueList = {0}; + nbgl_pageInfoLongPress_t infoLongPress = {0}; + uint8_t nbpairs = 0; + uint8_t i = 0; + uint8_t nbRenames = 0; + uint8_t nbNetworks = 1; // At least the network for the 1st account data + + // Single/Multi Rename only or Add + for (i = 0; i < accountsUpdate.nbAccounts; i++) { + if (accountsUpdate.existIndex[0] != INVALID_ACCOUNT_INDEX) { + nbRenames++; + } + if (accountsUpdate.accounts[i].chain_id != accountsUpdate.accounts[0].chain_id) { + nbNetworks++; + } + } + + // Last page configuration + if (nbRenames == accountsUpdate.nbAccounts) { + // Only rename operation(s) + if (accountsUpdate.nbAccounts == 1) { + infoLongPress.text = "Rename account?"; + } else { + infoLongPress.text = "Rename accounts?"; + } + infoLongPress.longPressText = "Rename"; + } else if (getNbFavoriteAccounts() == 0) { + // Address Book is currently empty + infoLongPress.text = "Save Address book?"; + infoLongPress.longPressText = "Save"; + } else { + // At least 1 add operation + infoLongPress.text = "Update Address book?"; + infoLongPress.longPressText = "Update"; + } + if ((accountsUpdate.nbAccounts == 1) || (nbNetworks == 1)) { + // Single account or single network + infoLongPress.icon = get_network_icon_from_chain_id( + (const uint64_t *) &(accountsUpdate.accounts[0].chain_id)); + } else { + // Use Ethereum default network icon + infoLongPress.icon = &C_stax_chain_1_64px; + } + + // Tag/Value pairs init + for (i = 0; i < accountsUpdate.nbAccounts; i++) { + const char *network_name = (const char *) get_network_name_from_chain_id( + (const uint64_t *) &(accountsUpdate.accounts[i].chain_id)); + + // Start on a new page + tagValuePair[nbpairs].force_page_start = 1; + // Tag/Value pairs to display: Operation type + tagValuePair[nbpairs].item = "Operation"; + if (accountsUpdate.existIndex[i] == INVALID_ACCOUNT_INDEX) { + // This is a new account + tagValuePair[nbpairs].value = "Add account"; + } else { + // This is an existing account + tagValuePair[nbpairs].value = "Rename account"; + } + nbpairs++; + // Tag/Value pairs to display: Network + if (network_name != NULL) { + tagValuePair[nbpairs].item = "Network"; + tagValuePair[nbpairs].value = network_name; + nbpairs++; + } + // Tag/Value pairs to display: Account name + if (accountsUpdate.existIndex[i] == INVALID_ACCOUNT_INDEX) { + // This is a new account: print its requested name + tagValuePair[nbpairs].item = "Name"; + tagValuePair[nbpairs].value = accountsUpdate.accounts[i].name; + } else { + // This is an existing account: print its current name + tagValuePair[nbpairs].item = "Current Name"; + tagValuePair[nbpairs].value = + (const char *) N_accounts.accounts[accountsUpdate.existIndex[i]].name; + } + nbpairs++; + // Tag/Value pairs to display: Address or New Name + if (accountsUpdate.existIndex[i] == INVALID_ACCOUNT_INDEX) { + // This is a new account: print its address + tagValuePair[nbpairs].item = "Address"; + tagValuePair[nbpairs].value = accountsUpdate.accounts[i].address; + } else { + // This is an existing account: print its new requested name + tagValuePair[nbpairs].item = "New Name"; + tagValuePair[nbpairs].value = accountsUpdate.accounts[i].name; + } + nbpairs++; + } + + // Static review + tagValueList.pairs = tagValuePair; + tagValueList.nbPairs = nbpairs; + tagValueList.startIndex = 0; + tagValueList.nbMaxLinesForValue = 3; + tagValueList.smallCaseForValue = true; + tagValueList.wrapping = true; + + nbgl_useCaseStaticReviewLight(&tagValueList, &infoLongPress, "Cancel", sync_cb); +} + +/** + * @brief Called from the App main screen to manage the accounts + */ +void ui_menu_account(void) { + nbgl_layoutDescription_t layoutDescription = {0}; + nbgl_layoutBar_t bar = {0}; + int status = -1; + uint8_t i = 0; + static char subtext[ACCOUNTS_MAX][ACCOUNT_NAME_MAX_LENGTH + 1] = {0}; + + // Add page layout + layoutDescription.onActionCallback = account_cb; + layoutDescription.modal = false; + layoutCtx = nbgl_layoutGet(&layoutDescription); + + // Add bottom option button + status = nbgl_layoutAddBottomButton(layoutCtx, + PIC(&C_filter32px), + TOKEN_ACCOUNT_OPTIONS, + true, + TUNE_TAP_CASUAL); + if (status < 0) { + return; + } + + // Add title touchable bar + memset(&bar, 0, sizeof(nbgl_layoutBar_t)); + bar.text = "My accounts"; + bar.iconLeft = &C_leftArrow32px; + bar.token = TOKEN_ACCOUNT_BACK; + bar.centered = true; + bar.tuneId = TUNE_TAP_CASUAL; + nbgl_layoutAddTouchableBar(layoutCtx, &bar); + nbgl_layoutAddSeparationLine(layoutCtx); + + // Add available accounts + for (i = 0; i < getNbFavoriteAccounts(); i++) { + memset(&bar, 0, sizeof(nbgl_layoutBar_t)); + snprintf( + subtext[i], + sizeof(subtext[i]), + "%s", + (const char *) formatShortAddress((const char *) N_accounts.accounts[i].address, 12)); + bar.text = (const char *) N_accounts.accounts[i].name; + bar.subText = (const char *) subtext[i]; + bar.iconRight = &C_Next32px; + bar.iconLeft = + get_network_icon_from_chain_id((const uint64_t *) &(N_accounts.accounts[i].chain_id)); + bar.token = TOKEN_ACCOUNT_1 + i; + bar.centered = false; + bar.tuneId = TUNE_TAP_CASUAL; + nbgl_layoutAddTouchableBar(layoutCtx, &bar); + nbgl_layoutAddSeparationLine(layoutCtx); + } + + // Draw the page + nbgl_layoutDraw(layoutCtx); +} diff --git a/src_nbgl/ui_idle.c b/src_nbgl/ui_idle.c index 56f559ea8e..b6970afb0f 100644 --- a/src_nbgl/ui_idle.c +++ b/src_nbgl/ui_idle.c @@ -54,11 +54,13 @@ void ui_idle(void) { uint64_t mainnet_chain_id = ETHEREUM_MAINNET_CHAINID; app_name = get_network_name_from_chain_id(&mainnet_chain_id); } - - nbgl_useCaseHome((char *) app_name, - get_app_icon(true), - tagline, - true, - ui_menu_settings, - app_quit); + nbgl_useCaseHomeExtIcon((char *) app_name, + get_app_icon(true), + tagline, + true, + "My accounts", + ui_menu_account, + ui_menu_settings, + app_quit, + &C_list32px); } diff --git a/src_nbgl/ui_nbgl.h b/src_nbgl/ui_nbgl.h index 921d604d9c..0ec46a9243 100644 --- a/src_nbgl/ui_nbgl.h +++ b/src_nbgl/ui_nbgl.h @@ -17,5 +17,7 @@ const nbgl_icon_details_t* get_app_icon(bool caller_icon); void ui_idle(void); void ui_menu_settings(void); void ui_menu_about(void); +void ui_menu_account(void); +void ui_account_sync(void); #endif // _UI_NBGL_H_