diff --git a/components/brave_wallet/browser/BUILD.gn b/components/brave_wallet/browser/BUILD.gn index 43442db2c3b0..0a7ef84b0e28 100644 --- a/components/brave_wallet/browser/BUILD.gn +++ b/components/brave_wallet/browser/BUILD.gn @@ -91,6 +91,8 @@ static_library("browser") { "json_rpc_service.h", "keyring_service.cc", "keyring_service.h", + "nft_metadata_fetcher.cc", + "nft_metadata_fetcher.h", "nonce_tracker.cc", "nonce_tracker.h", "password_encryptor.cc", diff --git a/components/brave_wallet/browser/json_rpc_service.cc b/components/brave_wallet/browser/json_rpc_service.cc index ab53d5674b6a..951ab98392bc 100644 --- a/components/brave_wallet/browser/json_rpc_service.cc +++ b/components/brave_wallet/browser/json_rpc_service.cc @@ -48,7 +48,6 @@ #include "brave/components/brave_wallet/common/web3_provider_constants.h" #include "brave/components/decentralized_dns/core/constants.h" #include "brave/components/decentralized_dns/core/utils.h" -#include "brave/components/ipfs/buildflags/buildflags.h" #include "components/grit/brave_components_strings.h" #include "components/prefs/pref_service.h" #include "components/prefs/scoped_user_pref_update.h" @@ -57,11 +56,6 @@ #include "ui/base/l10n/l10n_util.h" #include "url/origin.h" -#if BUILDFLAG(ENABLE_IPFS) -#include "brave/components/ipfs/ipfs_constants.h" -#include "brave/components/ipfs/ipfs_utils.h" -#endif - using api_request_helper::APIRequestHelper; using decentralized_dns::EnsOffchainResolveMethod; @@ -182,6 +176,9 @@ JsonRpcService::JsonRpcService( api_request_helper_ens_offchain_ = std::make_unique( GetENSOffchainNetworkTrafficAnnotationTag(), url_loader_factory); } + + nft_metadata_fetcher_ = std::unique_ptr( + new NftMetadataFetcher(url_loader_factory, this, prefs_)); } JsonRpcService::JsonRpcService( @@ -1961,37 +1958,37 @@ void JsonRpcService::ContinueGetERC721TokenBalance( void JsonRpcService::GetERC721Metadata(const std::string& contract_address, const std::string& token_id, const std::string& chain_id, - GetTokenMetadataCallback callback) { - JsonRpcService::GetTokenMetadata(contract_address, token_id, chain_id, - kERC721MetadataInterfaceId, - std::move(callback)); + GetERC721MetadataCallback callback) { + nft_metadata_fetcher_->GetEthTokenMetadata( + contract_address, token_id, chain_id, kERC721MetadataInterfaceId, + std::move(callback)); } void JsonRpcService::GetERC1155Metadata(const std::string& contract_address, const std::string& token_id, const std::string& chain_id, - GetTokenMetadataCallback callback) { - JsonRpcService::GetTokenMetadata(contract_address, token_id, chain_id, - kERC1155MetadataInterfaceId, - std::move(callback)); + GetERC1155MetadataCallback callback) { + nft_metadata_fetcher_->GetEthTokenMetadata( + contract_address, token_id, chain_id, kERC1155MetadataInterfaceId, + std::move(callback)); } -void JsonRpcService::GetTokenMetadata(const std::string& contract_address, - const std::string& token_id, - const std::string& chain_id, - const std::string& interface_id, - GetTokenMetadataCallback callback) { +void JsonRpcService::GetEthTokenUri(const std::string& chain_id, + const std::string& contract_address, + const std::string& token_id, + const std::string& interface_id, + GetEthTokenUriCallback callback) { auto network_url = GetNetworkURL(prefs_, chain_id, mojom::CoinType::ETH); if (!network_url.is_valid()) { std::move(callback).Run( - "", mojom::ProviderError::kInvalidParams, + GURL(), mojom::ProviderError::kInvalidParams, l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); return; } if (!EthAddress::IsValidAddress(contract_address)) { std::move(callback).Run( - "", mojom::ProviderError::kInvalidParams, + GURL(), mojom::ProviderError::kInvalidParams, l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); return; } @@ -1999,7 +1996,7 @@ void JsonRpcService::GetTokenMetadata(const std::string& contract_address, uint256_t token_id_uint = 0; if (!HexValueToUint256(token_id, &token_id_uint)) { std::move(callback).Run( - "", mojom::ProviderError::kInvalidParams, + GURL(), mojom::ProviderError::kInvalidParams, l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); return; } @@ -2008,56 +2005,27 @@ void JsonRpcService::GetTokenMetadata(const std::string& contract_address, if (interface_id == kERC721MetadataInterfaceId) { if (!erc721::TokenUri(token_id_uint, &function_signature)) { std::move(callback).Run( - "", mojom::ProviderError::kInvalidParams, + GURL(), mojom::ProviderError::kInvalidParams, l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); return; } } else if (interface_id == kERC1155MetadataInterfaceId) { if (!erc1155::Uri(token_id_uint, &function_signature)) { std::move(callback).Run( - "", mojom::ProviderError::kInvalidParams, + GURL(), mojom::ProviderError::kInvalidParams, l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); return; } } else { // Unknown inteface ID std::move(callback).Run( - "", mojom::ProviderError::kInvalidParams, + GURL(), mojom::ProviderError::kInvalidParams, l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); return; } auto internal_callback = - base::BindOnce(&JsonRpcService::OnGetSupportsInterfaceTokenMetadata, - weak_ptr_factory_.GetWeakPtr(), contract_address, - function_signature, network_url, std::move(callback)); - - GetSupportsInterface(contract_address, interface_id, chain_id, - std::move(internal_callback)); -} - -void JsonRpcService::OnGetSupportsInterfaceTokenMetadata( - const std::string& contract_address, - const std::string& function_signature, - const GURL& network_url, - GetTokenMetadataCallback callback, - bool is_supported, - mojom::ProviderError error, - const std::string& error_message) { - if (error != mojom::ProviderError::kSuccess) { - std::move(callback).Run("", error, error_message); - return; - } - - if (!is_supported) { - std::move(callback).Run( - "", mojom::ProviderError::kMethodNotSupported, - l10n_util::GetStringUTF8(IDS_WALLET_METHOD_NOT_SUPPORTED_ERROR)); - return; - } - - auto internal_callback = - base::BindOnce(&JsonRpcService::OnGetTokenUri, + base::BindOnce(&JsonRpcService::OnGetEthTokenUri, weak_ptr_factory_.GetWeakPtr(), std::move(callback)); RequestInternal(eth::eth_call("", contract_address, "", "", "", @@ -2065,11 +2033,11 @@ void JsonRpcService::OnGetSupportsInterfaceTokenMetadata( true, network_url, std::move(internal_callback)); } -void JsonRpcService::OnGetTokenUri(GetTokenMetadataCallback callback, - APIRequestResult api_request_result) { +void JsonRpcService::OnGetEthTokenUri(GetEthTokenUriCallback callback, + APIRequestResult api_request_result) { if (!api_request_result.Is2XXResponseCode()) { std::move(callback).Run( - "", mojom::ProviderError::kInternalError, + GURL(), mojom::ProviderError::kInternalError, l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); return; } @@ -2081,97 +2049,11 @@ void JsonRpcService::OnGetTokenUri(GetTokenMetadataCallback callback, std::string error_message; ParseErrorResult(api_request_result.body(), &error, &error_message); - std::move(callback).Run("", error, error_message); + std::move(callback).Run(url, error, error_message); return; } - // Obtain JSON from the URL depending on the scheme. - // IPFS, HTTPS, and data URIs are supported. - // IPFS and HTTPS URIs require an additional request to fetch the metadata. - std::string metadata_json; - std::string scheme = url.scheme(); -#if BUILDFLAG(ENABLE_IPFS) - if (scheme != url::kDataScheme && scheme != url::kHttpsScheme && - scheme != ipfs::kIPFSScheme) { -#else - if (scheme != url::kDataScheme && scheme != url::kHttpsScheme) { -#endif - std::move(callback).Run( - "", mojom::ProviderError::kMethodNotSupported, - l10n_util::GetStringUTF8(IDS_WALLET_METHOD_NOT_SUPPORTED_ERROR)); - return; - } - - if (scheme == url::kDataScheme) { - if (!eth::ParseDataURIAndExtractJSON(url, &metadata_json)) { - std::move(callback).Run( - "", mojom::ProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - return; - } - - // Sanitize JSON - data_decoder::JsonSanitizer::Sanitize( - std::move(metadata_json), - base::BindOnce(&JsonRpcService::OnSanitizeTokenMetadata, - weak_ptr_factory_.GetWeakPtr(), std::move(callback))); - return; - } - -#if BUILDFLAG(ENABLE_IPFS) - if (scheme == ipfs::kIPFSScheme && - !ipfs::TranslateIPFSURI(url, &url, ipfs::GetDefaultNFTIPFSGateway(prefs_), - false)) { - std::move(callback).Run("", mojom::ProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - return; - } -#endif - - auto internal_callback = - base::BindOnce(&JsonRpcService::OnGetTokenMetadataPayload, - weak_ptr_factory_.GetWeakPtr(), std::move(callback)); - api_request_helper_->Request("GET", url, "", "", true, - std::move(internal_callback)); -} - -void JsonRpcService::OnSanitizeTokenMetadata( - GetTokenMetadataCallback callback, - data_decoder::JsonSanitizer::Result result) { - if (result.error) { - VLOG(1) << "Data URI JSON validation error:" << *result.error; - std::move(callback).Run("", mojom::ProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - return; - } - - std::string metadata_json; - if (result.value.has_value()) { - metadata_json = result.value.value(); - } - - std::move(callback).Run(metadata_json, mojom::ProviderError::kSuccess, ""); -} - -void JsonRpcService::OnGetTokenMetadataPayload( - GetTokenMetadataCallback callback, - APIRequestResult api_request_result) { - if (!api_request_result.Is2XXResponseCode()) { - std::move(callback).Run( - "", mojom::ProviderError::kInternalError, - l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); - return; - } - - // Invalid JSON becomes an empty string after sanitization - if (api_request_result.body().empty()) { - std::move(callback).Run("", mojom::ProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - return; - } - - std::move(callback).Run(api_request_result.body(), - mojom::ProviderError::kSuccess, ""); + std::move(callback).Run(url, mojom::ProviderError::kSuccess, ""); } void JsonRpcService::GetERC1155TokenBalance( @@ -2527,6 +2409,13 @@ void JsonRpcService::OnGetSPLTokenAccountBalance( std::move(callback).Run(amount, decimals, ui_amount_string, mojom::SolanaProviderError::kSuccess, ""); } + +void JsonRpcService::GetSolTokenMetadata(const std::string& token_mint_address, + GetSolTokenMetadataCallback callback) { + nft_metadata_fetcher_->GetSolTokenMetadata(token_mint_address, + std::move(callback)); +} + void JsonRpcService::SendFilecoinTransaction( const std::string& signed_tx, SendFilecoinTransactionCallback callback) { diff --git a/components/brave_wallet/browser/json_rpc_service.h b/components/brave_wallet/browser/json_rpc_service.h index a695c3248a17..1d615575cad2 100644 --- a/components/brave_wallet/browser/json_rpc_service.h +++ b/components/brave_wallet/browser/json_rpc_service.h @@ -19,6 +19,7 @@ #include "brave/components/api_request_helper/api_request_helper.h" #include "brave/components/brave_wallet/browser/brave_wallet_constants.h" #include "brave/components/brave_wallet/browser/ens_resolver_task.h" +#include "brave/components/brave_wallet/browser/nft_metadata_fetcher.h" #include "brave/components/brave_wallet/browser/sns_resolver_task.h" #include "brave/components/brave_wallet/browser/solana_transaction.h" #include "brave/components/brave_wallet/browser/unstoppable_domains_multichain_calls.h" @@ -29,7 +30,6 @@ #include "mojo/public/cpp/bindings/receiver_set.h" #include "mojo/public/cpp/bindings/remote.h" #include "mojo/public/cpp/bindings/remote_set.h" -#include "services/data_decoder/public/cpp/json_sanitizer.h" #include "url/gurl.h" #include "url/origin.h" @@ -43,6 +43,7 @@ class PrefService; namespace brave_wallet { class EnsResolverTask; +class NftMetadataFetcher; class JsonRpcService : public KeyedService, public mojom::JsonRpcService { public: @@ -86,7 +87,6 @@ class JsonRpcService : public KeyedService, public mojom::JsonRpcService { const std::vector>& reward, mojom::ProviderError error, const std::string& error_message)>; - using EthGetLogsCallback = base::OnceCallback& logs, mojom::ProviderError error, @@ -299,26 +299,27 @@ class JsonRpcService : public KeyedService, public mojom::JsonRpcService { const std::string& chain_id, GetERC721TokenBalanceCallback callback) override; - using GetTokenMetadataCallback = - base::OnceCallback; - void GetTokenMetadata(const std::string& contract_address, - const std::string& token_id, - const std::string& chain_id, - const std::string& interface_id, - GetTokenMetadataCallback callback); - void GetERC721Metadata(const std::string& contract_address, const std::string& token_id, const std::string& chain_id, - GetTokenMetadataCallback callback) override; + GetERC721MetadataCallback callback) override; void GetERC1155Metadata(const std::string& contract_address, const std::string& token_id, const std::string& chain_id, - GetTokenMetadataCallback callback) override; + GetERC1155MetadataCallback callback) override; + // GetEthTokenUri should only be called after check whether the contract + // supports the ERC721 or ERC1155 interface + void GetEthTokenUri(const std::string& chain_id, + const std::string& contract_address, + const std::string& token_id, + const std::string& interface_id, + GetEthTokenUriCallback callback); void EthGetLogs(const std::string& chain_id, const std::string& from_block, @@ -365,6 +366,8 @@ class JsonRpcService : public KeyedService, public mojom::JsonRpcService { const std::string& token_mint_address, const std::string& chain_id, GetSPLTokenAccountBalanceCallback callback) override; + void GetSolTokenMetadata(const std::string& token_mint_address, + GetSolTokenMetadataCallback callback) override; using SendSolanaTransactionCallback = base::OnceCallback receivers_; PrefService* prefs_ = nullptr; PrefService* local_state_prefs_ = nullptr; + std::unique_ptr nft_metadata_fetcher_; base::WeakPtrFactory weak_ptr_factory_; }; diff --git a/components/brave_wallet/browser/json_rpc_service_unittest.cc b/components/brave_wallet/browser/json_rpc_service_unittest.cc index 1e4ecbeafee0..219d3bfccfd5 100644 --- a/components/brave_wallet/browser/json_rpc_service_unittest.cc +++ b/components/brave_wallet/browser/json_rpc_service_unittest.cc @@ -923,6 +923,25 @@ class JsonRpcServiceUnitTest : public testing::Test { })); } + void SetSolTokenMetadataInterceptor( + const GURL& expected_rpc_url, + const std::string& get_account_info_response, + const GURL& expected_metadata_url, + const std::string& metadata_response) { + auto network_url = + GetNetwork(mojom::kLocalhostChainId, mojom::CoinType::SOL); + ASSERT_TRUE(expected_rpc_url.is_valid()); + ASSERT_TRUE(expected_metadata_url.is_valid()); + url_loader_factory_.SetInterceptor(base::BindLambdaForTesting( + [&, expected_rpc_url, get_account_info_response, expected_metadata_url, + metadata_response](const network::ResourceRequest& request) { + url_loader_factory_.AddResponse(expected_rpc_url.spec(), + get_account_info_response); + url_loader_factory_.AddResponse(expected_metadata_url.spec(), + metadata_response); + })); + } + void SetInterceptor(const GURL& expected_url, const std::string& expected_method, const std::string& expected_cache_header, @@ -1104,20 +1123,20 @@ class JsonRpcServiceUnitTest : public testing::Test { run_loop.Run(); } - void TestGetTokenMetadata(const std::string& contract, - const std::string& token_id, - const std::string& chain_id, - const std::string& interface_id, - const std::string& expected_response, - mojom::ProviderError expected_error, - const std::string& expected_error_message) { + void TestGetEthTokenUri(const std::string& contract, + const std::string& token_id, + const std::string& chain_id, + const std::string& interface_id, + const GURL& expected_uri, + mojom::ProviderError expected_error, + const std::string& expected_error_message) { base::RunLoop run_loop; - json_rpc_service_->GetTokenMetadata( - contract, token_id, chain_id, interface_id, - base::BindLambdaForTesting([&](const std::string& response, + json_rpc_service_->GetEthTokenUri( + chain_id, contract, token_id, interface_id, + base::BindLambdaForTesting([&](const GURL& uri, mojom::ProviderError error, const std::string& error_message) { - EXPECT_EQ(response, expected_response); + EXPECT_EQ(uri, expected_uri); EXPECT_EQ(error, expected_error); EXPECT_EQ(error_message, expected_error_message); run_loop.Quit(); @@ -1398,6 +1417,24 @@ class JsonRpcServiceUnitTest : public testing::Test { loop.Run(); } + void TestGetSolTokenMetadata(const std::string& token_mint_address, + const std::string& expected_response, + mojom::SolanaProviderError expected_error, + const std::string& expected_error_message) { + base::RunLoop loop; + json_rpc_service_->GetSolTokenMetadata( + token_mint_address, + base::BindLambdaForTesting([&](const std::string& response, + mojom::SolanaProviderError error, + const std::string& error_message) { + EXPECT_EQ(response, expected_response); + EXPECT_EQ(error, expected_error); + EXPECT_EQ(error_message, expected_error_message); + loop.Quit(); + })); + loop.Run(); + } + protected: std::unique_ptr json_rpc_service_; network::TestURLLoaderFactory url_loader_factory_; @@ -3349,246 +3386,9 @@ TEST_F(JsonRpcServiceUnitTest, GetERC721OwnerOf) { EXPECT_TRUE(callback_called); } -TEST_F(JsonRpcServiceUnitTest, GetTokenMetadata) { - const std::string https_token_uri_response = R"({ - "jsonrpc":"2.0", - "id":1, - "result":"0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002468747470733a2f2f696e76697369626c65667269656e64732e696f2f6170692f3138313700000000000000000000000000000000000000000000000000000000" - })"; - const std::string http_token_uri_response = R"({ - "jsonrpc":"2.0", - "id":1, - "result":"0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000020687474703a2f2f696e76697369626c65667269656e64732e696f2f6170692f31" - })"; - const std::string data_token_uri_response = R"({ - "jsonrpc":"2.0", - "id":1, - "result": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000135646174613a6170706c69636174696f6e2f6a736f6e3b6261736536342c65794a686448527961574a316447567a496a6f69496977695a47567a59334a7063485270623234694f694a4f623234675a6e56755a326c696247556762476c7662694973496d6c745957646c496a6f695a474630595470706257466e5a53397a646d6372654731734f324a68633255324e43785153453479576e6c434e474a586548566a656a4270595568534d474e4562335a4d4d32517a5a486b314d3031354e585a6a62574e3254577042643031444f58706b62574e7053556861634670595a454e694d326335535770425a3031445154464e5245466e546c524264306c714e44686a5230597759554e436131425453576c4d656a513454444e4f4d6c70364e4430694c434a755957316c496a6f69546b5a4d496e303d0000000000000000000000" - })"; - const std::string data_token_uri_response_invalid_json = R"({ - "jsonrpc":"2.0", - "id":1, - "result":"0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000085646174613a6170706c69636174696f6e2f6a736f6e3b6261736536342c65794a755957316c496a6f69546b5a4d49697767496d526c63324e796158423061573975496a6f69546d397549475a31626d6470596d786c49477870623234694c43416959585230636d6c696458526c637949364969497349434a706257466e5a5349364969493d000000000000000000000000000000000000000000000000000000" - })"; - const std::string data_token_uri_response_empty_string = R"({ - "jsonrpc":"2.0", - "id":1, - "result":"0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001d646174613a6170706c69636174696f6e2f6a736f6e3b6261736536342c000000" - })"; - const std::string interface_supported_response = R"({ - "jsonrpc":"2.0", - "id":1, - "result": "0x0000000000000000000000000000000000000000000000000000000000000001" - })"; - const std::string exceeds_limit_json = R"({ - "jsonrpc":"2.0", - "id":1, - "error": { - "code":-32005, - "message": "Request exceeds defined limit" - } - })"; - const std::string interface_not_supported_response = R"({ - "jsonrpc":"2.0", - "id":1, - "result":"0x0000000000000000000000000000000000000000000000000000000000000000" - })"; - const std::string invalid_json = - "It might make sense just to get some in case it catches on"; - const std::string ipfs_token_uri_response = R"({ - "jsonrpc":"2.0", - "id":1, - "result":"0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003a697066733a2f2f516d65536a53696e4870506e6d586d73704d6a776958794e367a533445397a63636172694752336a7863615774712f31383137000000000000" - })"; - const std::string ipfs_metadata_response = - R"({"attributes":[{"trait_type":"Mouth","value":"Bored Cigarette"},{"trait_type":"Fur","value":"Gray"},{"trait_type":"Background","value":"Aquamarine"},{"trait_type":"Clothes","value":"Tuxedo Tee"},{"trait_type":"Hat","value":"Bayc Hat Black"},{"trait_type":"Eyes","value":"Coins"}],"image":"ipfs://QmQ82uDT3JyUMsoZuaFBYuEucF654CYE5ktPUrnA5d4VDH"})"; - - // Invalid inputs - // (1/3) Invalid contract address - TestGetTokenMetadata("", "0x1", mojom::kMainnetChainId, - kERC721MetadataInterfaceId, "", - mojom::ProviderError::kInvalidParams, - l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); - - // (2/3) Invalid token ID - TestGetTokenMetadata("0x06012c8cf97BEaD5deAe237070F9587f8E7A266d", "", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kInvalidParams, - l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); - - // (3/3) Invalid chain ID - TestGetTokenMetadata("0x06012c8cf97BEaD5deAe237070F9587f8E7A266d", "0x1", "", - kERC721MetadataInterfaceId, "", - mojom::ProviderError::kInvalidParams, - l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); - - // Mismatched - // (4/4) Unknown interfaceID - TestGetTokenMetadata("0x06012c8cf97BEaD5deAe237070F9587f8E7A266d", "0x1", "", - kERC721InterfaceId, "", - mojom::ProviderError::kInvalidParams, - l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); - - // Valid inputs - // (1/3) HTTP URI - SetTokenMetadataInterceptor( - kERC721MetadataInterfaceId, mojom::kMainnetChainId, - interface_supported_response, https_token_uri_response, - https_metadata_response); - TestGetTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, - https_metadata_response, mojom::ProviderError::kSuccess, - ""); - - // (2/3) IPFS URI - SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, - mojom::kLocalhostChainId, - interface_supported_response, - ipfs_token_uri_response, ipfs_metadata_response); - TestGetTokenMetadata("0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", - mojom::kLocalhostChainId, kERC721MetadataInterfaceId, - ipfs_metadata_response, mojom::ProviderError::kSuccess, - ""); - - // (3/3) Data URI - SetTokenMetadataInterceptor( - kERC721MetadataInterfaceId, mojom::kMainnetChainId, - interface_supported_response, data_token_uri_response); - TestGetTokenMetadata( - "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, - R"({"attributes":"","description":"Non fungible lion","image":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MDAgNTAwIj48cGF0aCBkPSIiLz48L3N2Zz4=","name":"NFL"})", - mojom::ProviderError::kSuccess, ""); - - // Invalid supportsInterface response - // (1/4) Timeout - SetTokenMetadataInterceptor( - kERC721MetadataInterfaceId, mojom::kMainnetChainId, - interface_supported_response, https_token_uri_response, "", - net::HTTP_REQUEST_TIMEOUT); - TestGetTokenMetadata("0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kInternalError, - l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); - - // (2/4) Invalid JSON - SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, - mojom::kMainnetChainId, invalid_json); - TestGetTokenMetadata("0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - - // (3/4) Request exceeds provider limit - SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, - mojom::kMainnetChainId, exceeds_limit_json); - TestGetTokenMetadata("0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kLimitExceeded, - "Request exceeds defined limit"); - - // (4/4) Interface not supported - SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, - mojom::kMainnetChainId, - interface_not_supported_response); - TestGetTokenMetadata( - "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kMethodNotSupported, - l10n_util::GetStringUTF8(IDS_WALLET_METHOD_NOT_SUPPORTED_ERROR)); - - // Invalid tokenURI response (6 total) - // (1/6) Timeout - SetTokenMetadataInterceptor( - kERC721MetadataInterfaceId, mojom::kMainnetChainId, - interface_supported_response, https_token_uri_response, "", net::HTTP_OK, - net::HTTP_REQUEST_TIMEOUT); - TestGetTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kInternalError, - l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); - - // (2/6) Invalid Provider JSON - SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, - mojom::kMainnetChainId, - interface_supported_response, invalid_json); - TestGetTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - - // (3/6) Invalid JSON in data URI - SetTokenMetadataInterceptor( - kERC721MetadataInterfaceId, mojom::kMainnetChainId, - interface_supported_response, data_token_uri_response_invalid_json); - TestGetTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - - // (4/6) Empty string as JSON in data URI - SetTokenMetadataInterceptor( - kERC721MetadataInterfaceId, mojom::kMainnetChainId, - interface_supported_response, data_token_uri_response_empty_string); - TestGetTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - - // (5/6) Request exceeds limit - SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, - mojom::kMainnetChainId, - interface_supported_response, exceeds_limit_json); - TestGetTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kLimitExceeded, - "Request exceeds defined limit"); - - // (6/6) URI scheme is not suported (HTTP) - SetTokenMetadataInterceptor( - kERC721MetadataInterfaceId, mojom::kMainnetChainId, - interface_supported_response, http_token_uri_response); - TestGetTokenMetadata( - "0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kMethodNotSupported, - l10n_util::GetStringUTF8(IDS_WALLET_METHOD_NOT_SUPPORTED_ERROR)); - - // Invalid metadata response (2 total) - // (1/2) Timeout - SetTokenMetadataInterceptor( - kERC721MetadataInterfaceId, mojom::kMainnetChainId, - interface_supported_response, https_token_uri_response, - https_metadata_response, net::HTTP_OK, net::HTTP_OK, - net::HTTP_REQUEST_TIMEOUT); - TestGetTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kInternalError, - l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); - - // (2/2) Invalid JSON - SetTokenMetadataInterceptor( - kERC721MetadataInterfaceId, mojom::kMainnetChainId, - interface_supported_response, ipfs_token_uri_response, invalid_json); - TestGetTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", - mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", - mojom::ProviderError::kParsingError, - l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); - - // ERC1155 - SetTokenMetadataInterceptor( - kERC1155MetadataInterfaceId, mojom::kMainnetChainId, - interface_supported_response, https_token_uri_response, - https_metadata_response); - TestGetTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", - mojom::kMainnetChainId, kERC1155MetadataInterfaceId, - https_metadata_response, mojom::ProviderError::kSuccess, - ""); -} - TEST_F(JsonRpcServiceUnitTest, GetERC721Metadata) { // Ensure GetERC721Metadata passes the correct interface ID to - // GetTokenMetadata + // GetEthTokenMetadata SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, mojom::kMainnetChainId, R"({ @@ -3609,7 +3409,7 @@ TEST_F(JsonRpcServiceUnitTest, GetERC721Metadata) { TEST_F(JsonRpcServiceUnitTest, GetERC1155Metadata) { // Ensure GetERC1155Metadata passes the correct interface ID to - // GetTokenMetadata + // GetEthTokenMetadata SetTokenMetadataInterceptor(kERC1155MetadataInterfaceId, mojom::kMainnetChainId, R"({ @@ -5850,4 +5650,267 @@ TEST_F(JsonRpcServiceUnitTest, EthGetLogs) { std::move(expected_logs), mojom::ProviderError::kSuccess, ""); } +TEST_F(JsonRpcServiceUnitTest, GetSolTokenMetadata) { + // Valid inputs should yield metadata JSON (happy case) + std::string get_account_info_response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.13.3", + "slot": 161038284 + }, + "value": { + "data": [ + "BGUN5hJf2zSue3S0I/fCq16UREt5NxP6mQdaq4cdGPs3Q8PG/R6KFUSgce78Nwk9Frvkd9bMbvTIKCRSDy88nZQgAAAAU1BFQ0lBTCBTQVVDRQAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAMgAAABodHRwczovL2JhZmtyZWlmNHd4NTR3anI3cGdmdWczd2xhdHIzbmZudHNmd25ndjZldXNlYmJxdWV6cnhlbmo2Y2s0LmlwZnMuZHdlYi5saW5rP2V4dD0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOgDAQIAAABlDeYSX9s0rnt0tCP3wqtelERLeTcT+pkHWquHHRj7NwFiDUmu+U8sXOOZQXL36xmknL+Zzd/z3uw2G0ERMo8Eth4BAgABAf8BAAEBoivvbAzLh2kD2cSu6IQIqGQDGeoh/UEDizyp6mLT1tUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "base64" + ], + "executable": false, + "lamports": 5616720, + "owner": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", + "rentEpoch": 361 + } + }, + "id": 1 + })"; + const std::string valid_metadata_response = + R"({"attributes":[{"trait_type":"hair","value":"green & blue"},{"trait_type":"pontus","value":"no"}],"description":"","external_url":"","image":"https://bafkreiagsgqhjudpta6trhjuv5y2n2exsrhbkkprl64tvg2mftjsdm3vgi.ipfs.dweb.link?ext=png","name":"SPECIAL SAUCE","properties":{"category":"image","creators":[{"address":"7oUUEdptZnZVhSet4qobU9PtpPfiNUEJ8ftPnrC6YEaa","share":98},{"address":"tsU33UT3K2JTfLgHUo7hdzRhRe4wth885cqVbM8WLiq","share":2}],"files":[{"type":"image/png","uri":"https://bafkreiagsgqhjudpta6trhjuv5y2n2exsrhbkkprl64tvg2mftjsdm3vgi.ipfs.dweb.link?ext=png"}],"maxSupply":0},"seller_fee_basis_points":1000,"symbol":""})"; + auto network_url = GetNetwork(mojom::kLocalhostChainId, mojom::CoinType::SOL); + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", + valid_metadata_response, + mojom::SolanaProviderError::kSuccess, ""); + + // Invalid token_mint_address yields internal error. + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("Invalid", "", + mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Non 200 getAccountInfo response of yields internal server error. + SetHTTPRequestTimeoutInterceptor(); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Invalid getAccountInfo response JSON yields internal error + SetSolTokenMetadataInterceptor( + network_url, "Invalid json response", + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Valid response JSON, invalid account info (missing result.value.owner + // field) info yields parse error + get_account_info_response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.13.3", + "slot": 161038284 + }, + "value": { + "data": [ + "BGUN5hJf2zSue3S0I/fCq16UREt5NxP6mQdaq4cdGPs3Q8PG/R6KFUSgce78Nwk9Frvkd9bMbvTIKCRSDy88nZQgAAAAU1BFQ0lBTCBTQVVDRQAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAMgAAABodHRwczovL2JhZmtyZWlmNHd4NTR3anI3cGdmdWczd2xhdHIzbmZudHNmd25ndjZldXNlYmJxdWV6cnhlbmo2Y2s0LmlwZnMuZHdlYi5saW5rP2V4dD0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOgDAQIAAABlDeYSX9s0rnt0tCP3wqtelERLeTcT+pkHWquHHRj7NwFiDUmu+U8sXOOZQXL36xmknL+Zzd/z3uw2G0ERMo8Eth4BAgABAf8BAAEBoivvbAzLh2kD2cSu6IQIqGQDGeoh/UEDizyp6mLT1tUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "base64" + ], + "executable": false, + "lamports": 5616720, + "rentEpoch": 361 + } + }, + "id": 1 + })"; + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // Valid response JSON, parsable account info, but invalid account info data + // (invalid base64) yields parse error + get_account_info_response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.13.3", + "slot": 161038284 + }, + "value": { + "data": [ + "*Invalid Base64*", + "base64" + ], + "executable": false, + "lamports": 5616720, + "owner": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", + "rentEpoch": 361 + } + }, + "id": 1 + })"; + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // Valid response JSON, parsable account info, invalid account info data + // (valid base64, but invalid borsh encoded metadata) yields parse error + get_account_info_response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.13.3", + "slot": 161038284 + }, + "value": { + "data": [ + "d2hvb3BzIQ==", + "base64" + ], + "executable": false, + "lamports": 5616720, + "owner": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", + "rentEpoch": 361 + } + }, + "id": 1 + })"; + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // Valid response JSON, parsable account info, invalid account info data + // (valid base64, valid borsh encoding, but when decoded the URI is not a + // valid URI) + get_account_info_response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.13.3", + "slot": 161038284 + }, + "value": { + "data": [ + "BGUN5hJf2zSue3S0I/fCq16UREt5NxP6mQdaq4cdGPs3Q8PG/R6KFUSgce78Nwk9Frvkd9bMbvTIKCRSDy88nZQgAAAAU1BFQ0lBTCBTQVVDRQAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAAsAAABpbnZhbGlkIHVybOgDAQIAAABlDeYSX9s0rnt0tCP3wqtelERLeTcT+pkHWquHHRj7NwFiDUmu+U8sXOOZQXL36xmknL+Zzd/z3uw2G0ERMo8Eth4BAgABAf8BAAEBoivvbAzLh2kD2cSu6IQIqGQDGeoh/UEDizyp6mLT1tUA", + "base64" + ], + "executable": false, + "lamports": 5616720, + "owner": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", + "rentEpoch": 361 + } + }, + "id": 1 + })"; + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); +} + +TEST_F(JsonRpcServiceUnitTest, GetEthTokenUri) { + // Invalid contract address input + TestGetEthTokenUri("", "0x1", mojom::kMainnetChainId, + kERC721MetadataInterfaceId, GURL(), + mojom::ProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + + // Invalid token ID input + TestGetEthTokenUri("0x06012c8cf97BEaD5deAe237070F9587f8E7A266d", "", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, GURL(), + mojom::ProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + + // Invalid chain ID input + TestGetEthTokenUri("0x06012c8cf97BEaD5deAe237070F9587f8E7A266d", "0x1", "", + kERC721MetadataInterfaceId, GURL(), + mojom::ProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + + // Unknown interfaceID input + TestGetEthTokenUri("0x06012c8cf97BEaD5deAe237070F9587f8E7A266d", "0x1", + mojom::kMainnetChainId, "invalid interface", GURL(), + mojom::ProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + + // Valid inputs but HTTP Timeout + SetHTTPRequestTimeoutInterceptor(); + TestGetEthTokenUri("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, GURL(), + mojom::ProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Valid inputs, request exceeds limit response + SetLimitExceededJsonErrorResponse(); + TestGetEthTokenUri("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, GURL(), + mojom::ProviderError::kLimitExceeded, + "Request exceeds defined limit"); + + // Valid inputs, invalid provider JSON + SetInvalidJsonInterceptor(); + TestGetEthTokenUri("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, GURL(), + mojom::ProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // Valid inputs, valid RPC response JSON, valid RLP encoding, invalid URI + SetInterceptor(GetNetwork(mojom::kMainnetChainId, mojom::CoinType::ETH), + "eth_call", "", R"({ + "jsonrpc":"2.0", + "id":1, + "result":"0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000b696e76616c69642075726c000000000000000000000000000000000000000000" + })"); + TestGetEthTokenUri("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, GURL(), + mojom::ProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // All valid + SetInterceptor(GetNetwork(mojom::kMainnetChainId, mojom::CoinType::ETH), + "eth_call", "", R"({ + "jsonrpc":"2.0", + "id":1, + "result":"0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002468747470733a2f2f696e76697369626c65667269656e64732e696f2f6170692f3138313700000000000000000000000000000000000000000000000000000000" + })"); + TestGetEthTokenUri("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + GURL("https://invisiblefriends.io/api/1817"), + mojom::ProviderError::kSuccess, ""); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/nft_metadata_fetcher.cc b/components/brave_wallet/browser/nft_metadata_fetcher.cc new file mode 100644 index 000000000000..a4ad1fd2988b --- /dev/null +++ b/components/brave_wallet/browser/nft_metadata_fetcher.cc @@ -0,0 +1,381 @@ +/* Copyright 2022 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 http://mozilla.org/MPL/2.0/. */ + +#include "brave/components/brave_wallet/browser/nft_metadata_fetcher.h" + +#include +#include + +#include "base/base64.h" +#include "brave/components/brave_wallet/browser/brave_wallet_utils.h" +#include "brave/components/brave_wallet/browser/eth_data_builder.h" +#include "brave/components/brave_wallet/browser/eth_response_parser.h" +#include "brave/components/brave_wallet/browser/json_rpc_service.h" +#include "brave/components/brave_wallet/common/hex_utils.h" +#include "brave/components/ipfs/buildflags/buildflags.h" +#include "services/network/public/cpp/shared_url_loader_factory.h" +#include "ui/base/l10n/l10n_util.h" + +#if BUILDFLAG(ENABLE_IPFS) +#include "brave/components/ipfs/ipfs_constants.h" +#include "brave/components/ipfs/ipfs_utils.h" +#endif + +namespace { + +absl::optional DecodeUint32(const std::vector& input, + size_t& offset) { + if (offset >= input.size() || input.size() - offset < sizeof(uint32_t)) { + return absl::nullopt; + } + + // Read bytes in little endian order. + base::span s = + base::make_span(input.begin() + offset, sizeof(uint32_t)); + uint32_t uint32_le = *reinterpret_cast(s.data()); + + offset += sizeof(uint32_t); + +#if defined(ARCH_CPU_LITTLE_ENDIAN) + return uint32_le; +#else + return base::ByteSwap(uint32_le); +#endif +} + +net::NetworkTrafficAnnotationTag GetNetworkTrafficAnnotationTag() { + return net::DefineNetworkTrafficAnnotation("json_rpc_service", R"( + semantics { + sender: "NFT Metata Fetcher" + description: + "This service is used to fetch NFT metadata " + "on behalf of the user interacting with the native Brave wallet." + trigger: + "Triggered by uses of the native Brave wallet." + data: + "NFT Metadata JSON." + destination: WEBSITE + } + policy { + cookies_allowed: NO + setting: + "You can enable or disable this feature on chrome://flags." + policy_exception_justification: + "Not implemented." + } + )"); +} + +} // namespace + +namespace brave_wallet { + +NftMetadataFetcher::NftMetadataFetcher( + scoped_refptr url_loader_factory, + JsonRpcService* json_rpc_service, + PrefService* prefs) + : api_request_helper_(new APIRequestHelper(GetNetworkTrafficAnnotationTag(), + url_loader_factory)), + json_rpc_service_(json_rpc_service), + prefs_(prefs), + weak_ptr_factory_(this) {} + +NftMetadataFetcher::~NftMetadataFetcher() = default; + +void NftMetadataFetcher::GetEthTokenMetadata( + const std::string& contract_address, + const std::string& token_id, + const std::string& chain_id, + const std::string& interface_id, + GetEthTokenMetadataCallback callback) { + auto network_url = GetNetworkURL(prefs_, chain_id, mojom::CoinType::ETH); + if (!network_url.is_valid()) { + std::move(callback).Run( + "", mojom::ProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + return; + } + + if (!EthAddress::IsValidAddress(contract_address)) { + std::move(callback).Run( + "", mojom::ProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + return; + } + + auto internal_callback = + base::BindOnce(&NftMetadataFetcher::OnGetSupportsInterface, + weak_ptr_factory_.GetWeakPtr(), contract_address, + interface_id, token_id, chain_id, std::move(callback)); + + json_rpc_service_->GetSupportsInterface( + contract_address, interface_id, chain_id, std::move(internal_callback)); +} + +void NftMetadataFetcher::OnGetSupportsInterface( + const std::string& contract_address, + const std::string& interface_id, + const std::string& token_id, + const std::string& chain_id, + GetEthTokenMetadataCallback callback, + bool is_supported, + mojom::ProviderError error, + const std::string& error_message) { + if (error != mojom::ProviderError::kSuccess) { + std::move(callback).Run("", error, error_message); + return; + } + + if (!is_supported) { + std::move(callback).Run( + "", mojom::ProviderError::kMethodNotSupported, + l10n_util::GetStringUTF8(IDS_WALLET_METHOD_NOT_SUPPORTED_ERROR)); + return; + } + + auto internal_callback = + base::BindOnce(&NftMetadataFetcher::OnGetEthTokenUri, + weak_ptr_factory_.GetWeakPtr(), std::move(callback)); + + json_rpc_service_->GetEthTokenUri(chain_id, contract_address, token_id, + interface_id, std::move(internal_callback)); +} + +void NftMetadataFetcher::OnGetEthTokenUri(GetEthTokenMetadataCallback callback, + const GURL& uri, + mojom::ProviderError error, + const std::string& error_message) { + if (error != mojom::ProviderError::kSuccess) { + std::move(callback).Run("", error, error_message); + return; + } + + if (!uri.is_valid()) { + std::move(callback).Run( + "", mojom::ProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + return; + } + + auto internal_callback = + base::BindOnce(&NftMetadataFetcher::CompleteGetEthTokenMetadata, + weak_ptr_factory_.GetWeakPtr(), std::move(callback)); + FetchMetadata(uri, std::move(internal_callback)); +} + +void NftMetadataFetcher::FetchMetadata( + GURL url, + GetTokenMetadataIntermediateCallback callback) { + // Obtain JSON from the URL depending on the scheme. + // IPFS, HTTPS, and data URIs are supported. + // IPFS and HTTPS URIs require an additional request to fetch the metadata. + std::string metadata_json; + std::string scheme = url.scheme(); +#if BUILDFLAG(ENABLE_IPFS) + if (scheme != url::kDataScheme && scheme != url::kHttpsScheme && + scheme != ipfs::kIPFSScheme) { +#else + if (scheme != url::kDataScheme && scheme != url::kHttpsScheme) { +#endif + std::move(callback).Run( + "", static_cast(mojom::JsonRpcError::kInternalError), + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + return; + } + + if (scheme == url::kDataScheme) { + if (!eth::ParseDataURIAndExtractJSON(url, &metadata_json)) { + std::move(callback).Run( + "", static_cast(mojom::JsonRpcError::kParsingError), + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + return; + } + + // Sanitize JSON + data_decoder::JsonSanitizer::Sanitize( + std::move(metadata_json), + base::BindOnce(&NftMetadataFetcher::OnSanitizeTokenMetadata, + weak_ptr_factory_.GetWeakPtr(), std::move(callback))); + return; + } + +#if BUILDFLAG(ENABLE_IPFS) + if (scheme == ipfs::kIPFSScheme && + !ipfs::TranslateIPFSURI(url, &url, ipfs::GetDefaultNFTIPFSGateway(prefs_), + false)) { + std::move(callback).Run( + "", static_cast(mojom::JsonRpcError::kParsingError), + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + return; + } +#endif + + auto internal_callback = + base::BindOnce(&NftMetadataFetcher::OnGetTokenMetadataPayload, + weak_ptr_factory_.GetWeakPtr(), std::move(callback)); + api_request_helper_->Request("GET", url, "", "", true, + std::move(internal_callback)); +} + +void NftMetadataFetcher::OnSanitizeTokenMetadata( + GetTokenMetadataIntermediateCallback callback, + data_decoder::JsonSanitizer::Result result) { + if (result.error) { + VLOG(1) << "Data URI JSON validation error:" << *result.error; + std::move(callback).Run( + "", static_cast(mojom::JsonRpcError::kParsingError), + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + return; + } + + std::string metadata_json; + if (result.value.has_value()) { + metadata_json = result.value.value(); + } + + std::move(callback).Run(metadata_json, 0, ""); // 0 is kSuccess +} + +void NftMetadataFetcher::OnGetTokenMetadataPayload( + GetTokenMetadataIntermediateCallback callback, + APIRequestResult api_request_result) { + mojom::ProviderErrorUnionPtr error; + if (!api_request_result.Is2XXResponseCode()) { + std::move(callback).Run( + "", static_cast(mojom::JsonRpcError::kInternalError), + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + return; + } + + // Invalid JSON becomes an empty string after sanitization + if (api_request_result.body().empty()) { + std::move(callback).Run( + "", static_cast(mojom::JsonRpcError::kParsingError), + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + return; + } + + std::move(callback).Run(api_request_result.body(), 0, ""); // 0 is kSuccess +} + +void NftMetadataFetcher::CompleteGetEthTokenMetadata( + GetEthTokenMetadataCallback callback, + const std::string& response, + int error, + const std::string& error_message) { + mojom::ProviderError mojo_err = static_cast(error); + if (!mojom::IsKnownEnumValue(mojo_err)) + mojo_err = mojom::ProviderError::kUnknown; + std::move(callback).Run(response, mojo_err, error_message); +} + +void NftMetadataFetcher::GetSolTokenMetadata( + const std::string& token_mint_address, + GetSolTokenMetadataCallback callback) { + // Derive metadata PDA for the NFT accounts + absl::optional associated_metadata_account = + SolanaKeyring::GetAssociatedMetadataAccount(token_mint_address); + if (!associated_metadata_account) { + std::move(callback).Run( + "", mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + return; + } + + auto internal_callback = + base::BindOnce(&NftMetadataFetcher::OnGetSolanaAccountInfoTokenMetadata, + weak_ptr_factory_.GetWeakPtr(), std::move(callback)); + json_rpc_service_->GetSolanaAccountInfo(*associated_metadata_account, + std::move(internal_callback)); +} + +void NftMetadataFetcher::OnGetSolanaAccountInfoTokenMetadata( + GetSolTokenMetadataCallback callback, + absl::optional account_info, + mojom::SolanaProviderError error, + const std::string& error_message) { + if (error != mojom::SolanaProviderError::kSuccess || !account_info) { + std::move(callback).Run("", error, error_message); + return; + } + + absl::optional> metadata = + base::Base64Decode(account_info->data); + + if (!metadata) { + std::move(callback).Run("", mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + return; + } + + absl::optional url = DecodeMetadataUri(*metadata); + if (!url || !url.value().is_valid()) { + std::move(callback).Run("", mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + return; + } + + FetchMetadata(*url, base::BindOnce( + &NftMetadataFetcher::CompleteGetSolTokenMetadata, + weak_ptr_factory_.GetWeakPtr(), std::move(callback))); +} + +void NftMetadataFetcher::CompleteGetSolTokenMetadata( + GetSolTokenMetadataCallback callback, + const std::string& response, + int error, + const std::string& error_message) { + mojom::SolanaProviderError mojo_err = + static_cast(error); + if (!mojom::IsKnownEnumValue(mojo_err)) + mojo_err = mojom::SolanaProviderError::kUnknown; + std::move(callback).Run(response, mojo_err, error_message); +} + +// static +// Expects a the bytes of a Borsh encoded Metadata struct (see +// https://docs.rs/spl-token-metadata/latest/spl_token_metadata/state/struct.Metadata.html) +// and returns the URI string in of the nested Data struct (see +// https://docs.rs/spl-token-metadata/latest/spl_token_metadata/state/struct.Data.html) +// as a GURL. +absl::optional NftMetadataFetcher::DecodeMetadataUri( + const std::vector& data) { + size_t offset = 0; + offset = offset + /* Skip first byte for metadata.key */ 1 + + /* Skip next 32 bytes for `metadata.update_authority` */ 32 + + /* Skip next 32 bytes for `metadata.mint` */ 32; + + // Skip next field, metdata.data.name, a string + // whose length is represented by a leading 32 bit integer + auto length = DecodeUint32(data, offset); + if (!length) { + return absl::nullopt; + } + offset += static_cast(*length); + + // Skip next field, `metdata.data.symbol`, a string + // whose length is represented by a leading 32 bit integer + length = DecodeUint32(data, offset); + if (!length) { + return absl::nullopt; + } + offset += static_cast(*length); + + // Parse next field, metadata.data.uri, a string + length = DecodeUint32(data, offset); + if (!length) { + return absl::nullopt; + } + + // Prevent out of bounds access in case length value incorrent + if (data.size() <= offset + *length) { + return absl::nullopt; + } + std::string uri = + std::string(data.begin() + offset, data.begin() + offset + *length); + return GURL(uri); +} + +} // namespace brave_wallet diff --git a/components/brave_wallet/browser/nft_metadata_fetcher.h b/components/brave_wallet/browser/nft_metadata_fetcher.h new file mode 100644 index 000000000000..a0aa669469e1 --- /dev/null +++ b/components/brave_wallet/browser/nft_metadata_fetcher.h @@ -0,0 +1,109 @@ +/* Copyright 2022 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 http://mozilla.org/MPL/2.0/. */ + +#ifndef BRAVE_COMPONENTS_BRAVE_WALLET_BROWSER_NFT_METADATA_FETCHER_H_ +#define BRAVE_COMPONENTS_BRAVE_WALLET_BROWSER_NFT_METADATA_FETCHER_H_ + +#include +#include +#include + +#include "base/memory/raw_ptr.h" +#include "base/memory/weak_ptr.h" +#include "brave/components/api_request_helper/api_request_helper.h" +#include "brave/components/brave_wallet/browser/json_rpc_service.h" +#include "services/data_decoder/public/cpp/json_sanitizer.h" + +class PrefService; + +namespace brave_wallet { + +class JsonRpcService; + +class NftMetadataFetcher { + public: + NftMetadataFetcher( + scoped_refptr url_loader_factory, + JsonRpcService* json_rpc_service, + PrefService* prefs); + + NftMetadataFetcher(const NftMetadataFetcher&) = delete; + NftMetadataFetcher& operator=(NftMetadataFetcher&) = delete; + ~NftMetadataFetcher(); + + using APIRequestHelper = api_request_helper::APIRequestHelper; + using APIRequestResult = api_request_helper::APIRequestResult; + using GetEthTokenMetadataCallback = + base::OnceCallback; + void GetEthTokenMetadata(const std::string& contract_address, + const std::string& token_id, + const std::string& chain_id, + const std::string& interface_id, + GetEthTokenMetadataCallback callback); + using GetSolTokenMetadataCallback = + base::OnceCallback; + void GetSolTokenMetadata(const std::string& token_mint_address, + GetSolTokenMetadataCallback callback); + + private: + void OnGetSupportsInterface(const std::string& contract_address, + const std::string& interface_id, + const std::string& token_id, + const std::string& chain_id, + // const GURL& network_url, + GetEthTokenMetadataCallback callback, + bool is_supported, + mojom::ProviderError error, + const std::string& error_message); + + void OnGetEthTokenUri(GetEthTokenMetadataCallback callback, + const GURL& uri, + mojom::ProviderError error, + const std::string& error_message); + // GetTokenMetadataIntermediateCallbacks convert the int error to a + // mojom::ProviderError or mojom::SolanaProviderError + using GetTokenMetadataIntermediateCallback = + base::OnceCallback; + void FetchMetadata(GURL url, GetTokenMetadataIntermediateCallback callback); + void OnSanitizeTokenMetadata(GetTokenMetadataIntermediateCallback callback, + data_decoder::JsonSanitizer::Result result); + void OnGetTokenMetadataPayload(GetTokenMetadataIntermediateCallback callback, + APIRequestResult api_request_result); + void OnGetSolanaAccountInfoTokenMetadata( + GetSolTokenMetadataCallback callback, + absl::optional account_info, + mojom::SolanaProviderError error, + const std::string& error_message); + void CompleteGetEthTokenMetadata(GetEthTokenMetadataCallback callback, + const std::string& response, + int error, + const std::string& error_message); + void CompleteGetSolTokenMetadata(GetSolTokenMetadataCallback callback, + const std::string& response, + int error, + const std::string& error_message); + + friend class NftMetadataFetcherUnitTest; + FRIEND_TEST_ALL_PREFIXES(NftMetadataFetcherUnitTest, DecodeMetadataUri); + + static absl::optional DecodeMetadataUri( + const std::vector& data); + + scoped_refptr url_loader_factory_; + std::unique_ptr api_request_helper_; + raw_ptr json_rpc_service_ = nullptr; + raw_ptr prefs_ = nullptr; + base::WeakPtrFactory weak_ptr_factory_; +}; + +} // namespace brave_wallet + +#endif // BRAVE_COMPONENTS_BRAVE_WALLET_BROWSER_NFT_METADATA_FETCHER_H_ diff --git a/components/brave_wallet/browser/nft_metadata_fetcher_unittest.cc b/components/brave_wallet/browser/nft_metadata_fetcher_unittest.cc new file mode 100644 index 000000000000..1ff8e8db1b7f --- /dev/null +++ b/components/brave_wallet/browser/nft_metadata_fetcher_unittest.cc @@ -0,0 +1,776 @@ +/* Copyright (c) 2022 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 http://mozilla.org/MPL/2.0/. */ + +#include "brave/components/brave_wallet/browser/nft_metadata_fetcher.h" + +#include + +#include "base/base64.h" +#include "base/test/bind.h" +#include "base/test/task_environment.h" +#include "brave/components/brave_wallet/browser/brave_wallet_prefs.h" +#include "brave/components/brave_wallet/browser/brave_wallet_utils.h" +#include "brave/components/brave_wallet/browser/json_rpc_service.h" +#include "brave/components/brave_wallet/common/hash_utils.h" +#include "brave/components/ipfs/ipfs_service.h" +#include "components/sync_preferences/testing_pref_service_syncable.h" +#include "services/data_decoder/public/cpp/test_support/in_process_data_decoder.h" +#include "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h" +#include "services/network/test/test_url_loader_factory.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "ui/base/l10n/l10n_util.h" + +namespace brave_wallet { + +namespace { + +constexpr char https_metadata_response[] = + R"({"attributes":[{"trait_type":"Feet","value":"Green Shoes"},{"trait_type":"Legs","value":"Tan Pants"},{"trait_type":"Suspenders","value":"White Suspenders"},{"trait_type":"Upper Body","value":"Indigo Turtleneck"},{"trait_type":"Sleeves","value":"Long Sleeves"},{"trait_type":"Hat","value":"Yellow / Blue Pointy Beanie"},{"trait_type":"Eyes","value":"White Nerd Glasses"},{"trait_type":"Mouth","value":"Toothpick"},{"trait_type":"Ears","value":"Bing Bong Stick"},{"trait_type":"Right Arm","value":"Swinging"},{"trait_type":"Left Arm","value":"Diamond Hand"},{"trait_type":"Background","value":"Blue"}],"description":"5,000 animated Invisible Friends hiding in the metaverse. A collection by Markus Magnusson & Random Character Collective.","image":"https://rcc.mypinata.cloud/ipfs/QmXmuSenZRnofhGMz2NyT3Yc4Zrty1TypuiBKDcaBsNw9V/1817.gif","name":"Invisible Friends #1817"})"; + +} // namespace + +class NftMetadataFetcherUnitTest : public testing::Test { + public: + NftMetadataFetcherUnitTest() + : shared_url_loader_factory_( + base::MakeRefCounted( + &url_loader_factory_)) {} + void SetUp() override { + brave_wallet::RegisterProfilePrefs(prefs_.registry()); + ipfs::IpfsService::RegisterProfilePrefs(prefs_.registry()); + json_rpc_service_ = std::make_unique( + shared_url_loader_factory_, &prefs_); + nft_metadata_fetcher_ = std::make_unique( + shared_url_loader_factory_, json_rpc_service_.get(), GetPrefs()); + } + + PrefService* GetPrefs() { return &prefs_; } + + GURL GetNetwork(const std::string& chain_id, mojom::CoinType coin) { + return brave_wallet::GetNetworkURL(GetPrefs(), chain_id, coin); + } + + void TestFetchMetadata(const GURL& url, + const std::string& expected_response, + int expected_error, + const std::string& expected_error_message) { + base::RunLoop run_loop; + nft_metadata_fetcher_->FetchMetadata( + url, + base::BindLambdaForTesting([&](const std::string& response, int error, + const std::string& error_message) { + EXPECT_EQ(response, expected_response); + EXPECT_EQ(error, expected_error); + EXPECT_EQ(error_message, expected_error_message); + run_loop.Quit(); + })); + run_loop.Run(); + } + + void TestGetEthTokenMetadata(const std::string& contract, + const std::string& token_id, + const std::string& chain_id, + const std::string& interface_id, + const std::string& expected_response, + mojom::ProviderError expected_error, + const std::string& expected_error_message) { + base::RunLoop run_loop; + nft_metadata_fetcher_->GetEthTokenMetadata( + contract, token_id, chain_id, interface_id, + base::BindLambdaForTesting([&](const std::string& response, + mojom::ProviderError error, + const std::string& error_message) { + EXPECT_EQ(response, expected_response); + EXPECT_EQ(error, expected_error); + EXPECT_EQ(error_message, expected_error_message); + run_loop.Quit(); + })); + run_loop.Run(); + } + + void TestGetSolTokenMetadata(const std::string& token_mint_address, + const std::string& expected_response, + mojom::SolanaProviderError expected_error, + const std::string& expected_error_message) { + base::RunLoop loop; + nft_metadata_fetcher_->GetSolTokenMetadata( + token_mint_address, + base::BindLambdaForTesting([&](const std::string& response, + mojom::SolanaProviderError error, + const std::string& error_message) { + EXPECT_EQ(response, expected_response); + EXPECT_EQ(error, expected_error); + EXPECT_EQ(error_message, expected_error_message); + loop.Quit(); + })); + loop.Run(); + } + + void SetInterceptor(const GURL& expected_url, const std::string& content) { + url_loader_factory_.SetInterceptor(base::BindLambdaForTesting( + [&, expected_url, content](const network::ResourceRequest& request) { + EXPECT_EQ(request.url, expected_url); + url_loader_factory_.ClearResponses(); + url_loader_factory_.AddResponse(request.url.spec(), content); + })); + } + + void SetInvalidJsonInterceptor() { + url_loader_factory_.SetInterceptor(base::BindLambdaForTesting( + [&](const network::ResourceRequest& request) { + url_loader_factory_.ClearResponses(); + url_loader_factory_.AddResponse(request.url.spec(), "Answer is 42"); + })); + } + + void SetHTTPRequestTimeoutInterceptor() { + url_loader_factory_.SetInterceptor(base::BindLambdaForTesting( + [&](const network::ResourceRequest& request) { + url_loader_factory_.ClearResponses(); + url_loader_factory_.AddResponse(request.url.spec(), "", + net::HTTP_REQUEST_TIMEOUT); + })); + } + + void SetTokenMetadataInterceptor( + const std::string& interface_id, + const std::string& chain_id, + const std::string& supports_interface_provider_response, + const std::string& token_uri_provider_response = "", + const std::string& metadata_response = "", + net::HttpStatusCode supports_interface_status = net::HTTP_OK, + net::HttpStatusCode token_uri_status = net::HTTP_OK, + net::HttpStatusCode metadata_status = net::HTTP_OK) { + GURL network_url = + GetNetworkURL(GetPrefs(), chain_id, mojom::CoinType::ETH); + ASSERT_TRUE(network_url.is_valid()); + url_loader_factory_.SetInterceptor(base::BindLambdaForTesting( + [&, interface_id, supports_interface_provider_response, + token_uri_provider_response, metadata_response, + supports_interface_status, token_uri_status, metadata_status, + network_url](const network::ResourceRequest& request) { + url_loader_factory_.ClearResponses(); + if (request.method == + "POST") { // An eth_call, either to supportsInterface or tokenURI + base::StringPiece request_string( + request.request_body->elements() + ->at(0) + .As() + .AsStringPiece()); + bool is_supports_interface_req = + request_string.find(GetFunctionHash( + "supportsInterface(bytes4)")) != std::string::npos; + if (is_supports_interface_req) { + ASSERT_NE(request_string.find(interface_id.substr(2)), + std::string::npos); + EXPECT_EQ(request.url.spec(), network_url); + url_loader_factory_.AddResponse( + network_url.spec(), supports_interface_provider_response, + supports_interface_status); + return; + } else { + std::string function_hash; + if (interface_id == kERC721MetadataInterfaceId) { + function_hash = GetFunctionHash("tokenURI(uint256)"); + } else { + function_hash = GetFunctionHash("uri(uint256)"); + } + ASSERT_NE(request_string.find(function_hash), std::string::npos); + url_loader_factory_.AddResponse(network_url.spec(), + token_uri_provider_response, + token_uri_status); + return; + } + } else { // A HTTP GET to fetch the metadata json from the web + url_loader_factory_.AddResponse(request.url.spec(), + metadata_response, metadata_status); + return; + } + })); + } + + void SetSolTokenMetadataInterceptor( + const GURL& expected_rpc_url, + const std::string& get_account_info_response, + const GURL& expected_metadata_url, + const std::string& metadata_response) { + ASSERT_TRUE(expected_rpc_url.is_valid()); + ASSERT_TRUE(expected_metadata_url.is_valid()); + url_loader_factory_.SetInterceptor(base::BindLambdaForTesting( + [&, expected_rpc_url, get_account_info_response, expected_metadata_url, + metadata_response](const network::ResourceRequest& request) { + url_loader_factory_.AddResponse(expected_rpc_url.spec(), + get_account_info_response); + url_loader_factory_.AddResponse(expected_metadata_url.spec(), + metadata_response); + })); + } + + protected: + base::test::TaskEnvironment task_environment_; + sync_preferences::TestingPrefServiceSyncable prefs_; + network::TestURLLoaderFactory url_loader_factory_; + data_decoder::test::InProcessDataDecoder in_process_data_decoder_; + scoped_refptr shared_url_loader_factory_; + std::unique_ptr json_rpc_service_; + // NftMetadataFetcher nft_metadata_fetcher_; + std::unique_ptr nft_metadata_fetcher_; +}; + +TEST_F(NftMetadataFetcherUnitTest, FetchMetadata) { + // Invalid URL yields internal error + TestFetchMetadata(GURL("invalid url"), "", + static_cast(mojom::JsonRpcError::kInternalError), + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Unsupported scheme yields internal error + TestFetchMetadata(GURL("file://host/path"), "", + static_cast(mojom::JsonRpcError::kInternalError), + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Data URL with unsupported mime type yields parsing error + TestFetchMetadata( + GURL("data:text/" + "csv;base64,eyJpbWFnZV91cmwiOiAgImh0dHBzOi8vZXhhbXBsZS5jb20ifQ=="), + "", static_cast(mojom::JsonRpcError::kParsingError), + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // Valid URL but that results in HTTP timeout yields internal error + SetHTTPRequestTimeoutInterceptor(); + TestFetchMetadata(GURL("https://example.com"), "", + static_cast(mojom::JsonRpcError::kInternalError), + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Valid URL but invalid json response yields parsing error + SetInvalidJsonInterceptor(); + TestFetchMetadata(GURL("https://example.com"), "", + static_cast(mojom::JsonRpcError::kParsingError), + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // All valid yields response json set via interceptor + GURL url = GURL("https://example.com"); + const std::string& metadata_json = + R"({"image_url":"https://example.com/image.jpg"})"; + SetInterceptor(url, metadata_json); + TestFetchMetadata(url, metadata_json, + static_cast(mojom::ProviderError::kSuccess), ""); +} + +TEST_F(NftMetadataFetcherUnitTest, GetEthTokenMetadata) { + const std::string https_token_uri_response = R"({ + "jsonrpc":"2.0", + "id":1, + "result":"0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002468747470733a2f2f696e76697369626c65667269656e64732e696f2f6170692f3138313700000000000000000000000000000000000000000000000000000000" + })"; + const std::string http_token_uri_response = R"({ + "jsonrpc":"2.0", + "id":1, + "result":"0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000020687474703a2f2f696e76697369626c65667269656e64732e696f2f6170692f31" + })"; + const std::string data_token_uri_response = R"({ + "jsonrpc":"2.0", + "id":1, + "result": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000135646174613a6170706c69636174696f6e2f6a736f6e3b6261736536342c65794a686448527961574a316447567a496a6f69496977695a47567a59334a7063485270623234694f694a4f623234675a6e56755a326c696247556762476c7662694973496d6c745957646c496a6f695a474630595470706257466e5a53397a646d6372654731734f324a68633255324e43785153453479576e6c434e474a586548566a656a4270595568534d474e4562335a4d4d32517a5a486b314d3031354e585a6a62574e3254577042643031444f58706b62574e7053556861634670595a454e694d326335535770425a3031445154464e5245466e546c524264306c714e44686a5230597759554e436131425453576c4d656a513454444e4f4d6c70364e4430694c434a755957316c496a6f69546b5a4d496e303d0000000000000000000000" + })"; + const std::string data_token_uri_response_invalid_json = R"({ + "jsonrpc":"2.0", + "id":1, + "result":"0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000085646174613a6170706c69636174696f6e2f6a736f6e3b6261736536342c65794a755957316c496a6f69546b5a4d49697767496d526c63324e796158423061573975496a6f69546d397549475a31626d6470596d786c49477870623234694c43416959585230636d6c696458526c637949364969497349434a706257466e5a5349364969493d000000000000000000000000000000000000000000000000000000" + })"; + const std::string data_token_uri_response_empty_string = R"({ + "jsonrpc":"2.0", + "id":1, + "result":"0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001d646174613a6170706c69636174696f6e2f6a736f6e3b6261736536342c000000" + })"; + const std::string interface_supported_response = R"({ + "jsonrpc":"2.0", + "id":1, + "result": "0x0000000000000000000000000000000000000000000000000000000000000001" + })"; + const std::string exceeds_limit_json = R"({ + "jsonrpc":"2.0", + "id":1, + "error": { + "code":-32005, + "message": "Request exceeds defined limit" + } + })"; + const std::string interface_not_supported_response = R"({ + "jsonrpc":"2.0", + "id":1, + "result":"0x0000000000000000000000000000000000000000000000000000000000000000" + })"; + const std::string invalid_json = + "It might make sense just to get some in case it catches on"; + const std::string ipfs_token_uri_response = R"({ + "jsonrpc":"2.0", + "id":1, + "result":"0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003a697066733a2f2f516d65536a53696e4870506e6d586d73704d6a776958794e367a533445397a63636172694752336a7863615774712f31383137000000000000" + })"; + const std::string ipfs_metadata_response = + R"({"attributes":[{"trait_type":"Mouth","value":"Bored Cigarette"},{"trait_type":"Fur","value":"Gray"},{"trait_type":"Background","value":"Aquamarine"},{"trait_type":"Clothes","value":"Tuxedo Tee"},{"trait_type":"Hat","value":"Bayc Hat Black"},{"trait_type":"Eyes","value":"Coins"}],"image":"ipfs://QmQ82uDT3JyUMsoZuaFBYuEucF654CYE5ktPUrnA5d4VDH"})"; + + // Invalid inputs + // (1/3) Invalid contract address + TestGetEthTokenMetadata( + "", "0x1", mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", + mojom::ProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + + // (2/3) Invalid token ID + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, https_token_uri_response, + https_metadata_response); + TestGetEthTokenMetadata( + "0x06012c8cf97BEaD5deAe237070F9587f8E7A266d", "", mojom::kMainnetChainId, + kERC721MetadataInterfaceId, "", mojom::ProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + + // (3/3) Invalid chain ID + TestGetEthTokenMetadata( + "0x06012c8cf97BEaD5deAe237070F9587f8E7A266d", "0x1", "", + kERC721MetadataInterfaceId, "", mojom::ProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + + // Mismatched + // (4/4) Unknown interfaceID + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, https_token_uri_response, + https_metadata_response); + TestGetEthTokenMetadata( + "0x06012c8cf97BEaD5deAe237070F9587f8E7A266d", "0x1", + mojom::kMainnetChainId, "invalid interface", "", + mojom::ProviderError::kInvalidParams, + l10n_util::GetStringUTF8(IDS_WALLET_INVALID_PARAMETERS)); + + // Valid inputs + // (1/3) HTTP URI + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, https_token_uri_response, + https_metadata_response); + TestGetEthTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + https_metadata_response, + mojom::ProviderError::kSuccess, ""); + + // (2/3) IPFS URI + SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, + mojom::kLocalhostChainId, + interface_supported_response, + ipfs_token_uri_response, ipfs_metadata_response); + TestGetEthTokenMetadata("0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", + mojom::kLocalhostChainId, kERC721MetadataInterfaceId, + ipfs_metadata_response, + mojom::ProviderError::kSuccess, ""); + + // (3/3) Data URI + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, data_token_uri_response); + TestGetEthTokenMetadata( + "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + R"({"attributes":"","description":"Non fungible lion","image":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MDAgNTAwIj48cGF0aCBkPSIiLz48L3N2Zz4=","name":"NFL"})", + mojom::ProviderError::kSuccess, ""); + + // Invalid supportsInterface response + // (1/4) Timeout + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, https_token_uri_response, "", + net::HTTP_REQUEST_TIMEOUT); + TestGetEthTokenMetadata("0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // (2/4) Invalid JSON + SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, + mojom::kMainnetChainId, invalid_json); + TestGetEthTokenMetadata("0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // (3/4) Request exceeds provider limit + SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, + mojom::kMainnetChainId, exceeds_limit_json); + TestGetEthTokenMetadata("0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kLimitExceeded, + "Request exceeds defined limit"); + + // (4/4) Interface not supported + SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, + mojom::kMainnetChainId, + interface_not_supported_response); + TestGetEthTokenMetadata( + "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, "", + mojom::ProviderError::kMethodNotSupported, + l10n_util::GetStringUTF8(IDS_WALLET_METHOD_NOT_SUPPORTED_ERROR)); + + // Invalid tokenURI response (6 total) + // (1/6) Timeout + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, https_token_uri_response, "", net::HTTP_OK, + net::HTTP_REQUEST_TIMEOUT); + TestGetEthTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // (2/6) Invalid Provider JSON + SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, + mojom::kMainnetChainId, + interface_supported_response, invalid_json); + TestGetEthTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // (3/6) Invalid JSON in data URI + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, data_token_uri_response_invalid_json); + TestGetEthTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // (4/6) Empty string as JSON in data URI + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, data_token_uri_response_empty_string); + TestGetEthTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // (5/6) Request exceeds limit + SetTokenMetadataInterceptor(kERC721MetadataInterfaceId, + mojom::kMainnetChainId, + interface_supported_response, exceeds_limit_json); + TestGetEthTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kLimitExceeded, + "Request exceeds defined limit"); + + // (6/6) URI scheme is not suported (HTTP) + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, http_token_uri_response); + TestGetEthTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Invalid metadata response (2 total) + // (1/2) Timeout + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, https_token_uri_response, + https_metadata_response, net::HTTP_OK, net::HTTP_OK, + net::HTTP_REQUEST_TIMEOUT); + TestGetEthTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // (2/2) Invalid JSON + SetTokenMetadataInterceptor( + kERC721MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, ipfs_token_uri_response, invalid_json); + TestGetEthTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC721MetadataInterfaceId, + "", mojom::ProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // ERC1155 + SetTokenMetadataInterceptor( + kERC1155MetadataInterfaceId, mojom::kMainnetChainId, + interface_supported_response, https_token_uri_response, + https_metadata_response); + TestGetEthTokenMetadata("0x59468516a8259058bad1ca5f8f4bff190d30e066", "0x719", + mojom::kMainnetChainId, kERC1155MetadataInterfaceId, + https_metadata_response, + mojom::ProviderError::kSuccess, ""); +} + +TEST_F(NftMetadataFetcherUnitTest, GetSolTokenMetadata) { + // Valid inputs should yield metadata JSON (happy case) + std::string get_account_info_response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.13.3", + "slot": 161038284 + }, + "value": { + "data": [ + "BGUN5hJf2zSue3S0I/fCq16UREt5NxP6mQdaq4cdGPs3Q8PG/R6KFUSgce78Nwk9Frvkd9bMbvTIKCRSDy88nZQgAAAAU1BFQ0lBTCBTQVVDRQAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAMgAAABodHRwczovL2JhZmtyZWlmNHd4NTR3anI3cGdmdWczd2xhdHIzbmZudHNmd25ndjZldXNlYmJxdWV6cnhlbmo2Y2s0LmlwZnMuZHdlYi5saW5rP2V4dD0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOgDAQIAAABlDeYSX9s0rnt0tCP3wqtelERLeTcT+pkHWquHHRj7NwFiDUmu+U8sXOOZQXL36xmknL+Zzd/z3uw2G0ERMo8Eth4BAgABAf8BAAEBoivvbAzLh2kD2cSu6IQIqGQDGeoh/UEDizyp6mLT1tUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "base64" + ], + "executable": false, + "lamports": 5616720, + "owner": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", + "rentEpoch": 361 + } + }, + "id": 1 + })"; + const std::string valid_metadata_response = + R"({"attributes":[{"trait_type":"hair","value":"green & blue"},{"trait_type":"pontus","value":"no"}],"description":"","external_url":"","image":"https://bafkreiagsgqhjudpta6trhjuv5y2n2exsrhbkkprl64tvg2mftjsdm3vgi.ipfs.dweb.link?ext=png","name":"SPECIAL SAUCE","properties":{"category":"image","creators":[{"address":"7oUUEdptZnZVhSet4qobU9PtpPfiNUEJ8ftPnrC6YEaa","share":98},{"address":"tsU33UT3K2JTfLgHUo7hdzRhRe4wth885cqVbM8WLiq","share":2}],"files":[{"type":"image/png","uri":"https://bafkreiagsgqhjudpta6trhjuv5y2n2exsrhbkkprl64tvg2mftjsdm3vgi.ipfs.dweb.link?ext=png"}],"maxSupply":0},"seller_fee_basis_points":1000,"symbol":""})"; + auto network_url = GetNetwork(mojom::kSolanaMainnet, mojom::CoinType::SOL); + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", + valid_metadata_response, + mojom::SolanaProviderError::kSuccess, ""); + + // Invalid token_mint_address yields internal error. + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("Invalid", "", + mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Non 200 getAccountInfo response of yields internal server error. + SetHTTPRequestTimeoutInterceptor(); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Invalid getAccountInfo response JSON yields internal error + SetSolTokenMetadataInterceptor( + network_url, "Invalid json response", + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kInternalError, + l10n_util::GetStringUTF8(IDS_WALLET_INTERNAL_ERROR)); + + // Valid response JSON, invalid account info (missing result.value.owner + // field) info yields parse error + get_account_info_response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.13.3", + "slot": 161038284 + }, + "value": { + "data": [ + "BGUN5hJf2zSue3S0I/fCq16UREt5NxP6mQdaq4cdGPs3Q8PG/R6KFUSgce78Nwk9Frvkd9bMbvTIKCRSDy88nZQgAAAAU1BFQ0lBTCBTQVVDRQAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAMgAAABodHRwczovL2JhZmtyZWlmNHd4NTR3anI3cGdmdWczd2xhdHIzbmZudHNmd25ndjZldXNlYmJxdWV6cnhlbmo2Y2s0LmlwZnMuZHdlYi5saW5rP2V4dD0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOgDAQIAAABlDeYSX9s0rnt0tCP3wqtelERLeTcT+pkHWquHHRj7NwFiDUmu+U8sXOOZQXL36xmknL+Zzd/z3uw2G0ERMo8Eth4BAgABAf8BAAEBoivvbAzLh2kD2cSu6IQIqGQDGeoh/UEDizyp6mLT1tUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "base64" + ], + "executable": false, + "lamports": 5616720, + "rentEpoch": 361 + } + }, + "id": 1 + })"; + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // Valid response JSON, parsable account info, but invalid account info data + // (invalid base64) yields parse error + get_account_info_response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.13.3", + "slot": 161038284 + }, + "value": { + "data": [ + "*Invalid Base64*", + "base64" + ], + "executable": false, + "lamports": 5616720, + "owner": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", + "rentEpoch": 361 + } + }, + "id": 1 + })"; + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // Valid response JSON, parsable account info, invalid account info data + // (valid base64, but invalid borsh encoded metadata) yields parse error + get_account_info_response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.13.3", + "slot": 161038284 + }, + "value": { + "data": [ + "d2hvb3BzIQ==", + "base64" + ], + "executable": false, + "lamports": 5616720, + "owner": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", + "rentEpoch": 361 + } + }, + "id": 1 + })"; + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); + + // Valid response JSON, parsable account info, invalid account info data + // (valid base64, valid borsh encoding, but when decoded the URI is not a + // valid URI) + get_account_info_response = R"({ + "jsonrpc": "2.0", + "result": { + "context": { + "apiVersion": "1.13.3", + "slot": 161038284 + }, + "value": { + "data": [ + "BGUN5hJf2zSue3S0I/fCq16UREt5NxP6mQdaq4cdGPs3Q8PG/R6KFUSgce78Nwk9Frvkd9bMbvTIKCRSDy88nZQgAAAAU1BFQ0lBTCBTQVVDRQAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAAsAAABpbnZhbGlkIHVybOgDAQIAAABlDeYSX9s0rnt0tCP3wqtelERLeTcT+pkHWquHHRj7NwFiDUmu+U8sXOOZQXL36xmknL+Zzd/z3uw2G0ERMo8Eth4BAgABAf8BAAEBoivvbAzLh2kD2cSu6IQIqGQDGeoh/UEDizyp6mLT1tUA", + "base64" + ], + "executable": false, + "lamports": 5616720, + "owner": "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", + "rentEpoch": 361 + } + }, + "id": 1 + })"; + SetSolTokenMetadataInterceptor( + network_url, get_account_info_response, + GURL("https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="), + valid_metadata_response); + TestGetSolTokenMetadata("5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh", "", + mojom::SolanaProviderError::kParsingError, + l10n_util::GetStringUTF8(IDS_WALLET_PARSING_ERROR)); +} + +TEST_F(NftMetadataFetcherUnitTest, DecodeMetadataUri) { + // Valid borsh encoding and URI yields expected URI + std::vector uri_borsh_encoded = { + 4, 101, 13, 230, 18, 95, 219, 52, 174, 123, 116, 180, 35, 247, 194, 171, + 94, 148, 68, 75, 121, 55, 19, 250, 153, 7, 90, 171, 135, 29, 24, 251, 55, + 67, 195, 198, 253, 30, 138, 21, 68, 160, 113, 238, 252, 55, 9, 61, 22, + 187, 228, 119, 214, 204, 110, 244, 200, 40, 36, 82, 15, 47, 60, 157, 148, + 32, 0, 0, 0, 83, 80, 69, 67, 73, 65, 76, 32, 83, 65, 85, 67, 69, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, // the next four bytes encode length of the URI string + // (200) + 200, 0, 0, 0, 104, 116, 116, 112, 115, 58, 47, 47, 98, 97, 102, 107, 114, + 101, 105, 102, 52, 119, 120, 53, 52, 119, 106, 114, 55, 112, 103, 102, + 117, 103, 51, 119, 108, 97, 116, 114, 51, 110, 102, 110, 116, 115, 102, + 119, 110, 103, 118, 54, 101, 117, 115, 101, 98, 98, 113, 117, 101, 122, + 114, 120, 101, 110, 106, 54, 99, 107, 52, 46, 105, 112, 102, 115, 46, 100, + 119, 101, 98, 46, 108, 105, 110, 107, 63, 101, 120, 116, 61, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 232, 3, 0, 0, 1, 1, 255, 1, 0, 1, 1, 162, 43, + 239, 108, 12, 203, 135, 105, 3, 217, 196, 174, 232, 132, 8, 168, 100, 3, + 25, 234, 33, 253, 65, 3, 139, 60, 169, 234, 98, 211, 214, 213, 0}; + auto uri = nft_metadata_fetcher_->DecodeMetadataUri(uri_borsh_encoded); + ASSERT_TRUE(uri); + EXPECT_EQ(uri.value().spec(), + "https://" + "bafkreif4wx54wjr7pgfug3wlatr3nfntsfwngv6eusebbquezrxenj6ck4.ipfs." + "dweb.link/?ext="); + + // Invalid borsh encoding due to incorrect claimed length of metadata URI + // string (too large) fails to decode (out of bounds check) + uri_borsh_encoded = { + 4, 101, 13, 230, 18, 95, 219, 52, 174, 123, 116, 180, 35, 247, 194, 171, + 94, 148, 68, 75, 121, 55, 19, 250, 153, 7, 90, 171, 135, 29, 24, 251, 55, + 67, 195, 198, 253, 30, 138, 21, 68, 160, 113, 238, 252, 55, 9, 61, 22, + 187, 228, 119, 214, 204, 110, 244, 200, 40, 36, 82, 15, 47, 60, 157, 148, + 32, 0, 0, 0, 83, 80, 69, 67, 73, 65, 76, 32, 83, 65, 85, 67, 69, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, + // next four bytes encode the URI of the string, which have been overrided + // to be incorrect (too large) + 255, 255, 255, 0, 104, 116, 116, 112, 115, 58, 47, 47, 98, 97, 102, 107, + 114, 101, 105, 102, 52, 119, 120, 53, 52, 119, 106, 114, 55, 112, 103, + 102, 117, 103, 51, 119, 108, 97, 116, 114, 51, 110, 102, 110, 116, 115, + 102, 119, 110, 103, 118, 54, 101, 117, 115, 101, 98, 98, 113, 117, 101, + 122, 114, 120, 101, 110, 106, 54, 99, 107, 52, 46, 105, 112, 102, 115, 46, + 100, 119, 101, 98, 46, 108, 105, 110, 107, 63, 101, 120, 116, 61, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 232, 3, 0, 0, 1, 1, 255, 1, 0, 1, 1, 162, + 43, 239, 108, 12, 203, 135, 105, 3, 217, 196, 174, 232, 132, 8, 168, 100, + 3, 25, 234, 33, 253, 65, 3, 139, 60, 169, 234, 98, 211, 214, 213, 0}; + uri = nft_metadata_fetcher_->DecodeMetadataUri(uri_borsh_encoded); + ASSERT_FALSE(uri); + + // Valid borsh encoding, but invalid URI is parsed but yields empty URI + auto uri_borsh_encoded2 = base::Base64Decode( + "BGUN5hJf2zSue3S0I/fCq16UREt5NxP6mQdaq4cdGPs3Q8PG/" + "R6KFUSgce78Nwk9Frvkd9bMbvTIKCRSDy88nZQgAAAAU1BFQ0lBTCBTQVVDRQAAAAAAAAAAA" + "AAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAAsAAABpbnZhbGlkIHVybOgDAQIAAABlDeYSX9s0r" + "nt0tCP3wqtelERLeTcT+pkHWquHHRj7NwFiDUmu+U8sXOOZQXL36xmknL+Zzd/" + "z3uw2G0ERMo8Eth4BAgABAf8BAAEBoivvbAzLh2kD2cSu6IQIqGQDGeoh/" + "UEDizyp6mLT1tUA"); + ASSERT_TRUE(uri_borsh_encoded2); + uri = nft_metadata_fetcher_->DecodeMetadataUri(*uri_borsh_encoded2); + ASSERT_TRUE(uri); + EXPECT_EQ(uri.value().spec(), ""); + + // Invalid borsh encoding is not parsed + uri_borsh_encoded2 = base::Base64Decode("d2hvb3BzIQ=="); + ASSERT_TRUE(uri_borsh_encoded2); + ASSERT_FALSE(nft_metadata_fetcher_->DecodeMetadataUri(*uri_borsh_encoded2)); +} + +} // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_keyring.cc b/components/brave_wallet/browser/solana_keyring.cc index 07d241c296de..63fc14076401 100644 --- a/components/brave_wallet/browser/solana_keyring.cc +++ b/components/brave_wallet/browser/solana_keyring.cc @@ -165,4 +165,31 @@ absl::optional SolanaKeyring::GetAssociatedTokenAccount( mojom::kSolanaAssociatedTokenProgramId); } +// static +// Derive metadata account using metadata seed constant, token metadata program +// id, and the mint address as the seeds. +// https://docs.metaplex.com/programs/token-metadata/accounts#metadata +absl::optional SolanaKeyring::GetAssociatedMetadataAccount( + const std::string& token_mint_address) { + std::vector> seeds; + const std::string metadata_seed_constant = "metadata"; + std::vector metaplex_seed_constant_bytes( + metadata_seed_constant.begin(), metadata_seed_constant.end()); + std::vector metadata_program_id_bytes; + std::vector token_mint_address_bytes; + + if (!Base58Decode(mojom::kSolanaMetadataProgramId, &metadata_program_id_bytes, + kSolanaPubkeySize) || + !Base58Decode(token_mint_address, &token_mint_address_bytes, + kSolanaPubkeySize)) { + return absl::nullopt; + } + + seeds.push_back(std::move(metaplex_seed_constant_bytes)); + seeds.push_back(std::move(metadata_program_id_bytes)); + seeds.push_back(std::move(token_mint_address_bytes)); + + return FindProgramDerivedAddress(seeds, mojom::kSolanaMetadataProgramId); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/solana_keyring.h b/components/brave_wallet/browser/solana_keyring.h index 22de3bda81a5..49e80bbbf9a2 100644 --- a/components/brave_wallet/browser/solana_keyring.h +++ b/components/brave_wallet/browser/solana_keyring.h @@ -39,6 +39,9 @@ class SolanaKeyring : public HDKeyring { const std::string& spl_token_mint_address, const std::string& wallet_address); + static absl::optional GetAssociatedMetadataAccount( + const std::string& token_mint_address); + private: std::string GetAddressInternal(HDKeyBase* hd_key) const override; }; diff --git a/components/brave_wallet/browser/solana_keyring_unittest.cc b/components/brave_wallet/browser/solana_keyring_unittest.cc index b1f1437cb2d2..56f9a9d109ea 100644 --- a/components/brave_wallet/browser/solana_keyring_unittest.cc +++ b/components/brave_wallet/browser/solana_keyring_unittest.cc @@ -237,4 +237,17 @@ TEST(SolanaKeyringUnitTest, GetAssociatedTokenAccount) { EXPECT_EQ(*addr, "3bHK4cYoW94angdFWJeDBQcAuSq3mtYEdVaqkm1xXKcy"); } +TEST(SolanaKeyringUnitTest, GetAssociatedMetadataAccount) { + auto addr = SolanaKeyring::GetAssociatedMetadataAccount( + "5ZXToo7froykjvjnpHtTLYr9u2tW3USMwPg3sNkiaQVh"); + ASSERT_TRUE(addr); + EXPECT_EQ(*addr, "6L255rMB19d544HLNumpvbdTKkTgiQ3fgMszzX6F9VAL"); + + addr = SolanaKeyring::GetAssociatedMetadataAccount( + "8q5qbP8xu1TgDWYXokwFjgTqoSNe6W3Ljj3phwqhDKqe"); + ASSERT_TRUE(addr); + + EXPECT_EQ(*addr, "586XgHr69ZhbUkkGJsQqGt16mf7jpFS6uhnvCAwb68Qq"); +} + } // namespace brave_wallet diff --git a/components/brave_wallet/browser/test/BUILD.gn b/components/brave_wallet/browser/test/BUILD.gn index 8619002c08bc..28aa45b91de4 100644 --- a/components/brave_wallet/browser/test/BUILD.gn +++ b/components/brave_wallet/browser/test/BUILD.gn @@ -45,6 +45,7 @@ source_set("brave_wallet_unit_tests") { "//brave/components/brave_wallet/browser/json_rpc_response_parser_unittest.cc", "//brave/components/brave_wallet/browser/json_rpc_service_test_utils_unittest.cc", "//brave/components/brave_wallet/browser/json_rpc_service_unittest.cc", + "//brave/components/brave_wallet/browser/nft_metadata_fetcher_unittest.cc", "//brave/components/brave_wallet/browser/password_encryptor_unittest.cc", "//brave/components/brave_wallet/browser/permission_utils_unittest.cc", "//brave/components/brave_wallet/browser/rlp_decode_unittest.cc", diff --git a/components/brave_wallet/common/brave_wallet.mojom b/components/brave_wallet/common/brave_wallet.mojom index 9b523566198d..ca242982bff2 100644 --- a/components/brave_wallet/common/brave_wallet.mojom +++ b/components/brave_wallet/common/brave_wallet.mojom @@ -798,6 +798,7 @@ const string kSolanaSystemProgramId = "11111111111111111111111111111111"; const string kSolanaTokenProgramId = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; const string kSolanaAssociatedTokenProgramId = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; const string kSolanaSysvarRentProgramId = "SysvarRent111111111111111111111111111111111"; +const string kSolanaMetadataProgramId = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"; // It is required to keep these chain IDs lowercase ASCII const string kMainnetChainId = "0x1"; @@ -973,6 +974,8 @@ interface JsonRpcService { GetSPLTokenAccountBalance(string wallet_address, string token_mint_address, string chain_id) => (string amount, uint8 decimals, string uiAmountString, SolanaProviderError error, string error_message); + // Returns the metadata json associated with the NFT account address + GetSolTokenMetadata(string token_mint_address) => (string response, SolanaProviderError error, string error_message); }; enum TransactionStatus { diff --git a/components/brave_wallet_ui/common/async/handlers.ts b/components/brave_wallet_ui/common/async/handlers.ts index e45dec506774..c73e5f9ea8d9 100644 --- a/components/brave_wallet_ui/common/async/handlers.ts +++ b/components/brave_wallet_ui/common/async/handlers.ts @@ -54,7 +54,8 @@ import { sendEthTransaction, sendFilTransaction, sendSolTransaction, - sendSPLTransaction + sendSPLTransaction, + getNFTMetadata } from './lib' import { Store } from './types' import InteractionNotifier from './interactionNotifier' @@ -298,12 +299,12 @@ handler.on(WalletActions.getAllTokensList.type, async (store) => { }) handler.on(WalletActions.addUserAsset.type, async (store: Store, payload: BraveWallet.BlockchainToken) => { - const { braveWalletService, jsonRpcService } = getAPIProxy() - if (payload.isErc721) { - // Get NFTMetadata - const result = await jsonRpcService.getERC721Metadata(payload.contractAddress, payload.tokenId, payload.chainId) - if (!result.error) { - const response = JSON.parse(result.response) + const { braveWalletService } = getAPIProxy() + + if (payload.isErc721 || payload.isNft) { + const result = await getNFTMetadata(payload) + if (!result?.error) { + const response = result?.response && JSON.parse(result.response) payload.logo = response.image || payload.logo } } @@ -311,7 +312,7 @@ handler.on(WalletActions.addUserAsset.type, async (store: Store, payload: BraveW const result = await braveWalletService.addUserAsset(payload) // Refresh balances here for adding ERC721 tokens if result is successful - if (payload.isErc721 && result.success) { + if ((payload.isErc721 || payload.isNft) && result.success) { refreshBalancesPricesAndHistory(store) } store.dispatch(WalletActions.addUserAssetError(!result.success)) diff --git a/components/brave_wallet_ui/common/async/lib.ts b/components/brave_wallet_ui/common/async/lib.ts index c65553b9cad5..8feee3db4dcc 100644 --- a/components/brave_wallet_ui/common/async/lib.ts +++ b/components/brave_wallet_ui/common/async/lib.ts @@ -1032,3 +1032,14 @@ export function getEthTxManagerProxy () { const { ethTxManagerProxy } = getAPIProxy() return ethTxManagerProxy } + +export async function getNFTMetadata (token: BraveWallet.BlockchainToken) { + const { jsonRpcService } = getAPIProxy() + if (token.coin === BraveWallet.CoinType.ETH) { + return await jsonRpcService.getERC721Metadata(token.contractAddress, token.tokenId, token.chainId) + } else if (token.coin === BraveWallet.CoinType.SOL) { + return await jsonRpcService.getSolTokenMetadata(token.contractAddress) + } + + return undefined +} diff --git a/components/brave_wallet_ui/common/hooks/assets-management.ts b/components/brave_wallet_ui/common/hooks/assets-management.ts index 9b3701a2f993..9a97a4735cee 100644 --- a/components/brave_wallet_ui/common/hooks/assets-management.ts +++ b/components/brave_wallet_ui/common/hooks/assets-management.ts @@ -38,7 +38,7 @@ export default function useAssetManagement () { onAddUserAsset(token) // We handle refreshing balances for ERC721 tokens in the addUserAsset handler. - if (!token.isErc721) { + if (!(token.isErc721 || token.isNft)) { dispatch(WalletActions.refreshBalancesAndPriceHistory()) } } diff --git a/components/brave_wallet_ui/components/desktop/portfolio-asset-item/index.tsx b/components/brave_wallet_ui/components/desktop/portfolio-asset-item/index.tsx index 48518e62a4a4..1d26f3122fe5 100644 --- a/components/brave_wallet_ui/components/desktop/portfolio-asset-item/index.tsx +++ b/components/brave_wallet_ui/components/desktop/portfolio-asset-item/index.tsx @@ -62,11 +62,13 @@ export const PortfolioAssetItem = ({ const [assetNetworkSkeletonWidth, setAssetNetworkSkeletonWidth] = React.useState(0) // memos & computed + const isNonFungibleToken = React.useMemo(() => token.isNft || token.isErc721, [token.isNft, token.isErc721]) + const AssetIconWithPlaceholder = React.useMemo(() => { - return withPlaceholderIcon(token.isErc721 && !isDataURL(token.logo) ? NftIcon : AssetIcon, { size: 'big', marginLeft: 0, marginRight: 8 }) - }, [token.isErc721]) + return withPlaceholderIcon(isNonFungibleToken && !isDataURL(token.logo) ? NftIcon : AssetIcon, { size: 'big', marginLeft: 0, marginRight: 8 }) + }, [isNonFungibleToken, token.logo]) - const formattedAssetBalance = token.isErc721 + const formattedAssetBalance = isNonFungibleToken ? new Amount(assetBalance) .divideByDecimals(token.decimals) .format() @@ -83,7 +85,7 @@ export const PortfolioAssetItem = ({ }, [fiatBalance, defaultCurrencies.fiat]) const isLoading = React.useMemo(() => { - return formattedAssetBalance === '' && !token.isErc721 + return formattedAssetBalance === '' && !isNonFungibleToken }, [formattedAssetBalance, token]) const tokensNetwork = React.useMemo(() => { @@ -116,8 +118,6 @@ export const PortfolioAssetItem = ({ return ( <> {token.visible && - // Selecting an erc721 token is temp disabled until UI is ready for viewing NFTs - // or when showing loading skeleton @@ -163,7 +163,7 @@ export const PortfolioAssetItem = ({ hideBalances={hideBalances ?? false} > - {!(token.isErc721 || token.isNft) && + {!isNonFungibleToken && <> {formattedFiatBalance ? ( {formattedFiatBalance} diff --git a/components/brave_wallet_ui/components/desktop/views/portfolio/portfolio-asset.tsx b/components/brave_wallet_ui/components/desktop/views/portfolio/portfolio-asset.tsx index 7605a36ad7b3..93acb826df54 100644 --- a/components/brave_wallet_ui/components/desktop/views/portfolio/portfolio-asset.tsx +++ b/components/brave_wallet_ui/components/desktop/views/portfolio/portfolio-asset.tsx @@ -258,7 +258,7 @@ export const PortfolioAsset = (props: Props) => { const visibleAssetOptions = userAssetList .filter((token) => token.asset.visible && - !token.asset.isErc721 + !(token.asset.isErc721 || token.asset.isNft) ) if (visibleAssetOptions.length === 0) { @@ -706,7 +706,7 @@ export const PortfolioAsset = (props: Props) => { { }, [visibleTokensForFilteredAccount, selectedAccountFilter, networkList, fullAssetBalance]) const visibleAssetOptions = React.useMemo((): UserAssetInfoType[] => { - return userAssetList.filter(({ asset }) => asset.visible && !asset.isErc721) + return userAssetList.filter(({ asset }) => asset.visible && !(asset.isErc721 || asset.isNft)) }, [userAssetList]) // This will scrape all of the user's accounts and combine the fiat value for every asset @@ -194,13 +194,13 @@ export const PortfolioOverview = () => { history.push(`${WalletRoutes.Portfolio}/${asset.symbol}`) return } else if (asset.isErc721) { - history.push(`${WalletRoutes.Portfolio}/${asset.contractAddress}/${asset.tokenId}`) + history.push(`${WalletRoutes.Portfolio}/${asset.contractAddress}/${asset.tokenId}`) } else { history.push(`${WalletRoutes.Portfolio}/${asset.contractAddress}`) } dispatch(WalletPageActions.selectAsset({ asset, timeFrame: selectedTimeline })) - if (asset.isErc721 && nftMetadata) { + if ((asset.isErc721 || asset.isNft) && nftMetadata) { // reset nft metadata dispatch(WalletPageActions.updateNFTMetadata(undefined)) } diff --git a/components/brave_wallet_ui/components/shared/create-placeholder-icon/index.tsx b/components/brave_wallet_ui/components/shared/create-placeholder-icon/index.tsx index 6f312bc9df3d..1440bb117253 100644 --- a/components/brave_wallet_ui/components/shared/create-placeholder-icon/index.tsx +++ b/components/brave_wallet_ui/components/shared/create-placeholder-icon/index.tsx @@ -56,13 +56,15 @@ function withPlaceholderIcon (WrappedComponent: React.ComponentType, config [network.symbol, asset.symbol] ) + const isNonFungibleToken = React.useMemo(() => asset.isNft || asset.isErc721, [asset.isNft, asset.isErc721]) + const tokenImageURL = stripERC20TokenImageURL(asset.logo) const isRemoteURL = isRemoteImageURL(tokenImageURL) const isStorybook = asset.logo.startsWith('static/media/components/brave_wallet_ui/') const isValidIcon = React.useMemo(() => { if (isRemoteURL || isDataURL(asset.logo)) { - return tokenImageURL?.includes('data:image/') || isIpfs(tokenImageURL) ? true : isValidIconExtension(new URL(asset.logo).pathname) + return tokenImageURL?.includes('data:image/') || isIpfs(tokenImageURL) || isNonFungibleToken ? true : isValidIconExtension(new URL(asset.logo).pathname) } if (isStorybook) { return true diff --git a/components/brave_wallet_ui/nft/components/nft-details/nft-details.tsx b/components/brave_wallet_ui/nft/components/nft-details/nft-details.tsx index 6b7f33862243..704ab33fab02 100644 --- a/components/brave_wallet_ui/nft/components/nft-details/nft-details.tsx +++ b/components/brave_wallet_ui/nft/components/nft-details/nft-details.tsx @@ -88,40 +88,55 @@ export const NftDetails = ({ selectedAsset, nftMetadata, nftMetadataError, token {nftMetadata && <> - + - {nftMetadata.contractInformation.name} { - selectedAsset.tokenId + {nftMetadata.contractInformation.name}{' '} + {selectedAsset.tokenId ? '#' + new Amount(selectedAsset.tokenId).toNumber() - : '' - } + : ''} {/* TODO: Add floorFiatPrice & floorCryptoPrice when data is available from backend: https://github.com/brave/brave-browser/issues/22627 */} {/* {CurrencySymbols[defaultCurrencies.fiat]}{nftMetadata.floorFiatPrice} */} {/* {nftMetadata.floorCryptoPrice} {selectedNetwork.symbol} */} - {getLocale('braveWalletNFTDetailBlockchain')} - {nftMetadata.chainName} + + {getLocale('braveWalletNFTDetailBlockchain')} + + + {nftMetadata.chainName} + - {getLocale('braveWalletNFTDetailTokenStandard')} - {nftMetadata.tokenType} + + {getLocale('braveWalletNFTDetailTokenStandard')} + + + {nftMetadata.tokenType} + - {getLocale('braveWalletNFTDetailTokenID')} + + {getLocale('braveWalletNFTDetailTokenID')} + - { - selectedAsset.tokenId - ? '#' + new Amount(selectedAsset.tokenId).toNumber() - : '' - } + {selectedAsset.tokenId + ? '#' + new Amount(selectedAsset.tokenId).toNumber() + : ''} - - + + @@ -129,24 +144,30 @@ export const NftDetails = ({ selectedAsset, nftMetadata, nftMetadataError, token {/* TODO: Add nft logo when data is available from backend: https://github.com/brave/brave-browser/issues/22627 */} {/* */} - {nftMetadata.contractInformation.name} - {nftMetadata.contractInformation.website && nftMetadata.contractInformation.twitter && nftMetadata.contractInformation.facebook && - - - - - - - - - - - - - - } + + {nftMetadata.contractInformation.name} + + {nftMetadata.contractInformation.website && + nftMetadata.contractInformation.twitter && + nftMetadata.contractInformation.facebook && ( + + + + + + + + + + + + + + )} - {nftMetadata.contractInformation.description} + + {nftMetadata.contractInformation.description} + } diff --git a/components/brave_wallet_ui/page/async/wallet_page_async_handler.ts b/components/brave_wallet_ui/page/async/wallet_page_async_handler.ts index f8a8facf6ab8..b23ff2d5760f 100644 --- a/components/brave_wallet_ui/page/async/wallet_page_async_handler.ts +++ b/components/brave_wallet_ui/page/async/wallet_page_async_handler.ts @@ -29,7 +29,8 @@ import { } from '../constants/action_types' import { findHardwareAccountInfo, - getKeyringIdFromAddress + getKeyringIdFromAddress, + getNFTMetadata } from '../../common/async/lib' import { NewUnapprovedTxAdded } from '../../common/constants/action_types' import { Store } from '../../common/async/types' @@ -141,7 +142,7 @@ handler.on(WalletPageActions.selectAsset.type, async (store: Store, payload: Upd const priceHistory = await assetRatioService.getPriceHistory(getTokenParam(selectedAsset), defaultFiat, payload.timeFrame) store.dispatch(WalletPageActions.updatePriceInfo({ priceHistory: priceHistory, defaultFiatPrice: defaultPrices.values[0], defaultCryptoPrice: defaultPrices.values[1], timeFrame: payload.timeFrame })) - if (payload.asset.isErc721) { + if (payload.asset.isErc721 || payload.asset.isNft) { store.dispatch(WalletPageActions.getNFTMetadata(payload.asset)) } } else { @@ -273,15 +274,17 @@ handler.on(WalletPageActions.openWalletSettings.type, async (store) => { handler.on(WalletPageActions.getNFTMetadata.type, async (store, payload: BraveWallet.BlockchainToken) => { store.dispatch(WalletPageActions.setIsFetchingNFTMetadata(true)) - const jsonRpcService = getWalletPageApiProxy().jsonRpcService - const result = await jsonRpcService.getERC721Metadata(payload.contractAddress, payload.tokenId, payload.chainId) - - if (!result.error) { - const response = JSON.parse(result.response) + const result = await getNFTMetadata(payload) + if (!result?.error) { + const response = result?.response && JSON.parse(result.response) const tokenNetwork = getTokensNetwork(getWalletState(store).networkList, payload) const nftMetadata: NFTMetadataReturnType = { chainName: tokenNetwork.chainName, - tokenType: 'ERC721', // getNFTMetadata currently supports only ERC721 standard. When other standards are supported, this value should be dynamic + tokenType: payload.coin === BraveWallet.CoinType.ETH + ? 'ERC721' + : payload.coin === BraveWallet.CoinType.SOL + ? 'SPL' + : '', tokenID: payload.tokenId, imageURL: response.image.startsWith('data:image/') ? response.image : httpifyIpfsUrl(response.image), imageMimeType: 'image/*',