From fa66bcb1cda75ac2961b806900c3e59953700e76 Mon Sep 17 00:00:00 2001 From: Anirudha Bose Date: Sat, 5 Aug 2023 03:20:12 +0530 Subject: [PATCH] feat(wallet): inject coingecko id for custom tokens (#19489) * feat(wallet): inject coingecko id for custom tokens * review(supermassive): add missing return statements * review(supermassive): remaining fixes * review(jleonard): move to endpoints + add regex for invalid addresses * review(jleonard): move address tests to utility funcs + add try..catch * lint fixes * skip query when contract address is empty --- .../browser/blockchain_list_parser.cc | 53 +++++++++++++ .../browser/blockchain_list_parser.h | 5 +- .../blockchain_list_parser_unittest.cc | 44 +++++++++-- .../browser/blockchain_registry.cc | 24 ++++++ .../browser/blockchain_registry.h | 8 ++ .../browser/blockchain_registry_unittest.cc | 77 +++++++++++++++++++ .../browser/brave_wallet_service.cc | 9 +++ .../browser/wallet_data_files_installer.cc | 56 ++++++++++++++ .../brave_wallet/common/brave_wallet.mojom | 4 + .../common/slices/api-base.slice.ts | 3 +- .../common/slices/api.slice.ts | 10 ++- .../slices/endpoints/coingecko-endpoints.ts | 68 ++++++++++++++++ .../add-custom-token-form.tsx | 58 +++++++++++--- .../utils/address-utils.test.ts | 66 +++++++++++++++- .../brave_wallet_ui/utils/address-utils.ts | 11 +++ 15 files changed, 472 insertions(+), 24 deletions(-) create mode 100644 components/brave_wallet_ui/common/slices/endpoints/coingecko-endpoints.ts diff --git a/components/brave_wallet/browser/blockchain_list_parser.cc b/components/brave_wallet/browser/blockchain_list_parser.cc index ca530c8fc8c..1bee22788f5 100644 --- a/components/brave_wallet/browser/blockchain_list_parser.cc +++ b/components/brave_wallet/browser/blockchain_list_parser.cc @@ -6,6 +6,7 @@ #include "brave/components/brave_wallet/browser/blockchain_list_parser.h" #include "brave/components/brave_wallet/browser/blockchain_list_schemas.h" +#include #include #include @@ -688,4 +689,56 @@ absl::optional ParseDappLists(const std::string& json) { return dapp_lists; } +absl::optional ParseCoingeckoIdsMap(const std::string& json) { + // { + // "0x1": { + // "0xb9ef770b6a5e12e45983c5d80545258aa38f3b78": "0chain", + // "0xe41d2489571d322189246dafa5ebde1f4699f498": "0x", + // "0x5a3e6a77ba2f983ec0d371ea3b475f8bc0811ad5": + // "0x0-ai-ai-smart-contract", + // "0xfcdb9e987f9159dab2f507007d5e3d10c510aa70": + // "0x1-tools-ai-multi-tool", + // "0x37268c4f56ebb13dfae9c16d57d17579312d0ee1": + // "0xauto-io-contract-auto-deployer" + // } + // } + + absl::optional records_v = + base::JSONReader::Read(json, base::JSON_PARSE_CHROMIUM_EXTENSIONS | + base::JSONParserOptions::JSON_PARSE_RFC); + + if (!records_v || !records_v->is_dict()) { + VLOG(1) << "Invalid response, could not parse JSON, JSON is: " << json; + return absl::nullopt; + } + + const base::Value::Dict* chain_ids = records_v->GetIfDict(); + if (!chain_ids) { + return absl::nullopt; + } + + std::map, std::string> coingecko_ids_map; + for (const auto chain_id_record : *chain_ids) { + const auto& chain_id = base::ToLowerASCII(chain_id_record.first); + + const auto* contract_addresses = chain_id_record.second.GetIfDict(); + if (!contract_addresses) { + return absl::nullopt; + } + + for (const auto contract_address_record : *contract_addresses) { + const auto& contract_address = + base::ToLowerASCII(contract_address_record.first); + const auto* coingecko_id = contract_address_record.second.GetIfString(); + if (!coingecko_id) { + return absl::nullopt; + } + + coingecko_ids_map[{chain_id, contract_address}] = *coingecko_id; + } + } + + return CoingeckoIdsMap(coingecko_ids_map.begin(), coingecko_ids_map.end()); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/blockchain_list_parser.h b/components/brave_wallet/browser/blockchain_list_parser.h index 63f65dc6203..34577bdb324 100644 --- a/components/brave_wallet/browser/blockchain_list_parser.h +++ b/components/brave_wallet/browser/blockchain_list_parser.h @@ -15,6 +15,9 @@ namespace brave_wallet { +using CoingeckoIdsMap = + base::flat_map, std::string>; + using TokenListMap = base::flat_map>; using ChainList = std::vector; @@ -38,7 +41,7 @@ absl::optional> ParseOnRampCurrencyLists( std::string GetTokenListKey(mojom::CoinType coin, const std::string& chain_id); bool ParseChainList(const std::string& json, ChainList* chain_list); absl::optional ParseDappLists(const std::string& json); - +absl::optional ParseCoingeckoIdsMap(const std::string& json); } // namespace brave_wallet #endif // BRAVE_COMPONENTS_BRAVE_WALLET_BROWSER_BLOCKCHAIN_LIST_PARSER_H_ diff --git a/components/brave_wallet/browser/blockchain_list_parser_unittest.cc b/components/brave_wallet/browser/blockchain_list_parser_unittest.cc index e88a12c6771..0bc2bde6e8e 100644 --- a/components/brave_wallet/browser/blockchain_list_parser_unittest.cc +++ b/components/brave_wallet/browser/blockchain_list_parser_unittest.cc @@ -15,7 +15,7 @@ using testing::ElementsAreArray; namespace brave_wallet { -TEST(ParseTokenListUnitTest, ParseTokenList) { +TEST(BlockchainListParseUnitTest, ParseTokenList) { std::string json(R"( { "0x06012c8cf97BEaD5deAe237070F9587f8E7A266d": { @@ -165,7 +165,7 @@ TEST(ParseTokenListUnitTest, GetTokenListKey) { "solana.0x65"); } -TEST(ParseChainListUnitTest, ParseChainList) { +TEST(BlockchainListParseUnitTest, ParseChainList) { const std::string chain_list = R"( [ { @@ -270,7 +270,7 @@ TEST(ParseChainListUnitTest, ParseChainList) { EXPECT_FALSE(chain2->is_eip1559); } -TEST(ParseDappListsUnitTest, ParseDappLists) { +TEST(BlockchainListParseUnitTest, ParseDappLists) { const std::string dapp_list = R"({ "solana": { "success": true, @@ -489,7 +489,7 @@ TEST(ParseDappListsUnitTest, ParseDappLists) { EXPECT_EQ(poly_dapp_list.size(), 0u); } -TEST(ParseOnRampTokensListMapUnitTest, ParseOnRampTokensListMap) { +TEST(BlockchainListParseUnitTest, ParseOnRampTokensListMap) { // Invalid JSON is not parsed absl::optional supported_tokens_list_map = ParseRampTokenListMaps(R"({)"); @@ -676,7 +676,7 @@ TEST(ParseOnRampTokensListMapUnitTest, ParseOnRampTokensListMap) { EXPECT_EQ(it->second[1]->coin, mojom::CoinType::ETH); } -TEST(ParseOffRampTokensListMapUnitTest, ParseOffRampTokensListMap) { +TEST(BlockchainListParseUnitTest, ParseOffRampTokensListMap) { const std::string supported_tokens_list = R"({ "tokens": [ { @@ -778,4 +778,38 @@ TEST(ParseOnRampCurrencyListTest, ParseOnRampCurrencyLists) { mojom::OnRampProvider::kStripe); } +TEST(BlockchainListParseUnitTest, ParseCoingeckoIdsMap) { + const std::string json = R"({ + "0x1": { + "0xb9ef770b6a5e12e45983c5d80545258aa38f3b78": "0chain", + "0xe41d2489571d322189246dafa5ebde1f4699f498": "0x", + "0x5a3e6a77ba2f983ec0d371ea3b475f8bc0811ad5": "0x0-ai-ai-smart-contract", + "0xfcdb9e987f9159dab2f507007d5e3d10c510aa70": "0x1-tools-ai-multi-tool" + } + })"; + + absl::optional coingecko_ids_map = + ParseCoingeckoIdsMap(json); + + ASSERT_TRUE(coingecko_ids_map); + + EXPECT_EQ((*coingecko_ids_map)[std::pair( + "0x1", "0xb9ef770b6a5e12e45983c5d80545258aa38f3b78")], + "0chain"); + + EXPECT_EQ((*coingecko_ids_map)[std::pair( + "0x1", "0xe41d2489571d322189246dafa5ebde1f4699f498")], + "0x"); + + EXPECT_EQ((*coingecko_ids_map)[std::pair( + "0x1", "0x5a3e6a77ba2f983ec0d371ea3b475f8bc0811ad5")], + "0x0-ai-ai-smart-contract"); + + EXPECT_EQ((*coingecko_ids_map)[std::pair( + "0x1", "0xfcdb9e987f9159dab2f507007d5e3d10c510aa70")], + "0x1-tools-ai-multi-tool"); + + EXPECT_FALSE(coingecko_ids_map->contains({"0x2", "0xdeadbeef"})); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/blockchain_registry.cc b/components/brave_wallet/browser/blockchain_registry.cc index 7a018161c26..32d7ad52b19 100644 --- a/components/brave_wallet/browser/blockchain_registry.cc +++ b/components/brave_wallet/browser/blockchain_registry.cc @@ -39,6 +39,11 @@ void BlockchainRegistry::Bind( receivers_.Add(this, std::move(receiver)); } +void BlockchainRegistry::UpdateCoingeckoIdsMap( + CoingeckoIdsMap coingecko_ids_map) { + coingecko_ids_map_ = std::move(coingecko_ids_map); +} + void BlockchainRegistry::UpdateTokenList(TokenListMap token_list_map) { token_list_map_ = std::move(token_list_map); } @@ -274,4 +279,23 @@ void BlockchainRegistry::GetTopDapps(const std::string& chain_id, std::move(callback).Run(std::move(dapps_copy)); } +absl::optional BlockchainRegistry::GetCoingeckoId( + const std::string& chain_id, + const std::string& contract_address) { + const auto& chain_id_lower = base::ToLowerASCII(chain_id); + const auto& contract_address_lower = base::ToLowerASCII(contract_address); + + if (!coingecko_ids_map_.contains({chain_id_lower, contract_address_lower})) { + return absl::nullopt; + } + + return coingecko_ids_map_[{chain_id_lower, contract_address_lower}]; +} + +void BlockchainRegistry::GetCoingeckoId(const std::string& chain_id, + const std::string& contract_address, + GetCoingeckoIdCallback callback) { + std::move(callback).Run(GetCoingeckoId(chain_id, contract_address)); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/blockchain_registry.h b/components/brave_wallet/browser/blockchain_registry.h index c90bd79cb66..795fcbe3591 100644 --- a/components/brave_wallet/browser/blockchain_registry.h +++ b/components/brave_wallet/browser/blockchain_registry.h @@ -33,6 +33,7 @@ class BlockchainRegistry : public mojom::BlockchainRegistry { mojo::PendingRemote MakeRemote(); void Bind(mojo::PendingReceiver receiver); + void UpdateCoingeckoIdsMap(CoingeckoIdsMap coingecko_ids_map); void UpdateTokenList(TokenListMap tokens); void UpdateTokenList(const std::string key, std::vector list); @@ -46,6 +47,9 @@ class BlockchainRegistry : public mojom::BlockchainRegistry { mojom::CoinType coin, const std::string& address); std::vector GetPrepopulatedNetworks(); + absl::optional GetCoingeckoId( + const std::string& chain_id, + const std::string& contract_address); // BlockchainRegistry interface methods void GetTokenByAddress(const std::string& chain_id, @@ -76,11 +80,15 @@ class BlockchainRegistry : public mojom::BlockchainRegistry { void GetTopDapps(const std::string& chain_id, mojom::CoinType coin, GetTopDappsCallback callback) override; + void GetCoingeckoId(const std::string& chain_id, + const std::string& contract_address, + GetCoingeckoIdCallback callback) override; protected: std::vector* GetTokenListFromChainId( const std::string& chain_id); + CoingeckoIdsMap coingecko_ids_map_; TokenListMap token_list_map_; ChainList chain_list_; DappListMap dapp_lists_; diff --git a/components/brave_wallet/browser/blockchain_registry_unittest.cc b/components/brave_wallet/browser/blockchain_registry_unittest.cc index 4794b3734e1..cc99e7187b1 100644 --- a/components/brave_wallet/browser/blockchain_registry_unittest.cc +++ b/components/brave_wallet/browser/blockchain_registry_unittest.cc @@ -419,6 +419,15 @@ const char dapp_lists_json[] = R"({ } })"; +const char coingecko_ids_map_json[] = R"({ + "0xa": { + "0x7f5c764cbc14f9669b88837ca1490cca17c31607": "usd-coin" + }, + "0x65": { + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": "usd-coin" + } +})"; + std::vector GetChainIds( const std::vector& networks) { std::vector result; @@ -929,4 +938,72 @@ TEST(BlockchainRegistryUnitTest, GetEthTokenListMap) { } } +TEST(BlockchainRegistryUnitTest, GetCoingeckoId) { + base::test::TaskEnvironment task_environment; + auto* registry = BlockchainRegistry::GetInstance(); + absl::optional coingecko_ids_map = + ParseCoingeckoIdsMap(coingecko_ids_map_json); + ASSERT_TRUE(coingecko_ids_map); + registry->UpdateCoingeckoIdsMap(std::move(*coingecko_ids_map)); + + // Chain: ✅ + // Contract: ✅ + // Result: ✅ + EXPECT_EQ( + registry->GetCoingeckoId(mojom::kOptimismMainnetChainId, + "0x7f5c764cbc14f9669b88837ca1490cca17c31607"), + "usd-coin"); + + // Chain: ✅ + // Contract: ❌ + // Result: ❌ + EXPECT_EQ( + registry->GetCoingeckoId(mojom::kOptimismMainnetChainId, "0xdeadbeef"), + absl::nullopt); + + // Chain: ❌ + // Contract: ✅ + // Result: ❌ + EXPECT_EQ(registry->GetCoingeckoId( + "0xdeadbeef", "0x7f5c764cbc14f9669b88837ca1490cca17c31607"), + absl::nullopt); + + // Chain: ❌ + // Contract: ❌ + // Result: ❌ + EXPECT_EQ(registry->GetCoingeckoId("0xdeadbeef", "0xcafebabe"), + absl::nullopt); + + // Chain: ✅ (wrong case) + // Contract: ✅ + // Result: ✅ + EXPECT_EQ(registry->GetCoingeckoId( + "0xA", "0x7f5c764cbc14f9669b88837ca1490cca17c31607"), + "usd-coin"); + + // Chain: ✅ + // Contract: ✅ (mixed case EIP-55) + // Result: ✅ + EXPECT_EQ( + registry->GetCoingeckoId(mojom::kOptimismMainnetChainId, + "0x7F5c764cBc14f9669B88837ca1490cCa17c31607"), + "usd-coin"); + + // Chain: ✅ + // Contract: ✅ (mixed case Solana) + // Result: ✅ + EXPECT_EQ( + registry->GetCoingeckoId(mojom::kSolanaMainnet, + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + "usd-coin"); + + // Chain: ✅ + // Contract: ✅ (wrong case Solana) + // Result: ✅ + EXPECT_EQ( + registry->GetCoingeckoId(mojom::kSolanaMainnet, + "epjfwdd5aufqssqem2qn1xzybapc8g4weggkzwytdt1v"), + "usd-coin"); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/brave_wallet_service.cc b/components/brave_wallet/browser/brave_wallet_service.cc index 3f0ee751a8a..d471dea7fee 100644 --- a/components/brave_wallet/browser/brave_wallet_service.cc +++ b/components/brave_wallet/browser/brave_wallet_service.cc @@ -1649,6 +1649,15 @@ void BraveWalletService::AddSuggestTokenRequest( request->token = std::move(token); } + if (request->token->coingecko_id.empty()) { + absl::optional coingecko_id = + BlockchainRegistry::GetInstance()->GetCoingeckoId( + request->token->chain_id, request->token->contract_address); + if (coingecko_id) { + request->token->coingecko_id = *coingecko_id; + } + } + add_suggest_token_requests_[addr] = std::move(request); add_suggest_token_callbacks_[addr] = std::move(callback); add_suggest_token_ids_[addr] = std::move(id); diff --git a/components/brave_wallet/browser/wallet_data_files_installer.cc b/components/brave_wallet/browser/wallet_data_files_installer.cc index b1be2f00581..0d07ed06f25 100644 --- a/components/brave_wallet/browser/wallet_data_files_installer.cc +++ b/components/brave_wallet/browser/wallet_data_files_installer.cc @@ -155,6 +155,39 @@ void OnSanitizedOnRampCurrenciesLists( std::move(*lists)); } +void OnSanitizedCoingeckoIdsMap(data_decoder::JsonSanitizer::Result result) { + if (!result.has_value()) { + VLOG(1) << "CoingeckoIdsMap JSON validation error:" << result.error(); + return; + } + + absl::optional coingecko_ids_map = + ParseCoingeckoIdsMap(*result); + if (!coingecko_ids_map) { + VLOG(1) << "Can't parse coingecko-ids.json"; + return; + } + + BlockchainRegistry::GetInstance()->UpdateCoingeckoIdsMap( + std::move(*coingecko_ids_map)); +} + +void HandleParseCoingeckoIdsMap(base::FilePath absolute_install_dir, + const std::string& filename) { + const base::FilePath coingecko_ids_map_json_path = + absolute_install_dir.AppendASCII(filename); + std::string coingecko_ids_map_json; + if (!base::ReadFileToString(coingecko_ids_map_json_path, + &coingecko_ids_map_json)) { + VLOG(1) << "Can't read coingecko ids map file: " << filename; + return; + } + + data_decoder::JsonSanitizer::Sanitize( + std::move(coingecko_ids_map_json), + base::BindOnce(&OnSanitizedCoingeckoIdsMap)); +} + void HandleParseTokenList(base::FilePath absolute_install_dir, const std::string& filename, mojom::CoinType coin_type) { @@ -231,6 +264,21 @@ void HandleParseOnRampCurrenciesLists(base::FilePath absolute_install_dir, base::BindOnce(&OnSanitizedOnRampCurrenciesLists)); } +void ParseCoingeckoIdsMapAndUpdateRegistry(const base::FilePath& install_dir) { + // On some platforms (e.g. Mac) we use symlinks for paths. Convert paths to + // absolute paths to avoid unexpected failure. base::MakeAbsoluteFilePath() + // requires IO so it can only be done in this function. + const base::FilePath absolute_install_dir = + base::MakeAbsoluteFilePath(install_dir); + + if (absolute_install_dir.empty()) { + LOG(ERROR) << "Failed to get absolute install path."; + return; + } + + HandleParseCoingeckoIdsMap(absolute_install_dir, "coingecko-ids.json"); +} + void ParseTokenListAndUpdateRegistry(const base::FilePath& install_dir) { // On some platforms (e.g. Mac) we use symlinks for paths. Convert paths to // absolute paths to avoid unexpected failure. base::MakeAbsoluteFilePath() @@ -240,6 +288,7 @@ void ParseTokenListAndUpdateRegistry(const base::FilePath& install_dir) { if (absolute_install_dir.empty()) { LOG(ERROR) << "Failed to get absolute install path."; + return; } HandleParseTokenList(absolute_install_dir, "contract-map.json", @@ -259,6 +308,7 @@ void ParseChainListAndUpdateRegistry(const base::FilePath& install_dir) { if (absolute_install_dir.empty()) { LOG(ERROR) << "Failed to get absolute install path."; + return; } HandleParseChainList(absolute_install_dir, "chainlist.json"); @@ -273,6 +323,7 @@ void ParseDappListsAndUpdateRegistry(const base::FilePath& install_dir) { if (absolute_install_dir.empty()) { LOG(ERROR) << "Failed to get absolute install path."; + return; } HandleParseDappList(absolute_install_dir, "dapp-lists.json"); @@ -287,6 +338,7 @@ void ParseOnRampListsAndUpdateRegistry(const base::FilePath& install_dir) { if (absolute_install_dir.empty()) { LOG(ERROR) << "Failed to get absolute install path."; + return; } HandleParseRampTokenLists(absolute_install_dir, "ramp-tokens.json"); @@ -356,6 +408,10 @@ void WalletDataFilesInstallerPolicy::ComponentReady( const base::FilePath& path, base::Value::Dict manifest) { last_installed_wallet_version = version; + + sequenced_task_runner_->PostTask( + FROM_HERE, base::BindOnce(&ParseCoingeckoIdsMapAndUpdateRegistry, path)); + sequenced_task_runner_->PostTask( FROM_HERE, base::BindOnce(&ParseTokenListAndUpdateRegistry, path)); diff --git a/components/brave_wallet/common/brave_wallet.mojom b/components/brave_wallet/common/brave_wallet.mojom index 37b6b830ffd..cdc19b9c15a 100644 --- a/components/brave_wallet/common/brave_wallet.mojom +++ b/components/brave_wallet/common/brave_wallet.mojom @@ -785,6 +785,10 @@ interface BlockchainRegistry { // Returns lists of top dapps GetTopDapps(string chain_id, CoinType coin) => (array dapps); + + // Returns the Coincecko ID for a given chain id and contract address + GetCoingeckoId(string chain_id, string contract_address) + => (string? coingecko_id); }; // Implements the HD wallet, Ledger & Trezor integration, account management, diff --git a/components/brave_wallet_ui/common/slices/api-base.slice.ts b/components/brave_wallet_ui/common/slices/api-base.slice.ts index cd7c62c7c13..eac7062c528 100644 --- a/components/brave_wallet_ui/common/slices/api-base.slice.ts +++ b/components/brave_wallet_ui/common/slices/api-base.slice.ts @@ -46,7 +46,8 @@ export function createWalletApiBase () { 'NFTPinningStatus', 'AutoPinEnabled', 'OnRampAssets', - 'OffRampAssets' + 'OffRampAssets', + 'CoingeckoId' ], endpoints: ({ mutation, query }) => ({}) }) diff --git a/components/brave_wallet_ui/common/slices/api.slice.ts b/components/brave_wallet_ui/common/slices/api.slice.ts index 0e4163c86b8..11f17134e19 100644 --- a/components/brave_wallet_ui/common/slices/api.slice.ts +++ b/components/brave_wallet_ui/common/slices/api.slice.ts @@ -101,9 +101,10 @@ import { signLedgerSolanaTransaction, signTrezorTransaction } from '../async/hardware' -import { getAccountBalancesKey } from '../../utils/balance-utils'; -import { onRampEndpoints } from './endpoints/on-ramp.endpoints'; -import { offRampEndpoints } from './endpoints/off-ramp.endpoints'; +import { getAccountBalancesKey } from '../../utils/balance-utils' +import { onRampEndpoints } from './endpoints/on-ramp.endpoints' +import { offRampEndpoints } from './endpoints/off-ramp.endpoints' +import { coingeckoEndpoints } from './endpoints/coingecko-endpoints' type GetAccountTokenCurrentBalanceArg = { accountId: BraveWallet.AccountId @@ -2803,6 +2804,8 @@ export function createWalletApi () { .injectEndpoints({ endpoints: onRampEndpoints }) // offRamp endpoints .injectEndpoints({ endpoints: offRampEndpoints }) + // coingecko endpoints + .injectEndpoints({ endpoints: coingeckoEndpoints }) } export type WalletApi = ReturnType @@ -2823,6 +2826,7 @@ export const { useGetAccountTokenCurrentBalanceQuery, useGetHardwareAccountDiscoveryBalanceQuery, useGetAddressByteCodeQuery, + useGetCoingeckoIdQuery, useGetCombinedTokenBalanceForAllAccountsQuery, useGetDefaultFiatCurrencyQuery, useGetERC721MetadataQuery, diff --git a/components/brave_wallet_ui/common/slices/endpoints/coingecko-endpoints.ts b/components/brave_wallet_ui/common/slices/endpoints/coingecko-endpoints.ts new file mode 100644 index 00000000000..3b8f5bc5318 --- /dev/null +++ b/components/brave_wallet_ui/common/slices/endpoints/coingecko-endpoints.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. +import { + BraveWallet +} from '../../../constants/types' +import { WalletApiEndpointBuilderParams } from '../api-base.slice' + +// Utils +import { + isValidEVMAddress, + isValidSolanaAddress +} from '../../../utils/address-utils' + +export const coingeckoEndpoints = ({ + mutation, + query +}: WalletApiEndpointBuilderParams) => { + return { + getCoingeckoId: query< + string | null, + Pick + >({ + queryFn: async ( + { chainId, contractAddress }, + api, + extraOptions, + baseQuery + ) => { + + try { + // Ignore invalid EVM and Solana addresses. + // + // EVM => 0x + 40 hex characters + // Solana => 32-44 base58 characters + if ( + !isValidEVMAddress(contractAddress) && + !isValidSolanaAddress(contractAddress) + ) { + return { + data: null + } + } + + const { blockchainRegistry } = baseQuery(undefined).data + const { coingeckoId } = await blockchainRegistry.getCoingeckoId( + chainId, + contractAddress + ) + + return { + data: coingeckoId + } + } catch (err) { + console.error(err) + return { + error: 'Unable to query coingeckoId' + } + } + }, + providesTags: (res, err, { chainId, contractAddress }) => + err + ? ['CoingeckoId', 'UNKNOWN_ERROR'] + : [{ type: 'CoingeckoId', id: `${chainId}-${contractAddress}` }] + }) + } +} diff --git a/components/brave_wallet_ui/components/shared/add-custom-token-form/add-custom-token-form.tsx b/components/brave_wallet_ui/components/shared/add-custom-token-form/add-custom-token-form.tsx index b5561badbb9..1d980fea014 100644 --- a/components/brave_wallet_ui/components/shared/add-custom-token-form/add-custom-token-form.tsx +++ b/components/brave_wallet_ui/components/shared/add-custom-token-form/add-custom-token-form.tsx @@ -5,6 +5,7 @@ import * as React from 'react' import { useSelector } from 'react-redux' +import { skipToken } from '@reduxjs/toolkit/query/react' import Button from '@brave/leo/react/button' // utils @@ -27,8 +28,8 @@ import { useTokenInfo } from '../../../common/hooks' import { - useGetNetworksRegistryQuery, - useGetSelectedChainQuery + useGetCoingeckoIdQuery, + useGetNetworksRegistryQuery } from '../../../common/slices/api.slice' import { useGetCombinedTokensListQuery @@ -74,7 +75,6 @@ export const AddCustomTokenForm = (props: Props) => { } = props // queries - const { data: selectedNetwork } = useGetSelectedChainQuery() const { data: networksRegistry = emptyNetworksRegistry } = useGetNetworksRegistryQuery() @@ -86,7 +86,8 @@ export const AddCustomTokenForm = (props: Props) => { const [tokenName, setTokenName] = React.useState('') const [tokenSymbol, setTokenSymbol] = React.useState('') const [tokenDecimals, setTokenDecimals] = React.useState('') - const [coingeckoID, setCoingeckoID] = React.useState('') + const [customCoingeckoId, setCustomCoingeckoId] = + React.useState(undefined) const [iconURL, setIconURL] = React.useState('') const [customAssetsNetwork, setCustomAssetsNetwork] = React.useState() @@ -107,12 +108,28 @@ export const AddCustomTokenForm = (props: Props) => { } = useTokenInfo( getBlockchainTokenInfo, combinedTokensList, - customAssetsNetwork || selectedNetwork + customAssetsNetwork ) const { onAddCustomAsset } = useAssetManagement() + const { data: matchedCoingeckoId } = useGetCoingeckoIdQuery( + customAssetsNetwork && tokenContractAddress + ? { + chainId: customAssetsNetwork.chainId, + contractAddress: tokenContractAddress + } + : skipToken + ) + + // If user has customized the coingecko id, use that even if it's an empty + // string. + const coingeckoId = customCoingeckoId ?? ( + foundTokenInfoByContractAddress?.coingeckoId || + tokenContractAddress ? matchedCoingeckoId || '': '' + ) + // Handle Form Input Changes const handleTokenNameChanged = React.useCallback((event: React.ChangeEvent) => { setHasError(false) @@ -136,7 +153,7 @@ export const AddCustomTokenForm = (props: Props) => { const handleCoingeckoIDChanged = React.useCallback((event: React.ChangeEvent) => { setHasError(false) - setCoingeckoID(event.target.value) + setCustomCoingeckoId(event.target.value) }, []) const handleIconURLChanged = React.useCallback((event: React.ChangeEvent) => { @@ -150,7 +167,7 @@ export const AddCustomTokenForm = (props: Props) => { onChangeContractAddress('') setTokenSymbol('') setTokenDecimals('') - setCoingeckoID('') + setCustomCoingeckoId(undefined) setIconURL('') }, [onChangeContractAddress]) @@ -162,7 +179,7 @@ export const AddCustomTokenForm = (props: Props) => { onNftAssetFound(foundTokenInfoByContractAddress.contractAddress) } let foundToken = { ...foundTokenInfoByContractAddress } - foundToken.coingeckoId = coingeckoID !== '' ? coingeckoID : foundTokenInfoByContractAddress.coingeckoId + foundToken.coingeckoId = coingeckoId foundToken.logo = foundToken.logo ? foundToken.logo : iconURL foundToken.chainId = customAssetsNetwork.chainId onAddCustomAsset(foundToken) @@ -180,14 +197,26 @@ export const AddCustomTokenForm = (props: Props) => { tokenId: '', logo: iconURL, visible: true, - coingeckoId: coingeckoID, + coingeckoId, chainId: customAssetsNetwork.chainId, coin: customAssetsNetwork.coin } onAddCustomAsset(newToken) } onHideForm() - }, [tokenContractAddress, foundTokenInfoByContractAddress, customAssetsNetwork, iconURL, tokenDecimals, tokenName, tokenSymbol, coingeckoID, onAddCustomAsset, onHideForm, onNftAssetFound]) + }, [ + tokenContractAddress, + foundTokenInfoByContractAddress, + customAssetsNetwork, + iconURL, + tokenDecimals, + tokenName, + tokenSymbol, + coingeckoId, + onAddCustomAsset, + onHideForm, + onNftAssetFound + ]) const onToggleShowAdvancedFields = () => setShowAdvancedFields(prev => !prev) @@ -204,7 +233,12 @@ export const AddCustomTokenForm = (props: Props) => { const onSelectCustomNetwork = React.useCallback((network: BraveWallet.NetworkInfo) => { setCustomAssetsNetwork(network) onHideNetworkDropDown() - }, [onHideNetworkDropDown]) + setCustomCoingeckoId(undefined) + }, [ + setCustomAssetsNetwork, + onHideNetworkDropDown, + setCustomCoingeckoId + ]) const onClickCancel = React.useCallback(() => { resetInputFields() @@ -375,7 +409,7 @@ export const AddCustomTokenForm = (props: Props) => { {getLocale('braveWalletWatchListCoingeckoId')} diff --git a/components/brave_wallet_ui/utils/address-utils.test.ts b/components/brave_wallet_ui/utils/address-utils.test.ts index b6d3d1ef240..4a4bdea06a6 100644 --- a/components/brave_wallet_ui/utils/address-utils.test.ts +++ b/components/brave_wallet_ui/utils/address-utils.test.ts @@ -2,8 +2,18 @@ // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this file, // you can obtain one at https://mozilla.org/MPL/2.0/. -import { isValidAddress, isValidFilAddress } from './address-utils' -import { mockAddresses, mockFilAddresses, mockFilInvalilAddresses } from '../common/constants/mocks' +import { + isValidAddress, + isValidEVMAddress, + isValidSolanaAddress, + isValidFilAddress +} from './address-utils' +import { + mockAddresses, + mockFilAddresses, + mockFilInvalilAddresses, + mockSolanaAccount +} from '../common/constants/mocks' const validAdresses = mockAddresses.map((addr: string) => [addr, true]) const invalidAddresses = [ @@ -14,6 +24,7 @@ const invalidAddresses = [ const validFilAdresses = mockFilAddresses.map((addr: string) => [addr, true]) const validFilInvalidAdresses = mockFilInvalilAddresses.map((addr: string) => [addr, false]) +const validSolanaAddress = mockSolanaAccount.address describe('Address Utils', () => { describe('isValidAddress', () => { @@ -46,4 +57,55 @@ describe('Address Utils', () => { expect(isValid).toBe(isValidFilAddress(address)) }) }) + + describe('isValidEVMAddress', () => { + it.each(validAdresses)( + 'should return true if address is valid', + (address: string, isValid: boolean) => { + expect(isValid).toBe(isValidEVMAddress(address)) + } + ) + + it.each(invalidAddresses)( + 'should return false if address is invalid', + (address: string, isValid: boolean) => { + expect(isValid).toBe(isValidEVMAddress(address)) + } + ) + + it('should return false if address length is invalid', () => { + expect(isValidEVMAddress('0xdeadbeef')).toBe(false) + expect(isValidEVMAddress(mockAddresses[1] + "0")).toBe(false) + }) + + it('should return false if address does not start with 0x', () => { + const testAddress = mockAddresses[1].substring(3) // exclude 0x + expect(isValidEVMAddress(testAddress)).toBe(false) + }) + + it('should return false if address contains invalid characters', () => { + expect( + isValidEVMAddress('0xdeadbeefdeadbeefdeadbeefdeadbeefdeadzzzz') + ).toBe(false) + }) + }) + + describe('isValidSolanaAddress', () => { + it('should return true if address is valid', () => { + expect(isValidSolanaAddress(validSolanaAddress)).toBe(true) + }) + + it('should return false if address length is invalid', () => { + expect( + isValidSolanaAddress(validSolanaAddress.substring(0, 31)) + ).toBe(false) + expect(isValidSolanaAddress(validSolanaAddress + "4")).toBe(false) + }) + + it('should return false if address contains invalid characters', () => { + expect( + isValidSolanaAddress(validSolanaAddress.substring(0, 31) + '0') + ).toBe(false) + }) + }) }) diff --git a/components/brave_wallet_ui/utils/address-utils.ts b/components/brave_wallet_ui/utils/address-utils.ts index a069fa2c516..2b31e31c775 100644 --- a/components/brave_wallet_ui/utils/address-utils.ts +++ b/components/brave_wallet_ui/utils/address-utils.ts @@ -20,6 +20,9 @@ export function isValidFilAddress (value: string): boolean { return (value.length === 41 || value.length === 86 || value.length === 44) } +/** + * @deprecated Use isValidEVMAddress instead + */ export function isValidAddress (value: string, length: number = 20): boolean { if (!value.match(/^0x[0-9A-Fa-f]*$/)) { return false @@ -32,6 +35,14 @@ export function isValidAddress (value: string, length: number = 20): boolean { return true } +export function isValidEVMAddress (value: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(value) +} + +export function isValidSolanaAddress (value: string): boolean { + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value) +} + export const suggestNewAccountName = ( accounts: BraveWallet.AccountInfo[], network: BraveWallet.NetworkInfo