From fd897a9410da9b5d373d5ccccdb55d810dc58358 Mon Sep 17 00:00:00 2001 From: ryanml Date: Fri, 10 Apr 2020 13:02:16 -0700 Subject: [PATCH] Merge pull request #5160 from brave/binance-version-2 Binance version 2 trading widget --- browser/BUILD.gn | 12 + browser/binance/binance_service_factory.cc | 41 + browser/binance/binance_service_factory.h | 35 + browser/brave_content_browser_client.cc | 13 + browser/brave_profile_prefs.cc | 7 + browser/extensions/BUILD.gn | 2 +- browser/extensions/api/binance_api.cc | 340 ++++- browser/extensions/api/binance_api.h | 128 +- browser/ui/webui/brave_webui_source.cc | 45 +- common/extensions/api/BUILD.gn | 2 +- common/extensions/api/binance.json | 231 +++ common/pref_names.cc | 2 + common/pref_names.h | 2 + common/url_constants.cc | 1 + common/url_constants.h | 1 + components/binance/browser/BUILD.gn | 29 +- .../binance/browser/binance_json_parser.cc | 421 ++++++ .../binance/browser/binance_json_parser.h | 42 + .../browser/binance_json_parser_unittest.cc | 214 +++ .../browser/binance_protocol_handler.cc | 89 ++ .../browser/binance_protocol_handler.h | 25 + components/binance/browser/binance_service.cc | 512 +++++++ components/binance/browser/binance_service.h | 168 +++ .../browser/binance_service_browsertest.cc | 805 +++++++++++ .../binance/browser/buildflags/BUILD.gn | 9 + .../{ => browser/buildflags}/buildflags.gni | 2 +- components/binance/browser/static_values.h | 2 +- .../actions/new_tab_actions.ts | 65 + components/brave_new_tab_ui/binance-utils.ts | 33 + components/brave_new_tab_ui/brave_new_tab.tsx | 2 + .../default/binance/assets/hide-icon.tsx | 16 + .../default/binance/assets/icons.ts | 11 + .../default/binance/assets/party.png | Bin 0 -> 6583 bytes .../components/default/binance/assets/qr.png | Bin 0 -> 9033 bytes .../default/binance/assets/search-icon.png | Bin 0 -> 891 bytes .../default/binance/assets/show-icon.tsx | 16 + .../components/default/binance/data.ts | 161 ++- .../components/default/binance/index.tsx | 1079 +++++++++++++- .../components/default/binance/style.ts | 449 +++++- .../default/widget/assets/disconnect.tsx | 19 + .../default/widget/assets/refresh.tsx | 16 + .../components/default/widget/index.tsx | 17 +- .../components/default/widget/styles.ts | 10 +- .../components/default/widget/widgetMenu.tsx | 48 +- .../constants/new_tab_types.ts | 18 +- .../containers/newTab/index.tsx | 133 +- .../reducers/new_tab_reducer.ts | 196 +++ .../storage/new_tab_storage.ts | 20 +- components/definitions/chromel.d.ts | 10 + components/definitions/newTab.d.ts | 18 + .../resources/brave_components_strings.grd | 44 + test/BUILD.gn | 13 + .../api_test/braveShields/background.js | 7 + .../api_test/notBraveShields/background.js | 7 + ui/webui/resources/css/crypto_styles.css | 1275 +++++++++++++++++ ui/webui/resources/fonts/crypto_fonts.css | 17 + .../fonts/third_party/crypto/coins.ttf | Bin 0 -> 149404 bytes 57 files changed, 6827 insertions(+), 53 deletions(-) create mode 100644 browser/binance/binance_service_factory.cc create mode 100644 browser/binance/binance_service_factory.h create mode 100644 components/binance/browser/binance_json_parser.cc create mode 100644 components/binance/browser/binance_json_parser.h create mode 100644 components/binance/browser/binance_json_parser_unittest.cc create mode 100644 components/binance/browser/binance_protocol_handler.cc create mode 100644 components/binance/browser/binance_protocol_handler.h create mode 100644 components/binance/browser/binance_service.cc create mode 100644 components/binance/browser/binance_service.h create mode 100644 components/binance/browser/binance_service_browsertest.cc create mode 100644 components/binance/browser/buildflags/BUILD.gn rename components/binance/{ => browser/buildflags}/buildflags.gni (54%) create mode 100644 components/brave_new_tab_ui/binance-utils.ts create mode 100644 components/brave_new_tab_ui/components/default/binance/assets/hide-icon.tsx create mode 100644 components/brave_new_tab_ui/components/default/binance/assets/icons.ts create mode 100644 components/brave_new_tab_ui/components/default/binance/assets/party.png create mode 100644 components/brave_new_tab_ui/components/default/binance/assets/qr.png create mode 100644 components/brave_new_tab_ui/components/default/binance/assets/search-icon.png create mode 100644 components/brave_new_tab_ui/components/default/binance/assets/show-icon.tsx create mode 100644 components/brave_new_tab_ui/components/default/widget/assets/disconnect.tsx create mode 100644 components/brave_new_tab_ui/components/default/widget/assets/refresh.tsx create mode 100644 ui/webui/resources/css/crypto_styles.css create mode 100644 ui/webui/resources/fonts/crypto_fonts.css create mode 100644 ui/webui/resources/fonts/third_party/crypto/coins.ttf diff --git a/browser/BUILD.gn b/browser/BUILD.gn index e8d963be59f8..b86d9730ea5b 100644 --- a/browser/BUILD.gn +++ b/browser/BUILD.gn @@ -1,4 +1,5 @@ import("//brave/build/config.gni") +import("//brave/components/binance/browser/buildflags/buildflags.gni") import("//brave/browser/tor/buildflags/buildflags.gni") import("//brave/components/brave_ads/browser/buildflags/buildflags.gni") import("//brave/components/brave_perf_predictor/browser/buildflags/buildflags.gni") @@ -126,12 +127,14 @@ source_set("browser_process") { "//brave/common", "//brave/common:pref_names", "//brave/components/brave_ads/browser", + "//brave/components/binance/browser/buildflags", "//brave/components/brave_component_updater/browser", "//brave/components/brave_drm", "//brave/components/brave_referrals/browser", "//brave/components/brave_rewards/browser", "//brave/components/brave_shields/browser", "//brave/components/brave_shields/common", + "//brave/components/brave_wallet/browser/buildflags", "//brave/components/brave_wayback_machine:buildflags", "//brave/components/brave_webtorrent/browser/buildflags", "//brave/components/content_settings/core/browser", @@ -249,6 +252,15 @@ source_set("browser_process") { ] } + if (binance_enabled) { + sources += [ + "binance/binance_service_factory.cc", + "binance/binance_service_factory.h", + ] + + deps += [ "//brave/components/binance/browser" ] + } + if (enable_tor) { deps += [ "//brave/browser/tor", diff --git a/browser/binance/binance_service_factory.cc b/browser/binance/binance_service_factory.cc new file mode 100644 index 000000000000..abedb84d44bb --- /dev/null +++ b/browser/binance/binance_service_factory.cc @@ -0,0 +1,41 @@ +/* Copyright (c) 2020 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/browser/binance/binance_service_factory.h" + +#include "brave/components/binance/browser/binance_service.h" +#include "chrome/browser/profiles/incognito_helpers.h" +#include "chrome/browser/profiles/profile.h" +#include "components/keyed_service/content/browser_context_dependency_manager.h" + +// static +BinanceServiceFactory* BinanceServiceFactory::GetInstance() { + return base::Singleton::get(); +} + +// static +BinanceService* BinanceServiceFactory::GetForProfile(Profile* profile) { + return static_cast( + GetInstance()->GetServiceForBrowserContext(profile, true)); +} + +BinanceServiceFactory::BinanceServiceFactory() + : BrowserContextKeyedServiceFactory( + "BinanceService", + BrowserContextDependencyManager::GetInstance()) { +} + +BinanceServiceFactory::~BinanceServiceFactory() { +} + +KeyedService* BinanceServiceFactory::BuildServiceInstanceFor( + content::BrowserContext* context) const { + return new BinanceService(Profile::FromBrowserContext(context)); +} + +content::BrowserContext* BinanceServiceFactory::GetBrowserContextToUse( + content::BrowserContext* context) const { + return chrome::GetBrowserContextRedirectedInIncognito(context); +} diff --git a/browser/binance/binance_service_factory.h b/browser/binance/binance_service_factory.h new file mode 100644 index 000000000000..72430236234b --- /dev/null +++ b/browser/binance/binance_service_factory.h @@ -0,0 +1,35 @@ +/* Copyright (c) 2020 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_BROWSER_BINANCE_BINANCE_SERVICE_FACTORY_H_ +#define BRAVE_BROWSER_BINANCE_BINANCE_SERVICE_FACTORY_H_ + +#include "base/memory/singleton.h" +#include "components/keyed_service/content/browser_context_keyed_service_factory.h" + +class BinanceService; +class Profile; + +class BinanceServiceFactory : public BrowserContextKeyedServiceFactory { + public: + static BinanceService* GetForProfile(Profile* profile); + static BinanceServiceFactory* GetInstance(); + + private: + friend struct base::DefaultSingletonTraits; + + BinanceServiceFactory(); + ~BinanceServiceFactory() override; + + // BrowserContextKeyedServiceFactory overrides: + KeyedService* BuildServiceInstanceFor( + content::BrowserContext* context) const override; + content::BrowserContext* GetBrowserContextToUse( + content::BrowserContext* context) const override; + + DISALLOW_COPY_AND_ASSIGN(BinanceServiceFactory); +}; + +#endif // BRAVE_BROWSER_BINANCE_BINANCE_SERVICE_FACTORY_H_ diff --git a/browser/brave_content_browser_client.cc b/browser/brave_content_browser_client.cc index c838c5c27d08..bee396592abc 100644 --- a/browser/brave_content_browser_client.cc +++ b/browser/brave_content_browser_client.cc @@ -21,6 +21,7 @@ #include "brave/browser/tor/buildflags.h" #include "brave/common/pref_names.h" #include "brave/common/webui_url_constants.h" +#include "brave/components/binance/browser/buildflags/buildflags.h" #include "brave/components/brave_ads/browser/buildflags/buildflags.h" #include "brave/components/brave_rewards/browser/buildflags/buildflags.h" #include "brave/components/brave_shields/browser/brave_shields_util.h" @@ -102,6 +103,10 @@ using extensions::ChromeContentBrowserClientExtensionsPart; #include "content/public/common/resource_type.h" #endif +#if BUILDFLAG(BINANCE_ENABLED) +#include "brave/components/binance/browser/binance_protocol_handler.h" +#endif + namespace { bool HandleURLReverseOverrideRewrite(GURL* url, @@ -186,6 +191,14 @@ bool BraveContentBrowserClient::HandleExternalProtocol( } #endif +#if BUILDFLAG(BINANCE_ENABLED) + if (binance::IsBinanceProtocol(url)) { + binance::HandleBinanceProtocol(url, std::move(web_contents_getter), + page_transition, has_user_gesture); + return true; + } +#endif + return ChromeContentBrowserClient::HandleExternalProtocol( url, std::move(web_contents_getter), child_id, navigation_data, is_main_frame, page_transition, has_user_gesture, initiating_origin, diff --git a/browser/brave_profile_prefs.cc b/browser/brave_profile_prefs.cc index 8daee468fc06..66bb5e78bd42 100644 --- a/browser/brave_profile_prefs.cc +++ b/browser/brave_profile_prefs.cc @@ -8,6 +8,7 @@ #include "brave/browser/themes/brave_dark_mode_utils.h" #include "brave/common/brave_wallet_constants.h" #include "brave/common/pref_names.h" +#include "brave/components/binance/browser/buildflags/buildflags.h" #include "brave/components/brave_perf_predictor/browser/buildflags.h" #include "brave/components/brave_shields/browser/brave_shields_web_contents_observer.h" #include "brave/components/brave_sync/brave_sync_prefs.h" @@ -203,6 +204,12 @@ void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable* registry) { registry->RegisterIntegerPref(kBraveWalletWeb3Provider, static_cast(BraveWalletWeb3ProviderTypes::ASK)); + // Binance widget +#if BUILDFLAG(BINANCE_ENABLED) + registry->RegisterStringPref(kBinanceAccessToken, ""); + registry->RegisterStringPref(kBinanceRefreshToken, ""); +#endif + // Autocomplete in address bar registry->RegisterBooleanPref(kAutocompleteEnabled, true); diff --git a/browser/extensions/BUILD.gn b/browser/extensions/BUILD.gn index ca136fee9ebf..13397170f6e3 100644 --- a/browser/extensions/BUILD.gn +++ b/browser/extensions/BUILD.gn @@ -1,5 +1,5 @@ import("//brave/browser/tor/buildflags/buildflags.gni") -import("//brave/components/binance/buildflags.gni") +import("//brave/components/binance/browser/buildflags/buildflags.gni") import("//brave/components/brave_rewards/browser/buildflags/buildflags.gni") import("//brave/components/brave_sync/buildflags/buildflags.gni") import("//brave/components/brave_wallet/browser/buildflags/buildflags.gni") diff --git a/browser/extensions/api/binance_api.cc b/browser/extensions/api/binance_api.cc index 6d78733e7d8f..bca335a81e67 100644 --- a/browser/extensions/api/binance_api.cc +++ b/browser/extensions/api/binance_api.cc @@ -1,57 +1,239 @@ -/* Copyright (c) 2019 The Brave Authors. All rights reserved. +/* Copyright (c) 2020 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/browser/extensions/api/binance_api.h" -#include #include +#include +#include +#include #include "base/environment.h" #include "brave/browser/profiles/profile_util.h" + +#include "brave/common/extensions/api/binance.h" +#include "brave/common/extensions/extension_constants.h" +#include "brave/common/pref_names.h" +#include "brave/browser/binance/binance_service_factory.h" +#include "brave/components/binance/browser/binance_service.h" #include "brave/components/binance/browser/static_values.h" +#include "chrome/browser/extensions/api/tabs/tabs_constants.h" +#include "chrome/browser/extensions/extension_tab_util.h" +#include "chrome/browser/infobars/infobar_service.h" #include "chrome/browser/profiles/profile.h" +#include "components/prefs/pref_service.h" #include "components/country_codes/country_codes.h" +#include "extensions/browser/extension_util.h" + +namespace { + +BinanceService* GetBinanceService(content::BrowserContext* context) { + return BinanceServiceFactory::GetInstance() + ->GetForProfile(Profile::FromBrowserContext(context)); +} + +bool IsBinanceAPIAvailable(content::BrowserContext* context) { + Profile* profile = Profile::FromBrowserContext(context); + return !brave::IsTorProfile(profile) && + !profile->IsIncognitoProfile() && + !profile->IsGuestSession(); +} + +} // namespace namespace extensions { namespace api { ExtensionFunction::ResponseAction BinanceGetUserTLDFunction::Run() { - Profile* profile = Profile::FromBrowserContext(browser_context()); - if (brave::IsTorProfile(profile)) { - return RespondNow(Error("Not available in Tor profile")); + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); } - const std::string us_TLD = "us"; - const std::string us_Code = "US"; - const std::string global_TLD = "com"; + auto* service = GetBinanceService(browser_context()); + const std::string user_tld = service->GetBinanceTLD(); - const int32_t user_country_id = - country_codes::GetCountryIDFromPrefs(profile->GetPrefs()); - const int32_t us_id = country_codes::CountryCharsToCountryID( - us_Code.at(0), us_Code.at(1)); + return RespondNow(OneArgument( + std::make_unique(user_tld))); +} - const std::string user_TLD = - (user_country_id == us_id) ? us_TLD : global_TLD; +ExtensionFunction::ResponseAction +BinanceGetClientUrlFunction::Run() { + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); + } + + auto* service = GetBinanceService(browser_context()); + const std::string client_url = service->GetOAuthClientUrl(); return RespondNow(OneArgument( - std::make_unique(user_TLD))); + std::make_unique(client_url))); +} + +ExtensionFunction::ResponseAction +BinanceGetAccessTokenFunction::Run() { + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); + } + + std::unique_ptr params( + binance::GetAccessToken::Params::Create(*args_)); + EXTENSION_FUNCTION_VALIDATE(params.get()); + + auto* service = GetBinanceService(browser_context()); + bool token_request = service->GetAccessToken(params->code, + base::BindOnce( + &BinanceGetAccessTokenFunction::OnCodeResult, this)); + + if (!token_request) { + return RespondNow( + Error("Could not make request for access tokens")); + } + + return RespondLater(); +} + +void BinanceGetAccessTokenFunction::OnCodeResult(bool success) { + Respond(OneArgument(std::make_unique(success))); +} + +ExtensionFunction::ResponseAction +BinanceGetAccountBalancesFunction::Run() { + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); + } + + auto* service = GetBinanceService(browser_context()); + bool balance_success = service->GetAccountBalances( + base::BindOnce( + &BinanceGetAccountBalancesFunction::OnGetAccountBalances, + this)); + + if (!balance_success) { + return RespondNow(Error("Could not send request to get balance")); + } + + return RespondLater(); +} + +void BinanceGetAccountBalancesFunction::OnGetAccountBalances( + const std::map& balances, bool success) { + auto balance_dict = std::make_unique( + base::Value::Type::DICTIONARY); + + for (const auto& balance : balances) { + balance_dict->SetStringKey(balance.first, balance.second); + } + + Respond(TwoArguments(std::move(balance_dict), + std::make_unique(success))); +} + +ExtensionFunction::ResponseAction +BinanceGetConvertQuoteFunction::Run() { + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); + } + + std::unique_ptr params( + binance::GetConvertQuote::Params::Create(*args_)); + EXTENSION_FUNCTION_VALIDATE(params.get()); + + auto* service = GetBinanceService(browser_context()); + bool token_request = service->GetConvertQuote( + params->from, params->to, params->amount, + base::BindOnce( + &BinanceGetConvertQuoteFunction::OnQuoteResult, this)); + + if (!token_request) { + return RespondNow( + Error("Could not make request for access tokens")); + } + + return RespondLater(); +} + +void BinanceGetConvertQuoteFunction::OnQuoteResult( + const std::string& quote_id, const std::string& quote_price, + const std::string& total_fee, const std::string& total_amount) { + auto quote = std::make_unique(base::Value::Type::DICTIONARY); + quote->SetStringKey("id", quote_id); + quote->SetStringKey("price", quote_price); + quote->SetStringKey("fee", total_fee); + quote->SetStringKey("amount", total_amount); + Respond(OneArgument(std::move(quote))); +} + +ExtensionFunction::ResponseAction +BinanceGetTickerPriceFunction::Run() { + std::unique_ptr params( + binance::GetTickerPrice::Params::Create(*args_)); + EXTENSION_FUNCTION_VALIDATE(params.get()); + + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); + } + + auto* service = GetBinanceService(browser_context()); + bool value_request = service->GetTickerPrice(params->symbol_pair, + base::BindOnce( + &BinanceGetTickerPriceFunction::OnGetTickerPrice, this)); + + if (!value_request) { + return RespondNow( + Error("Could not make request for BTC price")); + } + + return RespondLater(); +} + +void BinanceGetTickerPriceFunction::OnGetTickerPrice( + const std::string& symbol_pair_price) { + Respond(OneArgument(std::make_unique(symbol_pair_price))); +} + +ExtensionFunction::ResponseAction +BinanceGetTickerVolumeFunction::Run() { + std::unique_ptr params( + binance::GetTickerVolume::Params::Create(*args_)); + EXTENSION_FUNCTION_VALIDATE(params.get()); + + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); + } + + auto* service = GetBinanceService(browser_context()); + bool value_request = service->GetTickerVolume(params->symbol_pair, + base::BindOnce( + &BinanceGetTickerVolumeFunction::OnGetTickerVolume, this)); + + if (!value_request) { + return RespondNow( + Error("Could not make request for Volume")); + } + + return RespondLater(); +} + +void BinanceGetTickerVolumeFunction::OnGetTickerVolume( + const std::string& symbol_pair_volume) { + Respond(OneArgument(std::make_unique(symbol_pair_volume))); } ExtensionFunction::ResponseAction BinanceIsSupportedRegionFunction::Run() { - Profile* profile = Profile::FromBrowserContext(browser_context()); - if (brave::IsTorProfile(profile)) { - return RespondNow(Error("Not available in Tor profile")); + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); } bool is_blacklisted = false; + Profile* profile = Profile::FromBrowserContext(browser_context()); const int32_t user_country_id = country_codes::GetCountryIDFromPrefs(profile->GetPrefs()); - for (const auto& country : binance::kBinanceBlacklistRegions) { + for (const auto& country : ::binance::kBinanceBlacklistRegions) { const int id = country_codes::CountryCharsToCountryID( country.at(0), country.at(1)); @@ -65,5 +247,125 @@ BinanceIsSupportedRegionFunction::Run() { std::make_unique(!is_blacklisted))); } +ExtensionFunction::ResponseAction +BinanceGetDepositInfoFunction::Run() { + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); + } + + std::unique_ptr params( + binance::GetDepositInfo::Params::Create(*args_)); + EXTENSION_FUNCTION_VALIDATE(params.get()); + + auto* service = GetBinanceService(browser_context()); + bool info_request = service->GetDepositInfo(params->symbol, + base::BindOnce( + &BinanceGetDepositInfoFunction::OnGetDepositInfo, this)); + + if (!info_request) { + return RespondNow( + Error("Could not make request for deposit information.")); + } + + return RespondLater(); +} + +void BinanceGetDepositInfoFunction::OnGetDepositInfo( + const std::string& deposit_address, + const std::string& deposit_url, + bool success) { + Respond(TwoArguments( + std::make_unique(deposit_address), + std::make_unique(deposit_url))); +} + +ExtensionFunction::ResponseAction +BinanceConfirmConvertFunction::Run() { + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); + } + + std::unique_ptr params( + binance::ConfirmConvert::Params::Create(*args_)); + EXTENSION_FUNCTION_VALIDATE(params.get()); + + auto* service = GetBinanceService(browser_context()); + bool confirm_request = service->ConfirmConvert(params->quote_id, + base::BindOnce( + &BinanceConfirmConvertFunction::OnConfirmConvert, this)); + + if (!confirm_request) { + return RespondNow( + Error("Could not confirm conversion")); + } + + return RespondLater(); +} + +void BinanceConfirmConvertFunction::OnConfirmConvert( + bool success, const std::string& message) { + Respond(TwoArguments( + std::make_unique(success), + std::make_unique(message))); +} + +ExtensionFunction::ResponseAction +BinanceGetConvertAssetsFunction::Run() { + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); + } + + auto* service = GetBinanceService(browser_context()); + bool asset_request = service->GetConvertAssets(base::BindOnce( + &BinanceGetConvertAssetsFunction::OnGetConvertAssets, this)); + + if (!asset_request) { + return RespondNow( + Error("Could not retrieve supported convert assets")); + } + + return RespondLater(); +} + +void BinanceGetConvertAssetsFunction::OnGetConvertAssets( + const std::map>& assets) { + std::unique_ptr asset_dict( + new base::DictionaryValue()); + + for (const auto& asset : assets) { + auto supported = std::make_unique(); + if (!asset.second.empty()) { + for (auto const& ticker : asset.second) { + supported->Append(ticker); + } + } + asset_dict->SetList(asset.first, std::move(supported)); + } + + Respond(OneArgument(std::move(asset_dict))); +} + +ExtensionFunction::ResponseAction +BinanceRevokeTokenFunction::Run() { + if (!IsBinanceAPIAvailable(browser_context())) { + return RespondNow(Error("Not available in Tor/incognito/guest profile")); + } + + auto* service = GetBinanceService(browser_context()); + bool request = service->RevokeToken(base::BindOnce( + &BinanceRevokeTokenFunction::OnRevokeToken, this)); + + if (!request) { + return RespondNow( + Error("Could not revoke token")); + } + + return RespondLater(); +} + +void BinanceRevokeTokenFunction::OnRevokeToken(bool success) { + Respond(OneArgument(std::make_unique(success))); +} + } // namespace api } // namespace extensions diff --git a/browser/extensions/api/binance_api.h b/browser/extensions/api/binance_api.h index b1d21d879965..9a3a02382a5d 100644 --- a/browser/extensions/api/binance_api.h +++ b/browser/extensions/api/binance_api.h @@ -1,4 +1,4 @@ -/* Copyright (c) 2019 The Brave Authors. All rights reserved. +/* Copyright (c) 2020 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/. */ @@ -8,6 +8,7 @@ #include #include +#include #include "extensions/browser/extension_function.h" @@ -36,6 +37,131 @@ class BinanceIsSupportedRegionFunction : ResponseAction Run() override; }; +class BinanceGetClientUrlFunction : + public ExtensionFunction { + public: + DECLARE_EXTENSION_FUNCTION("binance.getClientUrl", UNKNOWN) + + protected: + ~BinanceGetClientUrlFunction() override {} + ResponseAction Run() override; +}; + +class BinanceGetAccessTokenFunction : + public ExtensionFunction { + public: + DECLARE_EXTENSION_FUNCTION("binance.getAccessToken", UNKNOWN) + + protected: + ~BinanceGetAccessTokenFunction() override {} + void OnCodeResult(bool success); + + ResponseAction Run() override; +}; + +class BinanceGetAccountBalancesFunction : + public ExtensionFunction { + public: + DECLARE_EXTENSION_FUNCTION("binance.getAccountBalances", UNKNOWN) + + protected: + ~BinanceGetAccountBalancesFunction() override {} + void OnGetAccountBalances(const std::map& balances, + bool success); + + ResponseAction Run() override; +}; + +class BinanceGetConvertQuoteFunction : + public ExtensionFunction { + public: + DECLARE_EXTENSION_FUNCTION("binance.getConvertQuote", UNKNOWN) + + protected: + ~BinanceGetConvertQuoteFunction() override {} + void OnQuoteResult(const std::string& quote_id, + const std::string& quote_price, + const std::string& total_fee, + const std::string& total_amount); + + ResponseAction Run() override; +}; + +class BinanceGetTickerPriceFunction : + public ExtensionFunction { + public: + DECLARE_EXTENSION_FUNCTION("binance.getTickerPrice", UNKNOWN) + + protected: + ~BinanceGetTickerPriceFunction() override {} + void OnGetTickerPrice(const std::string& symbol_pair_price); + + ResponseAction Run() override; +}; + +class BinanceGetTickerVolumeFunction : + public ExtensionFunction { + public: + DECLARE_EXTENSION_FUNCTION("binance.getTickerVolume", UNKNOWN) + + protected: + ~BinanceGetTickerVolumeFunction() override {} + void OnGetTickerVolume(const std::string& symbol_pair_volume); + + ResponseAction Run() override; +}; + +class BinanceGetDepositInfoFunction : + public ExtensionFunction { + public: + DECLARE_EXTENSION_FUNCTION("binance.getDepositInfo", UNKNOWN) + + protected: + ~BinanceGetDepositInfoFunction() override {} + void OnGetDepositInfo(const std::string& deposit_address, + const std::string& deposit_url, + bool success); + + ResponseAction Run() override; +}; + +class BinanceConfirmConvertFunction : + public ExtensionFunction { + public: + DECLARE_EXTENSION_FUNCTION("binance.confirmConvert", UNKNOWN) + + protected: + ~BinanceConfirmConvertFunction() override {} + void OnConfirmConvert(bool success, const std::string& message); + + ResponseAction Run() override; +}; + +class BinanceGetConvertAssetsFunction : + public ExtensionFunction { + public: + DECLARE_EXTENSION_FUNCTION("binance.getConvertAssets", UNKNOWN) + + protected: + ~BinanceGetConvertAssetsFunction() override {} + void OnGetConvertAssets( + const std::map>& assets); + + ResponseAction Run() override; +}; + +class BinanceRevokeTokenFunction : + public ExtensionFunction { + public: + DECLARE_EXTENSION_FUNCTION("binance.revokeToken", UNKNOWN) + + protected: + ~BinanceRevokeTokenFunction() override {} + void OnRevokeToken(bool success); + + ResponseAction Run() override; +}; + } // namespace api } // namespace extensions diff --git a/browser/ui/webui/brave_webui_source.cc b/browser/ui/webui/brave_webui_source.cc index e59ca12711c0..84c4f57d5019 100644 --- a/browser/ui/webui/brave_webui_source.cc +++ b/browser/ui/webui/brave_webui_source.cc @@ -238,7 +238,50 @@ void CustomizeWebUIHTMLSource(const std::string &name, // Binance Widget { "binanceWidgetBuy", IDS_BINANCE_WIDGET_BUY }, { "binanceWidgetBuyCrypto", IDS_BINANCE_WIDGET_BUY_CRYPTO }, - { "binanceWidgetBuyDefault", IDS_BINANCE_WIDGET_BUY_DEFAULT } + { "binanceWidgetBuyDefault", IDS_BINANCE_WIDGET_BUY_DEFAULT }, + { "binanceWidgetWelcomeTitle", IDS_BINANCE_WIDGET_WELCOME_TITLE }, + { "binanceWidgetSubText", IDS_BINANCE_WIDGET_SUB_TEXT }, + { "binanceWidgetConnectText", IDS_BINANCE_WIDGET_CONNECT_TEXT }, + { "binanceWidgetDismissText", IDS_BINANCE_WIDGET_DISMISS_TEXT }, + { "binanceWidgetValueText", IDS_BINANCE_WIDGET_VALUE_TEXT }, + { "binanceWidgetBTCTickerText" , IDS_BINANCE_BTC_TICKER_TEXT }, + { "binanceWidgetViewDetails", IDS_BRAVE_UI_VIEW_DETAILS }, + { "binanceWidgetDepositLabel", IDS_BINANCE_WIDGET_DEPOSIT_LABEL }, + { "binanceWidgetTradeLabel", IDS_BINANCE_WIDGET_TRADE_LABEL }, + { "binanceWidgetInvalidEntry", IDS_BINANCE_WIDGET_INVALID_ENTRY }, + { "binanceWidgetValidatingCreds", IDS_BINANCE_WIDGET_VALIDATING_CREDS }, // NOLINT + { "binanceWidgetDisconnectTitle", IDS_BINANCE_WIDGET_DISCONNECT_TITLE }, // NOLINT + { "binanceWidgetDisconnectText" , IDS_BINANCE_WIDGET_DISCONNECT_TEXT }, // NOLINT + { "binanceWidgetDisconnectButton" , IDS_BINANCE_WIDGET_DISCONNECT_BUTTON }, // NOLINT + { "binanceWidgetCancelText" , IDS_BRAVE_UI_CANCEL }, + { "binanceWidgetAccountDisconnected" , IDS_BINANCE_WIDGET_ACCOUNT_DISCONNECTED }, // NOLINT + { "binanceWidgetConfigureButton" , IDS_BINANCE_WIDGET_CONFIGURE_BUTTON }, // NOLINT + { "binanceWidgetConnect", IDS_BINANCE_WIDGET_CONNECT }, + { "binanceWidgetConverted", IDS_BINANCE_WIDGET_CONVERTED }, + { "binanceWidgetContinue", IDS_BINANCE_WIDGET_CONTINUE }, + { "binanceWidgetUnableToConvert", IDS_BINANCE_WIDGET_UNABLE_TO_CONVERT }, // NOLINT + { "binanceWidgetRetry", IDS_BINANCE_WIDGET_RETRY }, + { "binanceWidgetInsufficientFunds", IDS_BINANCE_WIDGET_INSUFFICIENT_FUNDS }, // NOLINT + { "binanceWidgetConversionFailed", IDS_BINANCE_WIDGET_CONVERSION_FAILED }, // NOLINT + { "binanceWidgetDone", IDS_BINANCE_WIDGET_DONE }, + { "binanceWidgetCopy", IDS_BINANCE_WIDGET_COPY }, + { "binanceWidgetSearch", IDS_BINANCE_WIDGET_SEARCH }, + { "binanceWidgetAddressUnavailable", IDS_BINANCE_WIDGET_ADDRESS_UNAVAILABLE }, // NOLINT + { "binanceWidgetDepositAddress", IDS_BINANCE_WIDGET_DEPOSIT_ADDRESS }, + { "binanceWidgetConfirmConversion", IDS_BINANCE_WIDGET_CONFIRM_CONVERSION }, // NOLINT + { "binanceWidgetConvert", IDS_BINANCE_WIDGET_CONVERT }, + { "binanceWidgetRate", IDS_BINANCE_WIDGET_RATE }, + { "binanceWidgetFee", IDS_BINANCE_WIDGET_FEE }, + { "binanceWidgetWillReceive", IDS_BINANCE_WIDGET_WILL_RECEIVE }, + { "binanceWidgetConfirm", IDS_BINANCE_WIDGET_CONFIRM }, + { "binanceWidgetCancel", IDS_BINANCE_WIDGET_CANCEL }, + { "binanceWidgetAvailable" , IDS_BINANCE_WIDGET_AVAILABLE }, + { "binanceWidgetConvertIntent", IDS_BINANCE_WIDGET_CONVERT_INTENT }, + { "binanceWidgetPreviewConvert", IDS_BINANCE_WIDGET_PREVIEW_CONVERT }, + { "binanceWidgetSummary", IDS_BINANCE_WIDGET_SUMMARY }, + { "binanceWidgetAuthInvalid", IDS_BINANCE_WIDGET_AUTH_INVALID }, + { "binanceWidgetAuthInvalidCopy", IDS_BINANCE_WIDGET_AUTH_INVALID_COPY }, // NOLINT + { "binanceWidgetRefreshData", IDS_BINANCE_WIDGET_REFRESH_DATA } } }, { std::string("welcome"), { diff --git a/common/extensions/api/BUILD.gn b/common/extensions/api/BUILD.gn index 7e198fa4be4d..79d60043531e 100644 --- a/common/extensions/api/BUILD.gn +++ b/common/extensions/api/BUILD.gn @@ -1,4 +1,4 @@ -import("//brave/components/binance/buildflags.gni") +import("//brave/components/binance/browser/buildflags/buildflags.gni") import("//brave/components/brave_sync/buildflags/buildflags.gni") import("//brave/components/brave_wallet/browser/buildflags/buildflags.gni") import("//tools/grit/grit_rule.gni") diff --git a/common/extensions/api/binance.json b/common/extensions/api/binance.json index b6921a1e7071..ecb2f1381d60 100644 --- a/common/extensions/api/binance.json +++ b/common/extensions/api/binance.json @@ -45,6 +45,237 @@ ] } ] + }, + { + "name": "getClientUrl", + "type": "function", + "description": "Fetches the Oauth Url for Binance", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "clientUrl", + "type": "string" + } + ] + } + ] + }, + { + "name": "getAccessToken", + "type": "function", + "description": "Facilitates access token creation given a temporary code", + "parameters": [ + { + "type": "string", + "name": "code" + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "success", + "type": "boolean", + "description": "Indicates the access token was retrieved successfully" + } + ] + } + ] + }, + { + "name": "getAccountBalances", + "type": "function", + "description": "Retrieves user asset balances", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "balances", + "type": "object", + "additionalProperties": { "type": "string" } + }, { + "name": "success", + "type": "boolean", + "description": "Indicates whether the retrieval was successful" + } + ] + } + ] + }, + { + "name": "getConvertQuote", + "type": "function", + "description": "Gets a quote for a given conversion", + "parameters": [ + { + "type": "string", + "name": "from" + }, + { + "type": "string", + "name": "to" + }, + { + "type": "string", + "name": "amount" + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "quote", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "quote id" + }, + "price": { + "type": "string", + "description": "quote price" + }, + "fee": { + "type": "string", + "description": "convert fee" + }, + "amount": { + "type": "string", + "description": "total amount to be recieved" + } + } + } + ] + } + ] + }, + { + "name": "getTickerPrice", + "type": "function", + "description": "Fetches latest symbol pair trading value", + "parameters": [ + { + "type": "string", + "name": "symbolPair" + }, { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "symbolPairValue", + "type": "string" + } + ] + } + ] + }, + { + "name": "getTickerVolume", + "type": "function", + "description": "Fetches latest symbol pair trading volume", + "parameters": [ + { + "type": "string", + "name": "symbolPair" + }, { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "symbolPairVolume", + "type": "string" + } + ] + } + ] + }, + { + "name": "getDepositInfo", + "type": "function", + "description": "Fetches user address/url given an asset", + "parameters": [ + { + "type": "string", + "name": "symbol" + }, { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "depositAddress", + "type": "string" + }, + { + "name": "depositURL", + "type": "string" + } + ] + } + ] + }, + { + "name": "confirmConvert", + "type": "function", + "description": "Confirms a conversion given a quote id", + "parameters": [ + { + "type": "string", + "name": "quoteId" + }, { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "success", + "type": "boolean" + }, + { + "name": "message", + "type": "string" + } + ] + } + ] + }, + { + "name": "getConvertAssets", + "type": "function", + "description": "Gets a list of supported convert assets and their respective tradeables", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "supportedAssets", + "type": "object", + "additionalProperties": { "type": "any" } + } + ] + } + ] + }, + { + "name": "revokeToken", + "type": "function", + "description": "Revokes the token", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "success", + "type": "boolean" + } + ] + } + ] } ], "types": [ diff --git a/common/pref_names.cc b/common/pref_names.cc index 398659c057e1..3a5c358a2345 100644 --- a/common/pref_names.cc +++ b/common/pref_names.cc @@ -70,6 +70,8 @@ const char kBraveWalletEncryptedSeed[] = "brave.wallet.encrypted_seed"; const char kBraveWalletEnabledDeprecated[] = "brave.wallet.enabled"; const char kBraveWalletPrefVersion[] = "brave.wallet.pref_version"; const char kBraveWalletWeb3Provider[] = "brave.wallet.web3_provider"; +const char kBinanceAccessToken[] = "brave.binance.access_token"; +const char kBinanceRefreshToken[] = "brave.binance.refresh_token"; const char kAlwaysShowBookmarkBarOnNTP[] = "brave.always_show_bookmark_bar_on_ntp"; const char kRemoteDebuggingEnabled[] = "brave.remote_debugging_enabled"; diff --git a/common/pref_names.h b/common/pref_names.h index 4316125db945..e329a6fd4374 100644 --- a/common/pref_names.h +++ b/common/pref_names.h @@ -69,6 +69,8 @@ extern const char kAutocompleteEnabled[]; extern const char kBraveDarkMode[]; extern const char kOtherBookmarksMigrated[]; extern const char kBraveShieldsSettingsVersion[]; +extern const char kBinanceAccessToken[]; +extern const char kBinanceRefreshToken[]; #if !BUILDFLAG(USE_GCM_FROM_PLATFORM) extern const char kBraveGCMChannelStatus[]; #endif diff --git a/common/url_constants.cc b/common/url_constants.cc index 207bd8d3d8ba..8ff86ccb2727 100644 --- a/common/url_constants.cc +++ b/common/url_constants.cc @@ -10,6 +10,7 @@ const char kChromeExtensionScheme[] = "chrome-extension"; const char kBraveUIScheme[] = "brave"; const char kMagnetScheme[] = "magnet"; const char kRewardsScheme[] = "rewards"; +const char kBinanceScheme[] = "com.brave.binance"; const char kWidevineMoreInfoURL[] = "https://www.eff.org/issues/drm"; const char kWidevineTOS[] = "https://policies.google.com/terms"; const char kRewardsUpholdSupport[] = "https://uphold.com/en/brave/support"; diff --git a/common/url_constants.h b/common/url_constants.h index d532716d3978..fc9996a0bfc7 100644 --- a/common/url_constants.h +++ b/common/url_constants.h @@ -11,6 +11,7 @@ extern const char kChromeExtensionScheme[]; extern const char kBraveUIScheme[]; extern const char kMagnetScheme[]; extern const char kRewardsScheme[]; +extern const char kBinanceScheme[]; extern const char kWidevineMoreInfoURL[]; extern const char kWidevineTOS[]; extern const char kRewardsUpholdSupport[]; diff --git a/components/binance/browser/BUILD.gn b/components/binance/browser/BUILD.gn index af956be9fad4..3c635b176b2b 100644 --- a/components/binance/browser/BUILD.gn +++ b/components/binance/browser/BUILD.gn @@ -3,11 +3,38 @@ import("//brave/components/binance/browser/buildflags/buildflags.gni") assert(binance_enabled) +declare_args() { + binance_client_id = "" +} + source_set("browser") { public_deps = [ "buildflags" ] sources = [ - "static_values.h", + "binance_json_parser.cc", + "binance_json_parser.h", + "binance_protocol_handler.cc", + "binance_protocol_handler.h", + "binance_service.cc", + "binance_service.h", + "static_values.h" + ] + + deps = [ + "//base", + "//components/keyed_service/content", + "//components/keyed_service/core", + "//content/public/browser", + "//services/network/public/cpp", + "//url", + ] + configs += [ ":binance_config" ] +} + + +config("binance_config") { + defines = [ + "BINANCE_CLIENT_ID=\"$binance_client_id\"" ] } diff --git a/components/binance/browser/binance_json_parser.cc b/components/binance/browser/binance_json_parser.cc new file mode 100644 index 000000000000..c232d95e083e --- /dev/null +++ b/components/binance/browser/binance_json_parser.cc @@ -0,0 +1,421 @@ +/* Copyright (c) 2020 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 + +#include "brave/components/binance/browser/binance_json_parser.h" + +#include "base/json/json_reader.h" + +// static +// Response Format +// { +// "access_token": "83f2bf51-a2c4-4c2e-b7c4-46cef6a8dba5", +// "refresh_token": "fb5587ee-d9cf-4cb5-a586-4aed72cc9bea", +// "scope": "read", +// "token_type": "bearer", +// "expires_in": 30714 +// } +// +bool BinanceJSONParser::GetTokensFromJSON( + const std::string& json, std::string *value, std::string type) { + if (!value) { + return false; + } + + base::JSONReader::ValueWithError value_with_error = + base::JSONReader::ReadAndReturnValueWithError( + json, base::JSONParserOptions::JSON_PARSE_RFC); + base::Optional& records_v = value_with_error.value; + if (!records_v) { + LOG(ERROR) << "Invalid response, could not parse JSON, JSON is: " << json; + return false; + } + + const base::Value* token = records_v->FindKey(type); + + if (token && token->is_string()) { + *value = token->GetString(); + } + + return true; +} + +// static +// Response Format: +// { +// "code": "000000", +// "message": null, +// "data": [ +// { +// "asset": "ADA", +// "free": "0.00000000", +// "locked": "0.00000000", +// "freeze": "1.00000000", +// "withdrawing": "0.00000000" +// } +// ] +// } +// +bool BinanceJSONParser::GetAccountBalancesFromJSON( + const std::string& json, std::map* balances) { + if (!balances) { + return false; + } + + base::JSONReader::ValueWithError value_with_error = + base::JSONReader::ReadAndReturnValueWithError( + json, base::JSONParserOptions::JSON_PARSE_RFC); + base::Optional& records_v = value_with_error.value; + + if (!records_v) { + LOG(ERROR) << "Invalid response, could not parse JSON, JSON is: " << json; + return false; + } + + const base::Value* pv_arr = records_v->FindKey("data"); + if (pv_arr && pv_arr->is_list()) { + for (const base::Value &val : pv_arr->GetList()) { + const base::Value* asset = val.FindKey("asset"); + const base::Value* free_amount = val.FindKey("free"); + const base::Value* locked_amount = val.FindKey("locked"); + if (asset && asset->is_string() && + free_amount && free_amount->is_string() && + locked_amount && locked_amount->is_string()) { + const std::string asset_symbol = asset->GetString(); + balances->insert({asset_symbol, free_amount->GetString()}); + } + } + } + + return true; +} + +// static +// Response Format: +// { +// "code": "000000", +// "message": null, +// "data": { +// "quoteId": "b5481fb7f8314bb2baf55aa6d4fcf068", +// "quotePrice": "1094.01086957", +// "tradeFee": "8", +// "railFee": "0", +// "totalFee": "8", +// "totalAmount": "100649", +// "showPrice": "1094.01086957" +// }, +// } +bool BinanceJSONParser::GetQuoteInfoFromJSON( + const std::string& json, std::string *quote_id, + std::string *quote_price, std::string *total_fee, + std::string *total_amount) { + DCHECK(quote_id); + DCHECK(quote_price); + DCHECK(total_fee); + DCHECK(total_amount); + + base::JSONReader::ValueWithError value_with_error = + base::JSONReader::ReadAndReturnValueWithError( + json, base::JSONParserOptions::JSON_PARSE_RFC); + base::Optional& records_v = value_with_error.value; + if (!records_v) { + LOG(ERROR) << "Invalid response, could not parse JSON, JSON is: " << json; + return false; + } + + const base::DictionaryValue* response_dict; + if (!records_v->GetAsDictionary(&response_dict)) { + return false; + } + + const base::DictionaryValue* data_dict; + if (!response_dict->GetDictionary("data", &data_dict)) { + return false; + } + + if (!data_dict->GetString("quoteId", quote_id) || + !data_dict->GetString("quotePrice", quote_price) || + !data_dict->GetString("totalFee", total_fee) || + !data_dict->GetString("totalAmount", total_amount)) { + return false; + } + + return true; +} + +// static +// Response format: +// { +// "symbol": "BTCUSDT", +// "price":"7265.82000000" +// } +// +bool BinanceJSONParser::GetTickerPriceFromJSON( + const std::string& json, std::string* symbol_pair_price) { + DCHECK(symbol_pair_price); + base::JSONReader::ValueWithError value_with_error = + base::JSONReader::ReadAndReturnValueWithError( + json, base::JSONParserOptions::JSON_PARSE_RFC); + base::Optional& parsed_response = value_with_error.value; + if (!parsed_response) { + LOG(ERROR) << "Invalid response, could not parse JSON, JSON is: " << json; + return false; + } + + const base::Value* price = parsed_response->FindKey("price"); + if (!price || !price->is_string()) { + return false; + } + + *symbol_pair_price = price->GetString(); + return true; +} + +// static +// Response Format: +// { +// "symbol":"BTCUSDT", +// "volume":"1337" +// } +bool BinanceJSONParser::GetTickerVolumeFromJSON( + const std::string& json, std::string* symbol_pair_volume) { + DCHECK(symbol_pair_volume); + + base::JSONReader::ValueWithError value_with_error = + base::JSONReader::ReadAndReturnValueWithError( + json, base::JSONParserOptions::JSON_PARSE_RFC); + base::Optional& parsed_response = value_with_error.value; + if (!parsed_response) { + LOG(ERROR) << "Invalid response, could not parse JSON, JSON is: " << json; + return false; + } + + const base::Value* volume = parsed_response->FindKey("volume"); + if (!volume || !volume->is_string()) { + return false; + } + + *symbol_pair_volume = volume->GetString(); + return true; +} + +// static +// Response Format: +// { +// "code": "000000", +// "message": null, +// "data": { +// "coin": "BTC", +// "address": "112tfsHDk6Yk8PbNnTVkv7yPox4aWYYDtW", +// "tag": "", +// "url": "https://btc.com/112tfsHDk6Yk8PbNnTVkv7yPox4aWYYDtW", +// "time": 1566366289000 +// }, +// "success": true +// } +bool BinanceJSONParser::GetDepositInfoFromJSON( + const std::string& json, std::string *address, std::string *url) { + DCHECK(address); + DCHECK(url); + + base::JSONReader::ValueWithError value_with_error = + base::JSONReader::ReadAndReturnValueWithError( + json, base::JSONParserOptions::JSON_PARSE_RFC); + base::Optional& records_v = value_with_error.value; + + if (!records_v) { + LOG(ERROR) << "Invalid response, could not parse JSON, JSON is: " << json; + return false; + } + + const base::DictionaryValue* response_dict; + if (!records_v->GetAsDictionary(&response_dict)) { + return false; + } + + const base::DictionaryValue* data_dict; + if (!response_dict->GetDictionary("data", &data_dict)) { + return false; + } + + std::string deposit_url; + std::string deposit_address; + + if (!data_dict->GetString("url", &deposit_url) || + !data_dict->GetString("address", &deposit_address)) { + return false; + } + + *url = deposit_url; + *address = deposit_address; + return true; +} + +// static +// Response Format: +// { +// "code": "000000", +// "message": null, +// "data": { +// "quoteId": "b5481fb7f8314bb2baf55aa6d4fcf068", +// "status": "FAIL", +// "orderId": "ab0ab6cfd62240d79e10347fc5000bc4", +// "fromAsset": "BNB", +// "toAsset": "TRX", +// "sourceAmount": 100, +// "obtainAmount": 100649, +// "tradeFee": 8, +// "price": 1094.01086957, +// "feeType": 1, +// "feeRate": 0.08000000, +// "fixFee": 13.00000000 +// }, +// "success": true +// } +bool BinanceJSONParser::GetConfirmStatusFromJSON( + const std::string& json, std::string *error_message, + bool* success_status) { + if (!error_message || !success_status) { + return false; + } + + base::JSONReader::ValueWithError value_with_error = + base::JSONReader::ReadAndReturnValueWithError( + json, base::JSONParserOptions::JSON_PARSE_RFC); + base::Optional& records_v = value_with_error.value; + if (!records_v) { + LOG(ERROR) << "Invalid response, could not parse JSON, JSON is: " << json; + return false; + } + + const base::DictionaryValue* response_dict; + if (!records_v->GetAsDictionary(&response_dict)) { + return false; + } + + const base::DictionaryValue* data_dict; + if (!response_dict->GetDictionary("data", &data_dict)) { + std::string message; + if (!response_dict->GetString("message", &message)) { + return false; + } + *success_status = false; + *error_message = message; + return true; + } + + *success_status = true; + return true; +} + +// static +// Response Format: +// { +// "code":"000000", +// "message":null, +// "data":[{ +// "assetCode":"BTC", +// "assetName":"Bitcoin", +// "logoUrl":"https://bin.bnbstatic.com/images/20191211/fake.png", +// "size":"6", +// "order":0, +// "freeAsset":"0.00508311", +// "subSelector":[{ +// "assetCode":"BNB", +// "assetName":"BNB", +// "logoUrl":"https://bin.bnbstatic.com/images/fake.png", +// "size":"2", +// "order":1, +// "perTimeMinLimit":"0.00200000", +// "perTimeMaxLimit":"1.00000000", +// "dailyMaxLimit":"10.00000000", +// "hadDailyLimit":"0", +// "needMarket":true, +// "feeType":1, +// "feeRate":"0.00050000", +// "fixFee":"1.00000000", +// "feeCoin":"BTC", +// "forexRate":"1.00000000", +// "expireTime":30 +// }] +// }], +// "success":true +// } +bool BinanceJSONParser::GetConvertAssetsFromJSON(const std::string& json, + std::map>* assets) { + if (!assets) { + return false; + } + + base::JSONReader::ValueWithError value_with_error = + base::JSONReader::ReadAndReturnValueWithError( + json, base::JSONParserOptions::JSON_PARSE_RFC); + base::Optional& records_v = value_with_error.value; + + if (!records_v) { + LOG(ERROR) << "Invalid response, could not parse JSON, JSON is: " << json; + return false; + } + + const base::Value* data_arr = records_v->FindKey("data"); + if (data_arr && data_arr->is_list()) { + for (const base::Value &val : data_arr->GetList()) { + const base::Value* asset_code = val.FindKey("assetCode"); + if (asset_code && asset_code->is_string()) { + std::vector sub_selectors; + std::string asset_symbol = asset_code->GetString(); + const base::Value* selectors = val.FindKey("subSelector"); + if (selectors && selectors->is_list()) { + for (const base::Value &selector : selectors->GetList()) { + const base::Value* sub_code = selector.FindKey("assetCode"); + if (sub_code && sub_code->is_string()) { + sub_selectors.push_back(sub_code->GetString()); + } + } + assets->insert({asset_symbol, sub_selectors}); + } + } + } + } + return true; +} + +// static +// Response Format: +// { +// "code": "000000", +// "message": null, +// "data": true,// true means clear access_token success +// "success": true +// } +bool BinanceJSONParser::RevokeTokenFromJSON( + const std::string& json, + bool* success_status) { + DCHECK(success_status); + if (!success_status) { + return false; + } + + base::JSONReader::ValueWithError value_with_error = + base::JSONReader::ReadAndReturnValueWithError( + json, base::JSONParserOptions::JSON_PARSE_RFC); + base::Optional& records_v = value_with_error.value; + if (!records_v) { + LOG(ERROR) << "Invalid response, could not parse JSON, JSON is: " << json; + return false; + } + + const base::DictionaryValue* response_dict; + if (!records_v->GetAsDictionary(&response_dict)) { + return false; + } + + if (!response_dict->GetBoolean("success", success_status)) { + return false; + } + + return true; +} diff --git a/components/binance/browser/binance_json_parser.h b/components/binance/browser/binance_json_parser.h new file mode 100644 index 000000000000..e7e7e8686c35 --- /dev/null +++ b/components/binance/browser/binance_json_parser.h @@ -0,0 +1,42 @@ +/* Copyright (c) 2020 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_BINANCE_BROWSER_BINANCE_JSON_PARSER_H_ +#define BRAVE_COMPONENTS_BINANCE_BROWSER_BINANCE_JSON_PARSER_H_ + +#include +#include +#include + +class BinanceJSONParser { + public: + static bool GetTokensFromJSON(const std::string& json, + std::string *value, std::string type); + static bool GetAccountBalancesFromJSON(const std::string& json, + std::map*); + static bool GetQuoteIDFromJSON(const std::string& json, + std::string *quote_id); + static bool GetTickerPriceFromJSON(const std::string& json, + std::string* symbol_pair_price); + static bool GetTickerVolumeFromJSON(const std::string& json, + std::string* symbol_pair_volume); + static bool GetDepositInfoFromJSON(const std::string& json, + std::string* address, + std::string *url); + static bool GetQuoteInfoFromJSON(const std::string& json, + std::string* quote_id, + std::string* quote_price, + std::string* total_fee, + std::string* total_amount); + static bool GetConfirmStatusFromJSON(const std::string& json, + std::string *error_message, + bool* success_status); + static bool GetConvertAssetsFromJSON(const std::string& json, + std::map>* assets); + static bool RevokeTokenFromJSON(const std::string& json, + bool* success_status); +}; + +#endif // BRAVE_COMPONENTS_BINANCE_BROWSER_BINANCE_JSON_PARSER_H_ diff --git a/components/binance/browser/binance_json_parser_unittest.cc b/components/binance/browser/binance_json_parser_unittest.cc new file mode 100644 index 000000000000..9a688ccf1ad6 --- /dev/null +++ b/components/binance/browser/binance_json_parser_unittest.cc @@ -0,0 +1,214 @@ +/* Copyright (c) 2020 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/binance/browser/binance_json_parser.h" + +#include "brave/components/content_settings/core/common/content_settings_util.h" +#include "chrome/test/base/chrome_render_view_host_test_harness.h" + +// npm run test -- brave_unit_tests --filter=BinanceJSONParserTest.* + +namespace { + +std::string GetBalanceFromAssets( + const std::map& balances, + const std::string& asset) { + std::string balance; + std::map::const_iterator it = + balances.find(asset); + if (it != balances.end()) { + balance = it->second; + } + return balance; +} + +typedef testing::Test BinanceJSONParserTest; + +TEST_F(BinanceJSONParserTest, GetAccountBalancesFromJSON) { + std::map balances; + ASSERT_TRUE(BinanceJSONParser::GetAccountBalancesFromJSON(R"( + { + "code": "000000", + "message": null, + "data": [ + { + "asset": "BNB", + "free": "10114.00000000", + "locked": "0.00000000", + "freeze": "999990.00000000", + "withdrawing": "0.00000000" + }, + { + "asset": "BTC", + "free": "2.45000000", + "locked": "0.00000000", + "freeze": "999990.00000000", + "withdrawing": "0.00000000" + } + ] + })", &balances)); + + std::string bnb_balance = GetBalanceFromAssets(balances, "BNB"); + std::string btc_balance = GetBalanceFromAssets(balances, "BTC"); + ASSERT_EQ(bnb_balance, "10114.00000000"); + ASSERT_EQ(btc_balance, "2.45000000"); +} + +TEST_F(BinanceJSONParserTest, GetTokensFromJSON) { + std::string access_token; + std::string refresh_token; + + // Tokens are taken from documentation, examples only + ASSERT_TRUE(BinanceJSONParser::GetTokensFromJSON(R"( + { + "access_token": "83f2bf51-a2c4-4c2e-b7c4-46cef6a8dba5", + "refresh_token": "fb5587ee-d9cf-4cb5-a586-4aed72cc9bea", + "scope": "read", + "token_type": "bearer", + "expires_in": 30714 + })", &access_token, "access_token")); + + ASSERT_TRUE(BinanceJSONParser::GetTokensFromJSON(R"( + { + "access_token": "83f2bf51-a2c4-4c2e-b7c4-46cef6a8dba5", + "refresh_token": "fb5587ee-d9cf-4cb5-a586-4aed72cc9bea", + "scope": "read", + "token_type": "bearer", + "expires_in": 30714 + })", &refresh_token, "refresh_token")); + + ASSERT_EQ(access_token, "83f2bf51-a2c4-4c2e-b7c4-46cef6a8dba5"); + ASSERT_EQ(refresh_token, "fb5587ee-d9cf-4cb5-a586-4aed72cc9bea"); +} + +TEST_F(BinanceJSONParserTest, GetTickerPriceFromJSON) { + std::string symbol_pair_price; + ASSERT_TRUE(BinanceJSONParser::GetTickerPriceFromJSON(R"( + { + "symbol": "BTCUSDT", + "price": "7137.98000000" + })", &symbol_pair_price)); + ASSERT_EQ(symbol_pair_price, "7137.98000000"); +} + +TEST_F(BinanceJSONParserTest, GetTickerVolumeFromJSON) { + std::string symbol_pair_volume; + ASSERT_TRUE(BinanceJSONParser::GetTickerVolumeFromJSON(R"( + { + "symbol": "BTCUSDT", + "volume": "99849.90399800" + })", &symbol_pair_volume)); + ASSERT_EQ(symbol_pair_volume, "99849.90399800"); +} + +TEST_F(BinanceJSONParserTest, GetDepositInfoFromJSON) { + std::string deposit_address; + std::string deposit_url; + ASSERT_TRUE(BinanceJSONParser::GetDepositInfoFromJSON(R"( + { + "code": "0000", + "message": "null", + "data": { + "coin": "BTC", + "address": "112tfsHDk6Yk8PbNnTVkv7yPox4aWYYDtW", + "url": "https://btc.com/112tfsHDk6Yk8PbNnTVkv7yPox4aWYYDtW", + "time": 1566366289000 + } + })", &deposit_address, &deposit_url)); + ASSERT_EQ(deposit_address, "112tfsHDk6Yk8PbNnTVkv7yPox4aWYYDtW"); + ASSERT_EQ(deposit_url, "https://btc.com/112tfsHDk6Yk8PbNnTVkv7yPox4aWYYDtW"); +} + +TEST_F(BinanceJSONParserTest, GetQuoteInfoFromJSON) { + std::string quote_id; + std::string quote_price; + std::string total_fee; + std::string total_amount; + ASSERT_TRUE(BinanceJSONParser::GetQuoteInfoFromJSON(R"( + { + "code": "000000", + "message": null, + "data": { + "quoteId": "b5481fb7f8314bb2baf55aa6d4fcf068", + "quotePrice": "1094.01086957", + "tradeFee": "8.000000", + "railFee": "0", + "totalFee": "8.000000", + "totalAmount": "100649.010000", + "showPrice": "1094.01086957" + } + })", "e_id, "e_price, &total_fee, &total_amount)); + ASSERT_EQ(quote_id, "b5481fb7f8314bb2baf55aa6d4fcf068"); + ASSERT_EQ(quote_price, "1094.01086957"); + ASSERT_EQ(total_fee, "8.000000"); + ASSERT_EQ(total_amount, "100649.010000"); +} + +TEST_F(BinanceJSONParserTest, GetConfirmStatusFromJSONSuccess) { + std::string error; + bool success; + ASSERT_TRUE(BinanceJSONParser::GetConfirmStatusFromJSON(R"( + { + "code": "000000", + "message": null, + "data": { + "quoteId": "b5481fb7f8314bb2baf55aa6d4fcf068", + "status": "WAIT_MARKET", + "orderId": "ab0ab6cfd62240d79e10347fc5000bc4", + "fromAsset": "BNB", + "toAsset": "TRX", + "sourceAmount": 100, + "obtainAmount": 100649, + "tradeFee": 8, + "price": 1094.01086957, + "feeType": 1, + "feeRate": 0.08000000, + "fixFee": 13.00000000 + }, + "success": true + })", &error, &success)); + ASSERT_EQ(error, ""); + ASSERT_TRUE(success); +} + +TEST_F(BinanceJSONParserTest, GetConfirmStatusFromJSONFail) { + std::string error; + bool success; + ASSERT_TRUE(BinanceJSONParser::GetConfirmStatusFromJSON(R"( + { + "code": "117041", + "message": "Quote expired. Please try again.", + "data": null, + "success": false + })", &error, &success)); + ASSERT_EQ(error, "Quote expired. Please try again."); + ASSERT_FALSE(success); +} + +TEST_F(BinanceJSONParserTest, RevokeTokenFromJSONSuccess) { + bool success; + ASSERT_TRUE(BinanceJSONParser::RevokeTokenFromJSON(R"( + { + "code": "000000", + "message": null, + "data": true,// true means clear access_token success + "success": true + })", &success)); + ASSERT_TRUE(success); +} + +TEST_F(BinanceJSONParserTest, RevokeTokenFromJSONFail) { + bool success; + ASSERT_TRUE(BinanceJSONParser::RevokeTokenFromJSON(R"( + { + "code": "000000", + "message": null, + "data": false,// true means clear access_token success + "success": false + })", &success)); + ASSERT_FALSE(success); +} + +} // namespace diff --git a/components/binance/browser/binance_protocol_handler.cc b/components/binance/browser/binance_protocol_handler.cc new file mode 100644 index 000000000000..dbb971b68ed1 --- /dev/null +++ b/components/binance/browser/binance_protocol_handler.cc @@ -0,0 +1,89 @@ +/* Copyright (c) 2020 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/binance/browser/binance_protocol_handler.h" + +#include +#include + +#include "base/strings/strcat.h" +#include "base/strings/string_util.h" +#include "base/task/post_task.h" +#include "brave/common/url_constants.h" +#include "content/public/browser/browser_task_traits.h" +#include "content/public/browser/browser_thread.h" +#include "net/base/escape.h" + +namespace { + +GURL TranslateUrl(const GURL& url) { + if (!url.is_valid()) { + return GURL(); + } + + std::string path = url.path(); + std::string query; + + if (url.has_query()) { + query = base::StrCat({ + "?", + net::EscapeExternalHandlerValue(url.query()) + }); + } + + base::ReplaceFirstSubstringAfterOffset(&path, 0, "/", ""); + return GURL( + base::StrCat({ + "chrome://newtab", + path, + query + })); +} + +void LoadNewTabURL( + const GURL& url, + content::WebContents::OnceGetter web_contents_getter, + ui::PageTransition page_transition, + bool has_user_gesture) { + content::WebContents* web_contents = std::move(web_contents_getter).Run(); + if (!web_contents) { + return; + } + + const auto ref_url = web_contents->GetURL(); + if (!ref_url.is_valid()) { + return; + } + + // We should only allow binance scheme to be used from accounts.binance.com + if (!web_contents->GetURL().DomainIs("accounts.binance.com")) { + return; + } + + const auto new_url = TranslateUrl(url); + web_contents->GetController().LoadURL(new_url, content::Referrer(), + page_transition, std::string()); +} + +} // namespace + +namespace binance { + +void HandleBinanceProtocol(const GURL& url, + content::WebContents::OnceGetter web_contents_getter, + ui::PageTransition page_transition, + bool has_user_gesture) { + DCHECK(IsBinanceProtocol(url)); + base::PostTask( + FROM_HERE, {content::BrowserThread::UI}, + base::BindOnce(&LoadNewTabURL, url, std::move(web_contents_getter), + page_transition, has_user_gesture)); +} + +bool IsBinanceProtocol(const GURL& url) { + return url.SchemeIs(kBinanceScheme); +} + +} // namespace binance diff --git a/components/binance/browser/binance_protocol_handler.h b/components/binance/browser/binance_protocol_handler.h new file mode 100644 index 000000000000..a963abdcc7c7 --- /dev/null +++ b/components/binance/browser/binance_protocol_handler.h @@ -0,0 +1,25 @@ +/* Copyright (c) 2020 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_BINANCE_BROWSER_BINANCE_PROTOCOL_HANDLER_H_ +#define BRAVE_COMPONENTS_BINANCE_BROWSER_BINANCE_PROTOCOL_HANDLER_H_ + +#include + +#include "chrome/browser/external_protocol/external_protocol_handler.h" +#include "chrome/browser/profiles/profile.h" + +namespace binance { + +void HandleBinanceProtocol(const GURL& url, + content::WebContents::OnceGetter web_contents_getter, + ui::PageTransition page_transition, + bool has_user_gesture); + +bool IsBinanceProtocol(const GURL& url); + +} // namespace binance + +#endif // BRAVE_COMPONENTS_BINANCE_BROWSER_BINANCE_PROTOCOL_HANDLER_H_ diff --git a/components/binance/browser/binance_service.cc b/components/binance/browser/binance_service.cc new file mode 100644 index 000000000000..e989b3526466 --- /dev/null +++ b/components/binance/browser/binance_service.cc @@ -0,0 +1,512 @@ +/* Copyright (c) 2020 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/binance/browser/binance_service.h" + +#include +#include +#include + +#include "base/base64.h" +#include "base/bind.h" +#include "base/containers/flat_set.h" +#include "base/files/file_enumerator.h" +#include "base/files/file_util.h" +#include "base/strings/string_number_conversions.h" +#include "base/strings/utf_string_conversions.h" +#include "base/task/post_task.h" +#include "base/task_runner_util.h" +#include "base/time/time.h" +#include "base/token.h" +#include "brave/common/pref_names.h" +#include "brave/components/binance/browser/binance_json_parser.h" +#include "chrome/browser/profiles/profile.h" +#include "components/country_codes/country_codes.h" +#include "components/os_crypt/os_crypt.h" +#include "components/prefs/pref_service.h" +#include "content/public/browser/browser_context.h" +#include "content/public/browser/storage_partition.h" +#include "crypto/random.h" +#include "crypto/sha2.h" +#include "net/base/load_flags.h" +#include "net/base/url_util.h" +#include "services/network/public/cpp/shared_url_loader_factory.h" +#include "services/network/public/cpp/simple_url_loader.h" + +namespace { + +const char oauth_host[] = "accounts.binance.com"; +const char api_host[] = "api.binance.com"; +const char oauth_callback[] = "com.brave.binance://authorization"; +const char oauth_scope[] = + "user:email,user:address,asset:balance,asset:ocbs"; +const GURL oauth_url("https://accounts.binance.com/en/oauth/authorize"); +const unsigned int kRetriesCountOnNetworkChange = 1; + +net::NetworkTrafficAnnotationTag GetNetworkTrafficAnnotationTag() { + return net::DefineNetworkTrafficAnnotation("binance_service", R"( + semantics { + sender: "Binance Service" + description: + "This service is used to communicate with Binance " + "on behalf of the user interacting with the Binance widget." + trigger: + "Triggered by user connecting the Binance widget." + data: + "Account balance for the widget." + destination: WEBSITE + } + policy { + cookies_allowed: NO + setting: + "You can enable or disable this feature on the new tab page." + policy_exception_justification: + "Not implemented." + } + )"); +} + +GURL GetURLWithPath(const std::string& host, const std::string& path) { + return GURL(std::string(url::kHttpsScheme) + "://" + host).Resolve(path); +} + +std::string GetHexEncodedCryptoRandomSeed() { + const size_t kSeedByteLength = 28; + // crypto::RandBytes is fail safe. + uint8_t random_seed_bytes[kSeedByteLength]; + crypto::RandBytes(random_seed_bytes, kSeedByteLength); + return base::HexEncode( + reinterpret_cast(random_seed_bytes), kSeedByteLength); +} + +} // namespace + +BinanceService::BinanceService(content::BrowserContext* context) + : client_id_(BINANCE_CLIENT_ID), + oauth_host_(oauth_host), + api_host_(api_host), + context_(context), + url_loader_factory_( + content::BrowserContext::GetDefaultStoragePartition(context_) + ->GetURLLoaderFactoryForBrowserProcess()), + weak_factory_(this) { + LoadTokensFromPrefs(); +} + +BinanceService::~BinanceService() { +} + +// static +std::string BinanceService::GetCodeChallenge(const std::string& code_verifier) { + std::string code_challenge; + char raw[crypto::kSHA256Length] = {0}; + crypto::SHA256HashString(code_verifier, + raw, + crypto::kSHA256Length); + base::Base64Encode(base::StringPiece(raw, + crypto::kSHA256Length), + &code_challenge); + + // Binance expects the following conversions for the base64 encoded value: + std::replace(code_challenge.begin(), code_challenge.end(), '+', '-'); + std::replace(code_challenge.begin(), code_challenge.end(), '/', '_'); + code_challenge.erase(std::find_if(code_challenge.rbegin(), + code_challenge.rend(), [](int ch) { + return ch != '='; + }).base(), code_challenge.end()); + + return code_challenge; +} + +std::string BinanceService::GetOAuthClientUrl() { + // The code_challenge_ value is derived from the code_verifier value. + // Step 1 of the oauth process uses the code_challenge_ value. + // Step 4 of the oauth process uess the code_verifer_. + // We never need to persist these values, they are just used to get an + // access token. + code_verifier_ = GetHexEncodedCryptoRandomSeed(); + code_challenge_ = GetCodeChallenge(code_verifier_); + + GURL url(oauth_url); + url = net::AppendQueryParameter(url, "response_type", "code"); + url = net::AppendQueryParameter(url, "client_id", client_id_); + url = net::AppendQueryParameter(url, "redirect_uri", oauth_callback); + url = net::AppendQueryParameter(url, "scope", oauth_scope); + url = net::AppendQueryParameter(url, "code_challenge", code_challenge_); + url = net::AppendQueryParameter(url, "code_challenge_method", "S256"); + return url.spec(); +} + +bool BinanceService::GetAccessToken(const std::string& code, + GetAccessTokenCallback callback) { + auto internal_callback = base::BindOnce(&BinanceService::OnGetAccessToken, + base::Unretained(this), std::move(callback)); + GURL base_url = GetURLWithPath(oauth_host_, oauth_path_access_token); + GURL url = base_url; + url = net::AppendQueryParameter(url, "grant_type", "authorization_code"); + url = net::AppendQueryParameter(url, "code", code); + url = net::AppendQueryParameter(url, "client_id", client_id_); + url = net::AppendQueryParameter(url, "code_verifier", code_verifier_); + url = net::AppendQueryParameter(url, "redirect_uri", oauth_callback); + return OAuthRequest( + base_url, "POST", url.query(), std::move(internal_callback)); +} + +bool BinanceService::GetAccountBalances(GetAccountBalancesCallback callback) { + auto internal_callback = base::BindOnce(&BinanceService::OnGetAccountBalances, + base::Unretained(this), std::move(callback)); + GURL url = GetURLWithPath(oauth_host_, oauth_path_account_balances); + url = net::AppendQueryParameter(url, "access_token", access_token_); + return OAuthRequest(url, "GET", "", std::move(internal_callback)); +} + +void BinanceService::OnGetAccountBalances(GetAccountBalancesCallback callback, + const int status, const std::string& body, + const std::map& headers) { + std::map balances; + + bool success = status >= 200 && status <= 299; + if (success) { + BinanceJSONParser::GetAccountBalancesFromJSON(body, &balances); + } + std::move(callback).Run(balances, success); +} + +void BinanceService::OnGetAccessToken( + GetAccessTokenCallback callback, + const int status, const std::string& body, + const std::map& headers) { + std::string access_token; + std::string refresh_token; + if (status >= 200 && status <= 299) { + BinanceJSONParser::GetTokensFromJSON(body, &access_token, "access_token"); + BinanceJSONParser::GetTokensFromJSON(body, &refresh_token, "refresh_token"); + SetAccessTokens(access_token, refresh_token); + } + std::move(callback).Run(!access_token.empty()); +} + +bool BinanceService::OAuthRequest(const GURL &url, + const std::string& method, + const std::string& post_data, + URLRequestCallback callback) { + auto request = std::make_unique(); + request->url = url; + request->load_flags = net::LOAD_DO_NOT_SEND_COOKIES | + net::LOAD_DO_NOT_SAVE_COOKIES | + net::LOAD_BYPASS_CACHE | + net::LOAD_DISABLE_CACHE; + request->method = method; + + auto url_loader = network::SimpleURLLoader::Create( + std::move(request), GetNetworkTrafficAnnotationTag()); + if (!post_data.empty()) { + url_loader->AttachStringForUpload(post_data, + "application/x-www-form-urlencoded"); + } + url_loader->SetRetryOptions( + kRetriesCountOnNetworkChange, + network::SimpleURLLoader::RetryMode::RETRY_ON_NETWORK_CHANGE); + auto iter = url_loaders_.insert(url_loaders_.begin(), std::move(url_loader)); + + Profile* profile = Profile::FromBrowserContext(context_); + auto* default_storage_partition = + content::BrowserContext::GetDefaultStoragePartition(profile); + auto* url_loader_factory = + default_storage_partition->GetURLLoaderFactoryForBrowserProcess().get(); + + iter->get()->DownloadToStringOfUnboundedSizeUntilCrashAndDie( + url_loader_factory, base::BindOnce( + &BinanceService::OnURLLoaderComplete, + base::Unretained(this), std::move(iter), std::move(callback))); + + return true; +} + +void BinanceService::OnURLLoaderComplete( + SimpleURLLoaderList::iterator iter, + URLRequestCallback callback, + const std::unique_ptr response_body) { + auto* loader = iter->get(); + auto response_code = -1; + std::map headers; + if (loader->ResponseInfo() && loader->ResponseInfo()->headers) { + response_code = loader->ResponseInfo()->headers->response_code(); + auto headers_list = loader->ResponseInfo()->headers; + if (headers_list) { + size_t iter = 0; + std::string key; + std::string value; + while (headers_list->EnumerateHeaderLines(&iter, &key, &value)) { + key = base::ToLowerASCII(key); + headers[key] = value; + } + } + } + + url_loaders_.erase(iter); + + std::move(callback).Run( + response_code, response_body ? *response_body : "", headers); +} + +bool BinanceService::SetAccessTokens(const std::string& access_token, + const std::string& refresh_token) { + access_token_ = access_token; + refresh_token_ = refresh_token; + + std::string encrypted_access_token; + std::string encrypted_refresh_token; + + if (!OSCrypt::EncryptString(access_token, &encrypted_access_token)) { + LOG(ERROR) << "Could not encrypt and save Binance token info"; + return false; + } + if (!OSCrypt::EncryptString(refresh_token, &encrypted_refresh_token)) { + LOG(ERROR) << "Could not encrypt and save Binance token info"; + return false; + } + + std::string encoded_encrypted_access_token; + std::string encoded_encrypted_refresh_token; + base::Base64Encode(encrypted_access_token, &encoded_encrypted_access_token); + base::Base64Encode( + encrypted_refresh_token, &encoded_encrypted_refresh_token); + + Profile* profile = Profile::FromBrowserContext(context_); + profile->GetPrefs()->SetString( + kBinanceAccessToken, encoded_encrypted_access_token); + profile->GetPrefs()->SetString(kBinanceRefreshToken, + encoded_encrypted_refresh_token); + + return true; +} + +bool BinanceService::LoadTokensFromPrefs() { + Profile* profile = Profile::FromBrowserContext(context_); + std::string encoded_encrypted_access_token = + profile->GetPrefs()->GetString(kBinanceAccessToken); + std::string encoded_encrypted_refresh_token = + profile->GetPrefs()->GetString(kBinanceRefreshToken); + + std::string encrypted_access_token; + std::string encrypted_refresh_token; + if (!base::Base64Decode(encoded_encrypted_access_token, + &encrypted_access_token) || + !base::Base64Decode(encoded_encrypted_refresh_token, + &encrypted_refresh_token)) { + LOG(ERROR) << "Could not Base64 decode Binance token info."; + return false; + } + + if (!OSCrypt::DecryptString(encrypted_access_token, &access_token_)) { + LOG(ERROR) << "Could not decrypt and save Binance token info."; + return false; + } + if (!OSCrypt::DecryptString(encrypted_refresh_token, &refresh_token_)) { + LOG(ERROR) << "Could not decrypt and save Binance token info."; + return false; + } + + return true; +} + +std::string BinanceService::GetBinanceTLD() { + Profile* profile = Profile::FromBrowserContext(context_); + const std::string us_code = "US"; + const int32_t user_country_id = + country_codes::GetCountryIDFromPrefs(profile->GetPrefs()); + const int32_t us_id = country_codes::CountryCharsToCountryID( + us_code.at(0), us_code.at(1)); + return (user_country_id == us_id) ? "us" : "com"; +} + +bool BinanceService::GetConvertQuote( + const std::string& from, + const std::string& to, + const std::string& amount, + GetConvertQuoteCallback callback) { + auto internal_callback = base::BindOnce(&BinanceService::OnGetConvertQuote, + base::Unretained(this), std::move(callback)); + GURL url = GetURLWithPath(oauth_host_, oauth_path_convert_quote); + url = net::AppendQueryParameter(url, "fromAsset", from); + url = net::AppendQueryParameter(url, "toAsset", to); + url = net::AppendQueryParameter(url, "baseAsset", from); + url = net::AppendQueryParameter(url, "amount", amount); + url = net::AppendQueryParameter(url, "access_token", access_token_); + return OAuthRequest(url, "POST", "", std::move(internal_callback)); +} + +void BinanceService::OnGetConvertQuote( + GetConvertQuoteCallback callback, + const int status, const std::string& body, + const std::map& headers) { + std::string quote_id; + std::string quote_price; + std::string total_fee; + std::string total_amount; + if (status >= 200 && status <= 299) { + BinanceJSONParser::GetQuoteInfoFromJSON( + body, "e_id, "e_price, &total_fee, &total_amount); + } + std::move(callback).Run(quote_id, quote_price, total_fee, total_amount); +} + +bool BinanceService::GetTickerPrice( + const std::string& symbol_pair, + GetTickerPriceCallback callback) { + auto internal_callback = base::BindOnce(&BinanceService::OnGetTickerPrice, + base::Unretained(this), std::move(callback)); + GURL url = GetURLWithPath(api_host_, api_path_ticker_price); + url = net::AppendQueryParameter(url, "symbol", symbol_pair); + return OAuthRequest(url, "GET", "", std::move(internal_callback)); +} + +bool BinanceService::GetTickerVolume( + const std::string& symbol_pair, + GetTickerVolumeCallback callback) { + auto internal_callback = base::BindOnce(&BinanceService::OnGetTickerVolume, + base::Unretained(this), std::move(callback)); + GURL url = GetURLWithPath(api_host_, api_path_ticker_volume); + url = net::AppendQueryParameter(url, "symbol", symbol_pair); + return OAuthRequest(url, "GET", "", std::move(internal_callback)); +} + +void BinanceService::OnGetTickerPrice( + GetTickerPriceCallback callback, + const int status, const std::string& body, + const std::map& headers) { + std::string symbol_pair_price = "0.00"; + if (status >= 200 && status <= 299) { + BinanceJSONParser::GetTickerPriceFromJSON(body, &symbol_pair_price); + } + std::move(callback).Run(symbol_pair_price); +} + +void BinanceService::OnGetTickerVolume( + GetTickerPriceCallback callback, + const int status, const std::string& body, + const std::map& headers) { + std::string symbol_pair_volume = "0"; + if (status >= 200 && status <= 299) { + BinanceJSONParser::GetTickerVolumeFromJSON(body, &symbol_pair_volume); + } + std::move(callback).Run(symbol_pair_volume); +} + +bool BinanceService::GetDepositInfo(const std::string& symbol, + GetDepositInfoCallback callback) { + auto internal_callback = base::BindOnce(&BinanceService::OnGetDepositInfo, + base::Unretained(this), std::move(callback)); + GURL url = GetURLWithPath(oauth_host_, oauth_path_deposit_info); + url = net::AppendQueryParameter(url, "coin", symbol); + url = net::AppendQueryParameter(url, "access_token", access_token_); + return OAuthRequest(url, "GET", "", std::move(internal_callback)); +} + +void BinanceService::OnGetDepositInfo( + GetDepositInfoCallback callback, + const int status, const std::string& body, + const std::map& headers) { + std::string deposit_address; + std::string deposit_url; + bool success = status >= 200 && status <= 299; + if (success) { + BinanceJSONParser::GetDepositInfoFromJSON( + body, &deposit_address, &deposit_url); + } + + std::move(callback).Run( + deposit_address, deposit_url, success); +} + +bool BinanceService::ConfirmConvert(const std::string& quote_id, + ConfirmConvertCallback callback) { + auto internal_callback = base::BindOnce(&BinanceService::OnConfirmConvert, + base::Unretained(this), std::move(callback)); + GURL url = GetURLWithPath(oauth_host_, oauth_path_convert_confirm); + url = net::AppendQueryParameter(url, "quoteId", quote_id); + url = net::AppendQueryParameter(url, "access_token", access_token_); + return OAuthRequest(url, "POST", "", std::move(internal_callback)); +} + +void BinanceService::OnConfirmConvert( + ConfirmConvertCallback callback, + const int status, const std::string& body, + const std::map& headers) { + bool success_status = false; + std::string error_message = ""; + + if (status >= 200 && status <= 299) { + BinanceJSONParser::GetConfirmStatusFromJSON( + body, &error_message, &success_status); + } + + std::move(callback).Run(success_status, error_message); +} + +bool BinanceService::GetConvertAssets(GetConvertAssetsCallback callback) { + auto internal_callback = base::BindOnce(&BinanceService::OnGetConvertAssets, + base::Unretained(this), std::move(callback)); + GURL url = GetURLWithPath(oauth_host_, oauth_path_convert_assets); + url = net::AppendQueryParameter(url, "access_token", access_token_); + return OAuthRequest(url, "GET", "", std::move(internal_callback)); +} + +void BinanceService::OnGetConvertAssets(GetConvertAssetsCallback callback, + const int status, const std::string& body, + const std::map& headers) { + std::map> assets; + + if (status >= 200 && status <= 299) { + BinanceJSONParser::GetConvertAssetsFromJSON(body, &assets); + } + + std::move(callback).Run(assets); +} + +bool BinanceService::RevokeToken(RevokeTokenCallback callback) { + auto internal_callback = base::BindOnce(&BinanceService::OnRevokeToken, + base::Unretained(this), std::move(callback)); + GURL url = GetURLWithPath(oauth_host_, oauth_path_revoke_token); + url = net::AppendQueryParameter(url, "access_token", access_token_); + return OAuthRequest(url, "POST", "", std::move(internal_callback)); +} + +void BinanceService::OnRevokeToken(RevokeTokenCallback callback, + const int status, const std::string& body, + const std::map& headers) { + bool success = false; + if (status >= 200 && status <= 299) { + BinanceJSONParser::RevokeTokenFromJSON(body, &success); + } + if (success) { + code_challenge_ = ""; + code_verifier_ = ""; + SetAccessTokens("", ""); + } + std::move(callback).Run(success); +} + +base::SequencedTaskRunner* BinanceService::io_task_runner() { + if (!io_task_runner_) { + io_task_runner_ = base::CreateSequencedTaskRunner( + {base::ThreadPool(), base::MayBlock(), base::TaskPriority::BEST_EFFORT, + base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN}); + } + return io_task_runner_.get(); +} + +void BinanceService::SetClientIdForTest(const std::string& client_id) { + client_id_ = client_id; +} + +void BinanceService::SetOAuthHostForTest(const std::string& oauth_host) { + oauth_host_ = oauth_host; +} + +void BinanceService::SetAPIHostForTest(const std::string& api_host) { + api_host_ = api_host; +} diff --git a/components/binance/browser/binance_service.h b/components/binance/browser/binance_service.h new file mode 100644 index 000000000000..55877a5efce8 --- /dev/null +++ b/components/binance/browser/binance_service.h @@ -0,0 +1,168 @@ +/* Copyright (c) 2020 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_BINANCE_BROWSER_BINANCE_SERVICE_H_ +#define BRAVE_COMPONENTS_BINANCE_BROWSER_BINANCE_SERVICE_H_ + +#include +#include +#include +#include +#include + +#include "base/callback_forward.h" +#include "base/containers/queue.h" +#include "base/files/file_path.h" +#include "base/macros.h" +#include "base/memory/scoped_refptr.h" +#include "base/memory/weak_ptr.h" +#include "base/scoped_observer.h" +#include "components/keyed_service/core/keyed_service.h" +#include "url/gurl.h" + +namespace base { +class FilePath; +class SequencedTaskRunner; +} // namespace base + +namespace content { +class BrowserContext; +} // namespace content + +namespace network { +class SharedURLLoaderFactory; +class SimpleURLLoader; +} // namespace network + +class Profile; + +const char oauth_path_access_token[] = "/oauth/token"; +const char oauth_path_account_balances[] = "/oauth-api/v1/balance"; +const char oauth_path_convert_assets[] = "/oauth-api/v1/ocbs/support-coins"; +const char oauth_path_convert_quote[] = "/oauth-api/v1/ocbs/quote"; +const char oauth_path_convert_confirm[] = "/oauth-api/v1/ocbs/confirm"; +const char oauth_path_deposit_info[] = "/oauth-api/v1/get-charge-address"; +const char oauth_path_revoke_token[] = "/oauth-api/v1/revoke-token"; + +const char api_path_ticker_price[] = "/api/v3/ticker/price"; +const char api_path_ticker_volume[] = "/api/v3/ticker/24hr"; + +class BinanceService : public KeyedService { + public: + explicit BinanceService(content::BrowserContext* context); + ~BinanceService() override; + + using GetAccessTokenCallback = base::OnceCallback; + using GetConvertQuoteCallback = base::OnceCallback; + using GetAccountBalancesCallback = base::OnceCallback< + void(const std::map&, bool success)>; + using GetDepositInfoCallback = base::OnceCallback; + using ConfirmConvertCallback = base::OnceCallback; + using GetConvertAssetsCallback = base::OnceCallback< + void(const std::map>&)>; + using GetTickerPriceCallback = base::OnceCallback; + using GetTickerVolumeCallback = base::OnceCallback; + using RevokeTokenCallback = base::OnceCallback; + + bool GetAccessToken(const std::string& code, + GetAccessTokenCallback callback); + bool GetConvertQuote(const std::string& from, + const std::string& to, + const std::string& amount, + GetConvertQuoteCallback callback); + bool GetAccountBalances(GetAccountBalancesCallback callback); + bool GetDepositInfo(const std::string& symbol, + GetDepositInfoCallback callback); + bool ConfirmConvert(const std::string& quote_id, + ConfirmConvertCallback callback); + bool GetConvertAssets(GetConvertAssetsCallback callback); + bool GetTickerPrice(const std::string& symbol_pair, + GetTickerPriceCallback callback); + bool GetTickerVolume(const std::string& symbol_pair, + GetTickerVolumeCallback callback); + bool RevokeToken(RevokeTokenCallback callback); + + std::string GetBinanceTLD(); + std::string GetOAuthClientUrl(); + static std::string GetCodeChallenge(const std::string& code_verifier); + + private: + static GURL oauth_endpoint_; + static GURL api_endpoint_; + using SimpleURLLoaderList = + std::list>; + bool SetAccessTokens(const std::string& access_token, + const std::string& refresh_token); + + using URLRequestCallback = + base::OnceCallback&)>; + + base::SequencedTaskRunner* io_task_runner(); + void OnGetAccessToken(GetAccessTokenCallback callback, + const int status, const std::string& body, + const std::map& headers); + void OnGetConvertQuote(GetConvertQuoteCallback callback, + const int status, const std::string& body, + const std::map& headers); + void OnGetAccountBalances(GetAccountBalancesCallback callback, + const int status, const std::string& body, + const std::map& headers); + void OnGetTickerPrice(GetTickerPriceCallback callback, + const int status, const std::string& body, + const std::map& headers); + void OnGetTickerVolume(GetTickerVolumeCallback callback, + const int status, const std::string& body, + const std::map& headers); + void OnGetDepositInfo(GetDepositInfoCallback callback, + const int status, const std::string& body, + const std::map& headers); + void OnConfirmConvert(ConfirmConvertCallback callback, + const int status, const std::string& body, + const std::map& headers); + void OnGetConvertAssets(GetConvertAssetsCallback callback, + const int status, const std::string& body, + const std::map& headers); + void OnRevokeToken(RevokeTokenCallback callback, + const int status, const std::string& body, + const std::map& headers); + bool OAuthRequest(const GURL& url, const std::string& method, + const std::string& post_data, URLRequestCallback callback); + bool LoadTokensFromPrefs(); + void OnURLLoaderComplete( + SimpleURLLoaderList::iterator iter, + URLRequestCallback callback, + const std::unique_ptr response_body); + void SetClientIdForTest(const std::string& client_id); + void SetOAuthHostForTest(const std::string& oauth_host); + void SetAPIHostForTest(const std::string& api_host); + + scoped_refptr io_task_runner_; + std::string access_token_; + std::string refresh_token_; + std::string code_challenge_; + std::string code_verifier_; + std::string client_id_; + std::string oauth_host_; + std::string api_host_; + + content::BrowserContext* context_; + scoped_refptr url_loader_factory_; + SimpleURLLoaderList url_loaders_; + base::WeakPtrFactory weak_factory_; + + FRIEND_TEST_ALL_PREFIXES(BinanceAPIBrowserTest, GetOAuthClientURL); + friend class BinanceAPIBrowserTest; + + DISALLOW_COPY_AND_ASSIGN(BinanceService); +}; + +#endif // BRAVE_COMPONENTS_BINANCE_BROWSER_BINANCE_SERVICE_H_ diff --git a/components/binance/browser/binance_service_browsertest.cc b/components/binance/browser/binance_service_browsertest.cc new file mode 100644 index 000000000000..8438c3df47f4 --- /dev/null +++ b/components/binance/browser/binance_service_browsertest.cc @@ -0,0 +1,805 @@ +/* Copyright (c) 2019 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 "base/path_service.h" +#include "base/scoped_observer.h" +#include "brave/browser/binance/binance_service_factory.h" +#include "brave/common/brave_paths.h" +#include "brave/common/pref_names.h" +#include "brave/components/binance/browser/binance_service.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/browser/ui/browser.h" +#include "chrome/test/base/in_process_browser_test.h" +#include "chrome/test/base/ui_test_utils.h" +#include "components/country_codes/country_codes.h" +#include "components/prefs/pref_service.h" +#include "content/public/test/browser_test_utils.h" +#include "net/dns/mock_host_resolver.h" +#include "net/base/url_util.h" +#include "net/test/embedded_test_server/http_request.h" +#include "net/test/embedded_test_server/http_response.h" + +namespace { + +std::unique_ptr HandleRequest( + const net::test_server::HttpRequest& request) { + std::unique_ptr http_response( + new net::test_server::BasicHttpResponse()); + http_response->set_code(net::HTTP_OK); + http_response->set_content_type("text/html"); + std::string request_path = request.GetURL().path(); + if (request_path == oauth_path_access_token) { + http_response->set_content(R"({ + "access_token": "83f2bf51-a2c4-4c2e-b7c4-46cef6a8dba5", + "refresh_token": "fb5587ee-d9cf-4cb5-a586-4aed72cc9bea", + "scope": "read", + "token_type": "bearer", + "expires_in": 30714 + })"); + } else if (request_path == oauth_path_convert_quote) { + http_response->set_content(R"({ + "code": "000000", + "message": null, + "data": { + "quoteId": "b5481fb7f8314bb2baf55aa6d4fcf068", + "quotePrice": "1094.01086957", + "tradeFee": "8", + "railFee": "0", + "totalFee": "8", + "totalAmount": "100649", + "showPrice": "1094.01086957" + } + })"); + } else if (request_path == oauth_path_account_balances) { + http_response->set_content(R"({ + "code": "000000", + "message": null, + "data": [{ + "asset": "BAT", + "free": "2.00000000", + "locked": "0.00000000", + "freeze": "0.00000000", + "withdrawing": "0.00000000" + }] + })"); + } else if (request_path == oauth_path_deposit_info) { + http_response->set_content(R"({ + "code": "000000", + "message": null, + "data": { + "coin": "BTC", + "address": "112tfsHDk6Yk8PbNnTVkv7yPox4aWYYDtW", + "tag": "", + "url": "https://btc.com/112tfsHDk6Yk8PbNnTVkv7yPox4aWYYDtW", + "time": 1566366289000 + }, + "success": true + })"); + } else if (request_path == oauth_path_convert_confirm) { + http_response->set_content(R"({ + "code": "000000", + "message": null, + "data": { + "quoteId": "b5481fb7f8314bb2baf55aa6d4fcf068", + "status": "FAIL", + "orderId": "ab0ab6cfd62240d79e10347fc5000bc4", + "fromAsset": "BNB", + "toAsset": "TRX", + "sourceAmount": 100, + "obtainAmount": 100649, + "tradeFee": 8, + "price": 1094.01086957, + "feeType": 1, + "feeRate": 0.08000000, + "fixFee": 13.00000000 + }, + "success": true + })"); + } else if (request_path == oauth_path_convert_assets) { + http_response->set_content(R"({ + "code":"000000", + "message":null, + "data":[{ + "assetCode":"BTC", + "assetName":"Bitcoin", + "logoUrl":"https://bin.bnbstatic.com/images/20191211/fake.png", + "size":"6", + "order":0, + "freeAsset":"0.00508311", + "subSelector":[{ + "assetCode":"BNB", + "assetName":"BNB", + "logoUrl":"https://bin.bnbstatic.com/images/fake.png", + "size":"2", + "order":1, + "perTimeMinLimit":"0.00200000", + "perTimeMaxLimit":"1.00000000", + "dailyMaxLimit":"10.00000000", + "hadDailyLimit":"0", + "needMarket":true, + "feeType":1, + "feeRate":"0.00050000", + "fixFee":"1.00000000", + "feeCoin":"BTC", + "forexRate":"1.00000000", + "expireTime":30 + }] + }], + "success":true + })"); + } else if (request_path == api_path_ticker_price) { + http_response->set_content(R"({ + "symbol":"BTCUSDT", + "price":"7265.82000000" + })"); + } else if (request_path == api_path_ticker_volume) { + http_response->set_content(R"({ + "symbol":"BTCUSDT", + "volume":"1337" + })"); + } else if (request_path == oauth_path_revoke_token) { + http_response->set_content(R"({ + "code": "000000", + "message": null, + "data": true, + "success": true + })"); + } + return std::move(http_response); +} + +std::unique_ptr HandleRequestUnauthorized( + const net::test_server::HttpRequest& request) { + std::unique_ptr http_response( + new net::test_server::BasicHttpResponse()); + http_response->set_content_type("text/html"); + http_response->set_code(net::HTTP_UNAUTHORIZED); + return std::move(http_response); +} + +std::unique_ptr HandleRequestServerError( + const net::test_server::HttpRequest& request) { + std::unique_ptr http_response( + new net::test_server::BasicHttpResponse()); + http_response->set_content_type("text/html"); + http_response->set_code(net::HTTP_INTERNAL_SERVER_ERROR); + return std::move(http_response); +} + +const char kBinanceAPIExistsScript[] = + "window.domAutomationController.send(!!chrome.binance)"; + +} // namespace + +class BinanceAPIBrowserTest : public InProcessBrowserTest { + public: + BinanceAPIBrowserTest() : + expected_success_(false) { + } + + void SetUpOnMainThread() override { + InProcessBrowserTest::SetUpOnMainThread(); + host_resolver()->AddRule("*", "127.0.0.1"); + + ResetHTTPSServer(base::BindRepeating(&HandleRequest)); + + brave::RegisterPathProvider(); + base::FilePath test_data_dir; + base::PathService::Get(brave::DIR_TEST_DATA, &test_data_dir); + } + + ~BinanceAPIBrowserTest() override { + } + + content::WebContents* contents() { + return browser()->tab_strip_model()->GetActiveWebContents(); + } + + void ResetHTTPSServer( + const net::EmbeddedTestServer::HandleRequestCallback& callback) { + https_server_.reset(new net::EmbeddedTestServer( + net::test_server::EmbeddedTestServer::TYPE_HTTPS)); + https_server_->SetSSLConfig(net::EmbeddedTestServer::CERT_OK); + https_server_->RegisterRequestHandler(callback); + ASSERT_TRUE(https_server_->Start()); + BinanceService* service = GetBinanceService(); + std::string host = https_server_->base_url().host() + ":" + + std::to_string(https_server_->port()); + service->SetAPIHostForTest(host); + service->SetOAuthHostForTest(host); + } + + void OnGetAccessToken(bool unauthorized) { + if (wait_for_request_) { + wait_for_request_->Quit(); + } + ASSERT_EQ(expected_success_, unauthorized); + } + + void WaitForGetAccessToken(bool expected_success) { + if (wait_for_request_) { + return; + } + expected_success_ = expected_success; + wait_for_request_.reset(new base::RunLoop); + wait_for_request_->Run(); + } + + void OnGetConvertQuote(const std::string& quote_id, + const std::string& quote_price, const std::string& total_fee, + const std::string& total_amount) { + if (wait_for_request_) { + wait_for_request_->Quit(); + } + ASSERT_EQ(expected_quote_id_, quote_id); + ASSERT_EQ(expected_quote_price_, quote_price); + ASSERT_EQ(expected_total_fee_, total_fee); + ASSERT_EQ(expected_total_amount_, total_amount); + } + + void WaitForGetConvertQuote(const std::string& expected_quote_id, + const std::string& expected_quote_price, + const std::string& expected_total_fee, + const std::string& expected_total_amount) { + if (wait_for_request_) { + return; + } + + expected_quote_id_ = expected_quote_id; + expected_quote_price_ = expected_quote_price; + expected_total_fee_ = expected_total_fee; + expected_total_amount_ = expected_total_amount; + + wait_for_request_.reset(new base::RunLoop); + wait_for_request_->Run(); + } + + void OnGetAccountBalances(const std::map& balances, + bool success) { + if (wait_for_request_) { + wait_for_request_->Quit(); + } + ASSERT_EQ(expected_balances_, balances); + ASSERT_EQ(expected_success_, success); + } + + void WaitForGetAccountBalances( + const std::map& expected_balances, + bool expected_success) { + if (wait_for_request_) { + return; + } + expected_balances_ = expected_balances; + expected_success_ = expected_success; + wait_for_request_.reset(new base::RunLoop); + wait_for_request_->Run(); + } + + void OnGetDepositInfo(const std::string& address, const std::string& url, + bool success) { + if (wait_for_request_) { + wait_for_request_->Quit(); + } + ASSERT_EQ(expected_address_, address); + ASSERT_EQ(expected_url_, url); + ASSERT_EQ(expected_success_, success); + } + + void WaitForGetDepositInfo( + const std::string& expected_address, const std::string& expected_url, + bool expected_success) { + if (wait_for_request_) { + return; + } + expected_address_ = expected_address; + expected_url_ = expected_url; + expected_success_ = expected_success; + wait_for_request_.reset(new base::RunLoop); + wait_for_request_->Run(); + } + + void OnConfirmConvert(bool success, const std::string& error_message) { + if (wait_for_request_) { + wait_for_request_->Quit(); + } + ASSERT_EQ(expected_success_, success); + ASSERT_EQ(expected_error_message_, error_message); + } + + void WaitForConfirmConvert( + bool expected_success, const std::string& expected_error_message) { + if (wait_for_request_) { + return; + } + expected_success_ = expected_success; + expected_error_message_ = expected_error_message; + wait_for_request_.reset(new base::RunLoop); + wait_for_request_->Run(); + } + + void OnGetConvertAssets( + const std::map>& assets) { + if (wait_for_request_) { + wait_for_request_->Quit(); + } + ASSERT_EQ(expected_assets_with_sub_, assets); + } + + void WaitForGetConvertAssets( + const std::map>& expected_assets) { + if (wait_for_request_) { + return; + } + expected_assets_with_sub_ = expected_assets; + wait_for_request_.reset(new base::RunLoop); + wait_for_request_->Run(); + } + + void OnGetTickerPrice(const std::string& symbol_pair_price) { + if (wait_for_request_) { + wait_for_request_->Quit(); + } + ASSERT_EQ(expected_symbol_pair_price_, symbol_pair_price); + } + + void WaitForGetTickerPrice(const std::string& symbol_pair_price) { + if (wait_for_request_) { + return; + } + expected_symbol_pair_price_ = symbol_pair_price; + wait_for_request_.reset(new base::RunLoop); + wait_for_request_->Run(); + } + + void OnGetTickerVolume(const std::string& symbol_pair_volume) { + if (wait_for_request_) { + wait_for_request_->Quit(); + } + ASSERT_EQ(expected_symbol_pair_volume_, symbol_pair_volume); + } + + void WaitForGetTickerVolume(const std::string& symbol_pair_volume) { + if (wait_for_request_) { + return; + } + expected_symbol_pair_volume_ = symbol_pair_volume; + wait_for_request_.reset(new base::RunLoop); + wait_for_request_->Run(); + } + + void OnRevokeToken(bool success) { + if (wait_for_request_) { + wait_for_request_->Quit(); + } + ASSERT_EQ(expected_success_, success); + } + + void WaitForRevokeToken(bool success) { + if (wait_for_request_) { + return; + } + expected_success_ = success; + wait_for_request_.reset(new base::RunLoop); + wait_for_request_->Run(); + } + + content::WebContents* active_contents() { + return browser()->tab_strip_model()->GetActiveWebContents(); + } + + bool NavigateToNewTabUntilLoadStop() { + ui_test_utils::NavigateToURL(browser(), GURL("chrome://newtab")); + return WaitForLoadStop(active_contents()); + } + + bool NavigateToVersionTabUntilLoadStop() { + ui_test_utils::NavigateToURL(browser(), GURL("chrome://version")); + return WaitForLoadStop(active_contents()); + } + + BinanceService* GetBinanceService() { + BinanceService* service = BinanceServiceFactory::GetInstance() + ->GetForProfile(Profile::FromBrowserContext(browser()->profile())); + EXPECT_TRUE(service); + return service; + } + + private: + net::EmbeddedTestServer* https_server() { return https_server_.get(); } + + bool expected_success_; + std::string expected_quote_id_; + std::string expected_quote_price_; + std::string expected_total_fee_; + std::string expected_total_amount_; + std::string expected_address_; + std::string expected_url_; + std::string expected_error_message_; + std::string expected_symbol_pair_price_; + std::string expected_symbol_pair_volume_; + std::vector expected_assets_; + std::map expected_balances_; + std::map> expected_assets_with_sub_; + + std::unique_ptr wait_for_request_; + std::unique_ptr https_server_; +}; + + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetCodeChallenge) { + std::string verifier = + "FA87A1758E149A8BCD3A6D43DEAFAA013BCE2F132639ADA66C5BF101"; + ASSERT_EQ( + "1vw-WOmdXSW7OHQPgnuMsZjhaQKxi3LO5L7uX0YEtHs", + BinanceService::GetCodeChallenge(verifier)); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetOAuthClientURL) { + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + service->SetClientIdForTest("ultra-fake-id"); + + GURL client_url(service->GetOAuthClientUrl()); + GURL expected_url( + "https://accounts.binance.com/en/oauth/authorize?" + "response_type=code&" + "client_id=ultra-fake-id" + "&redirect_uri=com.brave.binance%3A%2F%2Fauthorization&" + "scope=user%3Aemail%2Cuser%3Aaddress%2Casset%3Abalance%2Casset%3Aocbs&" + "code_challenge=da0KASk6XZX4ksgvIGAa87iwNSVvmWdys2GYh3kjBZw&" + "code_challenge_method=S256"); + // Replace the code_challenge since it is always different + client_url = net::AppendOrReplaceQueryParameter(client_url, "code_challenge", + "ultra-fake-id"); + expected_url = net::AppendOrReplaceQueryParameter(expected_url, + "code_challenge", "ultra-fake-id"); + ASSERT_EQ(expected_url, client_url); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetAccessToken) { + ResetHTTPSServer(base::BindRepeating(&HandleRequest)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetAccessToken("abc123", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetAccessToken, + base::Unretained(this)))); + WaitForGetAccessToken(true); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetAccessTokenUnauthorized) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestUnauthorized)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetAccessToken("abc123", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetAccessToken, + base::Unretained(this)))); + WaitForGetAccessToken(false); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetAccessTokenServerError) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetAccessToken("abc123", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetAccessToken, + base::Unretained(this)))); + WaitForGetAccessToken(false); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetConvertQuote) { + ResetHTTPSServer(base::BindRepeating(&HandleRequest)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetConvertQuote("BTC", "ETH", "1", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetConvertQuote, + base::Unretained(this)))); + WaitForGetConvertQuote("b5481fb7f8314bb2baf55aa6d4fcf068", + "1094.01086957", "8", "100649"); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetConvertQuoteUnauthorized) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestUnauthorized)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetConvertQuote("BTC", "ETH", "1", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetConvertQuote, + base::Unretained(this)))); + WaitForGetConvertQuote("", "", "", ""); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetConvertQuoteServerError) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetConvertQuote("BTC", "ETH", "1", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetConvertQuote, + base::Unretained(this)))); + WaitForGetConvertQuote("", "", "", ""); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetAccountBalances) { + ResetHTTPSServer(base::BindRepeating(&HandleRequest)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetAccountBalances( + base::BindOnce( + &BinanceAPIBrowserTest::OnGetAccountBalances, + base::Unretained(this)))); + WaitForGetAccountBalances( + std::map { + {"BAT", "2.00000000"} + }, true); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetAccountBalancesUnauthorized) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestUnauthorized)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetAccountBalances( + base::BindOnce( + &BinanceAPIBrowserTest::OnGetAccountBalances, + base::Unretained(this)))); + WaitForGetAccountBalances( + std::map(), false); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetAccountBalancesServerError) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetAccountBalances( + base::BindOnce( + &BinanceAPIBrowserTest::OnGetAccountBalances, + base::Unretained(this)))); + WaitForGetAccountBalances( + std::map(), false); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetDepositInfo) { + ResetHTTPSServer(base::BindRepeating(&HandleRequest)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetDepositInfo("BTC", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetDepositInfo, + base::Unretained(this)))); + std::string address = "112tfsHDk6Yk8PbNnTVkv7yPox4aWYYDtW"; + std::string url = "https://btc.com/112tfsHDk6Yk8PbNnTVkv7yPox4aWYYDtW"; + WaitForGetDepositInfo(address, url, true); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetDepositInfoUnauthorized) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestUnauthorized)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetDepositInfo("BTC", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetDepositInfo, + base::Unretained(this)))); + WaitForGetDepositInfo("", "", false); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetDepositInfoServerError) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetDepositInfo("BTC", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetDepositInfo, + base::Unretained(this)))); + WaitForGetDepositInfo("", "", false); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, ConfirmConvert) { + ResetHTTPSServer(base::BindRepeating(&HandleRequest)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->ConfirmConvert("b5481fb7f8314bb2baf55aa6d4fcf068", + base::BindOnce( + &BinanceAPIBrowserTest::OnConfirmConvert, + base::Unretained(this)))); + WaitForConfirmConvert(true, ""); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, ConfirmConvertUnauthorized) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestUnauthorized)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->ConfirmConvert("b5481fb7f8314bb2baf55aa6d4fcf068", + base::BindOnce( + &BinanceAPIBrowserTest::OnConfirmConvert, + base::Unretained(this)))); + WaitForConfirmConvert(false, ""); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, ConfirmConvertServerError) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->ConfirmConvert("b5481fb7f8314bb2baf55aa6d4fcf068", + base::BindOnce( + &BinanceAPIBrowserTest::OnConfirmConvert, + base::Unretained(this)))); + WaitForConfirmConvert(false, ""); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetConvertAssets) { + ResetHTTPSServer(base::BindRepeating(&HandleRequest)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetConvertAssets( + base::BindOnce( + &BinanceAPIBrowserTest::OnGetConvertAssets, + base::Unretained(this)))); + std::vector sub {"BNB"}; + std::map > assets {{"BTC", sub}}; + WaitForGetConvertAssets(assets); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetConvertAssetsUnauthorized) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestUnauthorized)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetConvertAssets( + base::BindOnce( + &BinanceAPIBrowserTest::OnGetConvertAssets, + base::Unretained(this)))); + WaitForGetConvertAssets(std::map >()); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetConvertAssetsServerError) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetConvertAssets( + base::BindOnce( + &BinanceAPIBrowserTest::OnGetConvertAssets, + base::Unretained(this)))); + WaitForGetConvertAssets(std::map >()); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetTickerPrice) { + ResetHTTPSServer(base::BindRepeating(&HandleRequest)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetTickerPrice("BTCUSDT", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetTickerPrice, + base::Unretained(this)))); + WaitForGetTickerPrice("7265.82000000"); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetTickerPriceUnauthorized) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestUnauthorized)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetTickerPrice("BTCUSDT", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetTickerPrice, + base::Unretained(this)))); + WaitForGetTickerPrice("0.00"); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetTickerPriceServerError) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetTickerPrice("BTCUSDT", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetTickerPrice, + base::Unretained(this)))); + WaitForGetTickerPrice("0.00"); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetTickerVolume) { + ResetHTTPSServer(base::BindRepeating(&HandleRequest)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetTickerVolume("BTCUSDT", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetTickerVolume, + base::Unretained(this)))); + WaitForGetTickerVolume("1337"); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetTickerVolumeUnauthorized) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestUnauthorized)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetTickerVolume("BTCUSDT", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetTickerVolume, + base::Unretained(this)))); + WaitForGetTickerVolume("0"); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetTickerVolumeServerError) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->GetTickerVolume("BTCUSDT", + base::BindOnce( + &BinanceAPIBrowserTest::OnGetTickerVolume, + base::Unretained(this)))); + WaitForGetTickerVolume("0"); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, RevokeToken) { + ResetHTTPSServer(base::BindRepeating(&HandleRequest)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->RevokeToken( + base::BindOnce( + &BinanceAPIBrowserTest::OnRevokeToken, + base::Unretained(this)))); + WaitForRevokeToken(true); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, RevokeTokenUnauthorized) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestUnauthorized)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->RevokeToken( + base::BindOnce( + &BinanceAPIBrowserTest::OnRevokeToken, + base::Unretained(this)))); + WaitForRevokeToken(false); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, RevokeTokenServerError) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + auto* service = GetBinanceService(); + ASSERT_TRUE(service->RevokeToken( + base::BindOnce( + &BinanceAPIBrowserTest::OnRevokeToken, + base::Unretained(this)))); + WaitForRevokeToken(false); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, GetBinanceTLD) { + ResetHTTPSServer(base::BindRepeating(&HandleRequestServerError)); + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + const std::string usCode = "US"; + const std::string canadaCode = "CA"; + const int32_t us_id = country_codes::CountryCharsToCountryID( + usCode.at(0), usCode.at(1)); + const int32_t canada_id = country_codes::CountryCharsToCountryID( + canadaCode.at(0), canadaCode.at(1)); + + auto* service = GetBinanceService(); + browser()->profile()->GetPrefs()->SetInteger( + country_codes::kCountryIDAtInstall, us_id); + ASSERT_EQ(service->GetBinanceTLD(), "us"); + + browser()->profile()->GetPrefs()->SetInteger( + country_codes::kCountryIDAtInstall, canada_id); + ASSERT_EQ(service->GetBinanceTLD(), "com"); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, NewTabHasBinanceAPIAccess) { + EXPECT_TRUE(NavigateToNewTabUntilLoadStop()); + bool result = false; + EXPECT_TRUE( + ExecuteScriptAndExtractBool(contents(), kBinanceAPIExistsScript, + &result)); + ASSERT_TRUE(result); +} + +IN_PROC_BROWSER_TEST_F(BinanceAPIBrowserTest, + OtherChromeTabHasBinanceAPIAccess) { + EXPECT_TRUE(NavigateToVersionTabUntilLoadStop()); + bool result = true; + EXPECT_TRUE( + ExecuteScriptAndExtractBool(contents(), kBinanceAPIExistsScript, + &result)); + ASSERT_FALSE(result); +} diff --git a/components/binance/browser/buildflags/BUILD.gn b/components/binance/browser/buildflags/BUILD.gn new file mode 100644 index 000000000000..3b48d4a1415e --- /dev/null +++ b/components/binance/browser/buildflags/BUILD.gn @@ -0,0 +1,9 @@ +import("//build/buildflag_header.gni") +import("//brave/components/binance/browser/buildflags/buildflags.gni") + +buildflag_header("buildflags") { + header = "buildflags.h" + flags = [ + "BINANCE_ENABLED=$binance_enabled", + ] +} diff --git a/components/binance/buildflags.gni b/components/binance/browser/buildflags/buildflags.gni similarity index 54% rename from components/binance/buildflags.gni rename to components/binance/browser/buildflags/buildflags.gni index d04e9cffe999..c08c91b867f2 100644 --- a/components/binance/buildflags.gni +++ b/components/binance/browser/buildflags/buildflags.gni @@ -1,5 +1,5 @@ import("//build/config/features.gni") declare_args() { - binance_enabled = true + binance_enabled = is_mac || is_linux || is_win } diff --git a/components/binance/browser/static_values.h b/components/binance/browser/static_values.h index edf828b2113b..900bd009fa39 100644 --- a/components/binance/browser/static_values.h +++ b/components/binance/browser/static_values.h @@ -1,4 +1,4 @@ -/* Copyright (c) 2019 The Brave Authors. All rights reserved. +/* Copyright (c) 2020 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/. */ diff --git a/components/brave_new_tab_ui/actions/new_tab_actions.ts b/components/brave_new_tab_ui/actions/new_tab_actions.ts index d2330d94cba3..f4bc489bdca6 100644 --- a/components/brave_new_tab_ui/actions/new_tab_actions.ts +++ b/components/brave_new_tab_ui/actions/new_tab_actions.ts @@ -111,3 +111,68 @@ export const setOnlyAnonWallet = (onlyAnonWallet: boolean) => action(types.SET_O export const setBinanceSupported = (supported: boolean) => action(types.SET_BINANCE_SUPPORTED, { supported }) + +export const onBinanceClientUrl = (clientUrl: string) => action(types.ON_BINANCE_CLIENT_URL, { + clientUrl +}) + +export const onValidAuthCode = () => action(types.ON_VALID_AUTH_CODE) + +export const setHideBalance = (hide: boolean) => action(types.SET_HIDE_BALANCE, { + hide +}) + +export const connectToBinance = () => action(types.CONNECT_TO_BINANCE) + +export const disconnectBinance = () => action(types.DISCONNECT_BINANCE) + +export const onBinanceAccountBalances = (balances: Record) => action(types.ON_BINANCE_ACCOUNT_BALANCES, { + balances +}) + +export const onBTCUSDPrice = (price: string) => action(types.ON_BTC_USD_PRICE, { + price +}) + +export const onBTCUSDVolume = (volume: string) => action(types.ON_BTC_USD_VOLUME, { + volume +}) + +export const onAssetBTCPrice = (ticker: string, price: string) => action(types.ON_ASSET_BTC_PRICE, { + ticker, + price +}) + +export const onAssetBTCVolume = (ticker: string, volume: string) => action(types.ON_ASSET_BTC_VOLUME, { + ticker, + volume +}) + +export const onAssetUSDPrice = (ticker: string, price: string) => action(types.ON_ASSET_USD_PRICE, { + ticker, + price +}) + +export const onAssetDepositInfo = (symbol: string, address: string, url: string) => action(types.ON_ASSET_DEPOSIT_INFO, { + symbol, + address, + url +}) + +export const onDepositQRForAsset = (asset: string, imageSrc: string) => action(types.ON_DEPOSIT_QR_FOR_ASSET, { + asset, + imageSrc +}) + +export const onConvertableAssets = (convertAsset: string, assets: string[]) => action(types.ON_CONVERTABLE_ASSETS, { + convertAsset, + assets +}) + +export const setDisconnectInProgress = (inProgress: boolean) => action(types.SET_DISCONNECT_IN_PROGRESS, { + inProgress +}) + +export const setAuthInvalid = (authInvalid: boolean) => action(types.SET_AUTH_INVALID, { + authInvalid +}) diff --git a/components/brave_new_tab_ui/binance-utils.ts b/components/brave_new_tab_ui/binance-utils.ts new file mode 100644 index 000000000000..243f2850d36d --- /dev/null +++ b/components/brave_new_tab_ui/binance-utils.ts @@ -0,0 +1,33 @@ +/* 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/. */ +const qr = require('qr-image') + +export const generateQRData = (url: string, asset: string, qrAction: any) => { + const image = qr.image(url) + try { + let chunks: Array = [] + image + .on('data', (chunk: Uint8Array) => chunks.push(chunk)) + .on('end', () => { + qrAction(asset, `data:image/png;base64,${Buffer.concat(chunks).toString('base64')}`) + }) + } catch (error) { + console.error('Could not create deposit QR', error.toString()) + } +} + +export const getUSDPrice = (accountBTCBalance: string, btcUSDPrice: string) => { + if (!accountBTCBalance || !btcUSDPrice) { + return '0.00' + } + + const btcUSDPriceNumber = parseFloat(btcUSDPrice) + const btcBalanceNumber = parseFloat(accountBTCBalance) + + if (isNaN(btcUSDPriceNumber) || isNaN(btcBalanceNumber)) { + return '0.00' + } + + return (btcUSDPriceNumber * btcBalanceNumber).toFixed(2) +} diff --git a/components/brave_new_tab_ui/brave_new_tab.tsx b/components/brave_new_tab_ui/brave_new_tab.tsx index 7a585d63ea5a..61f24c442f8a 100644 --- a/components/brave_new_tab_ui/brave_new_tab.tsx +++ b/components/brave_new_tab_ui/brave_new_tab.tsx @@ -21,6 +21,8 @@ import 'emptykit.css' // Fonts import '../../ui/webui/resources/fonts/poppins.css' import '../../ui/webui/resources/fonts/muli.css' +import '../../ui/webui/resources/fonts/crypto_fonts.css' +import '../../ui/webui/resources/css/crypto_styles.css' function initialize () { console.timeStamp('loaded') diff --git a/components/brave_new_tab_ui/components/default/binance/assets/hide-icon.tsx b/components/brave_new_tab_ui/components/default/binance/assets/hide-icon.tsx new file mode 100644 index 000000000000..3d47dc6ce9a4 --- /dev/null +++ b/components/brave_new_tab_ui/components/default/binance/assets/hide-icon.tsx @@ -0,0 +1,16 @@ +/* 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/. */ + +import * as React from 'react' + +export default class HideIcon extends React.PureComponent { + + render () { + return ( + + + + ) + } +} diff --git a/components/brave_new_tab_ui/components/default/binance/assets/icons.ts b/components/brave_new_tab_ui/components/default/binance/assets/icons.ts new file mode 100644 index 000000000000..2fcfac8dd28a --- /dev/null +++ b/components/brave_new_tab_ui/components/default/binance/assets/icons.ts @@ -0,0 +1,11 @@ +/* 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/. */ + +import ShowIcon from './show-icon' +import HideIcon from './hide-icon' + +export { + HideIcon, + ShowIcon +} diff --git a/components/brave_new_tab_ui/components/default/binance/assets/party.png b/components/brave_new_tab_ui/components/default/binance/assets/party.png new file mode 100644 index 0000000000000000000000000000000000000000..5e17535295390afc34cb02267a90e2a582c1e413 GIT binary patch literal 6583 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 literal 0 HcmV?d00001 diff --git a/components/brave_new_tab_ui/components/default/binance/assets/qr.png b/components/brave_new_tab_ui/components/default/binance/assets/qr.png new file mode 100644 index 0000000000000000000000000000000000000000..9b2cac2c4cc9c74dea8a71c48043dc609b88ad9b GIT binary patch literal 9033 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%0Dx zaF5(2;cE80qG3DrA2?@|9IC3kG6AILLtcJVf?AEPpEI+vKA-Y#x&8MAZ>tDuVLxPd%vLsDyo6eLd|!~h!QPu?JYF!2s5vfXcl!Ee z*cceB69`tyXS~&fj#DC_)je9`VaX*O{ep^J`&SE_*ce1F!HBlwKx_l%;pEz}=eT0$FX4{VU)w_q=V zV-JRg=U2TgBIqa#WJ$j5nKtzg=i%Db01c5hhw{i#ljLL*fVU~EsX;A9Z<>fkdTV$C zX>9t1S#ybT;VftOXTv-XJNVGh&?aZCj2r?|){`ARsw|cZ2an$8~_e#jNf);SK{7GF4FsETNgc5ZaZ-=)i0n!*QJR&>6O)&>*Mo=C zJJUs@ZOy(FH7UVAf0s=DW=*i(nhVQ#~*_wx^|HlrbPMXWyA@ z6_etJ1dX+nDWmoEm)l3xe~n&A$0(y@5tftCchA*?a--vli^Cl6lE*X<)nqrwWY0;n zg%n$ia+_;ql!|ExEdmeWSw;3oh-B$?_htVcmU<_B_N93*jG;8gwyG@46E%x=>nL%$Bd#}HW8jB!Y)RgrPV^~r&|1_ItOgMFpQKbKwd`PPaAN+Zgg@gHGnGs{RkD7DG7 zs%xl~x5j&!WJ)plJbU|6huZ)xf6!fI&e8Sju-Tx#!8@j#nok@AB|b4jD^Yh=?q~*9 n+3x;ye1BCpEP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR916`%tE1ONa40RR916#xJL00R82@!Q4~JszBm4* zl_gP&K%zE65fO-1qi+&tMsXacMT=Uvtxby-MhMk1gMOqI5mc*4Fh{XwgmRvgi!zlE zro<=zW3d8?zsqAM&Iyo3C*XW-l|O6#?f! zKo=M^CQy7c0LlT3W+JFU#2FH5aG%dTo=LZ4F-chwh9i160fksix2sSbYSOehdr&y4 zw;E)eM?#@0P!;fb;&zPOo20j*vBRwD5-aQsHF}OX0WDi^P&#>!Kc z&zgliHWHm+g;VQG3yxWiY;&-|{Zs(9b1EuW`2i=Kn0h_S1%Y*f@Kkkm^`aBVZVUQr z&+!r85x{=CPws^QxKl1Lzi3d-e>3>V1|be{3d~r0jEV$hfJlM8mGAA&Tzig_O3R!u zd}HXNS^dG0HBNwRHbN-|K0Ep4@h7B%5{qspfRjy%;GFK^+w#Q8FE*?yxXXW>M@qHp zx}MfSPVgmLI1+1R;*Ff!8%`)8E6$Za%{O4jr$svM1WLE{4d|O$XD3U29Q4&*OZQo8 zAs4HQ@C;8nel*`}6}^CY-S4szMrWXD)` zb4Fa?p1+v4Qte1AB>8cnE3EjrP*tz}S^R@Eo#ght2b+?K#1_Lq7oYN3(C?W}KW=Yi zs3}E87w2})rC@!NSDUorvyxhXTysxU-^~wEKk(H-;c95{xkn`4EL5(*T)vQB8&-r` z!}QUoGFbo5q>`Z7@19T literal 0 HcmV?d00001 diff --git a/components/brave_new_tab_ui/components/default/binance/assets/show-icon.tsx b/components/brave_new_tab_ui/components/default/binance/assets/show-icon.tsx new file mode 100644 index 000000000000..8728c7ddeadb --- /dev/null +++ b/components/brave_new_tab_ui/components/default/binance/assets/show-icon.tsx @@ -0,0 +1,16 @@ +/* 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/. */ + +import * as React from 'react' + +export default class ShowIcon extends React.PureComponent { + + render () { + return ( + + + + ) + } +} diff --git a/components/brave_new_tab_ui/components/default/binance/data.ts b/components/brave_new_tab_ui/components/default/binance/data.ts index 453ab39b8ac6..0399741643b4 100644 --- a/components/brave_new_tab_ui/components/default/binance/data.ts +++ b/components/brave_new_tab_ui/components/default/binance/data.ts @@ -64,5 +64,164 @@ export default { 'BUSD', 'TUSD', 'USDT' - ] + ], + cryptoColors: { + '1st': '#ff4946', + '300': '#985f1f', + 'ada': '#016F80', + 'adc': '#3CB0E5', + 'aeon': '#164450', + 'amp': '#048DD2', + 'anc': '#000', + 'atom': '#2E3148', + 'arch': '#002652', + 'ardr': '#002652', + 'aur': '#136c5e', + 'banx': '#225BA6', + 'bat': '#9e1f63', + 'bay': '#584ba1', + 'bc': '#202121', + 'bcc': '#F7931A', + 'bch': '#8dc351', + 'bcn': '#964F51', + 'bft': '#4fc3f7', + 'bnb': '#F3BA2F', + 'brk': '#194fa0', + 'brx': '#a8c300', + 'bsd': '#1186E7', + 'bsv': '#EAB304', + 'bta': '#210094', + 'btc': '#F7931A', + 'btcd': '#2A72DC', + 'bts': '#03A9E0', + 'clam': '#D6AB31', + 'cloak': '#DF3F1E', + 'dao': '#FF3B3B', + 'dash': '#1c75bc', + 'dcr': '#3b7cfb', + 'dct': '#008770', + 'dgb': '#d8a24a', + 'dgx': '#d8a24a', + 'dmd': '#5497b2', + 'doge': '#ba9f33', + 'emc': '#674c8c', + 'eos': '#FFFFFF', + 'erc': '#101e84', + 'etc': '#669073', + 'eth': '#FFFFFF', + 'exp': '#FFAA5C', + 'fc2': '#040405', + 'fct': '#2175bb', + 'flo': '#2175bb', + 'frk': '#0633cd', + 'ftc': '#679ef1', + 'game': '#ed1b24', + 'gdc': '#e9a226', + 'gemz': '#e86060', + 'gld': '#e8be24', + 'gno': '#00a6c4', + 'gnt': '#00d6e3', + 'golos': '#2670b7', + 'grc': '#88a13c', + 'grs': '#648fa0', + 'gusd': '#00DCFA', + 'heat': '#ff5606', + 'icn': '#4c6f8c', + 'ifc': '#ed272d', + 'incnt': '#f2932f', + 'ioc': '#2fa3de', + 'iota': '#000', + 'iost': '#1c1c1c', + 'jbs': '#1a8bcd', + 'kmd': '#326464', + 'kobo': '#80c342', + 'kore': '#df4124', + 'lbc': '#015c47', + 'ldoge': '#ffcc00', + 'lisk': '#1a6896', + 'lsk': '#1a6896', + 'ltc': '#838383', + 'maid': '#5492d6', + 'mco': '#0d3459', + 'mint': '#006835', + 'mkr': '#1abc9c', + 'mona': '#a99364', + 'mrc': '#4279bd', + 'msc': '#1d4983', + 'mtr': '#b92429', + 'mue': '#f5a10e', + 'nbt': '#ffc93d', + 'neo': '#58bf00', + 'neos': '#1d1d1b', + 'neu': '#2983c0', + 'nlg': '#003e7e', + 'nmc': '#6787b7', + 'note': '#42daff', + 'nvc': '#ecab41', + 'nxt': '#008fbb', + 'ok': '#0165a4', + 'omg': '#1a53f0', + 'omni': '#18347e', + 'ong': '#000', + 'ont': '#32a4be', + 'opal': '#7193aa', + 'part': '#05d5a3', + 'piggy': '#f27a7a', + 'pink': '#ed31ca', + 'pivx': '#3b2f4d', + 'pot': '#105b2f', + 'ppc': '#3fa30c', + 'qrk': '#22aabf', + 'qtum': '#2E9AD0', + 'rads': '#924cea', + 'rbies': '#c62436', + 'rbt': '#0d4982', + 'rby': '#d31f26', + 'rdd': '#ed1c24', + 'rep': '#40a2cb', + 'rise': '#43cea2', + 'sar': '#1b72b8', + 'scot': '#3498db', + 'sdc': '#981d2d', + 'sia': '#00cba0', + 'sjcx': '#003366', + 'slg': '#5a6875', + 'sls': '#1eb549', + 'snrg': '#160363', + 'start': '#01aef0', + 'steem': '#1a5099', + 'str': '#08b5e5', + 'strat': '#2398dd', + 'swift': '#428bca', + 'sync': '#008dd2', + 'sys': '#0098da', + 'trig': '#1fbff4', + 'trx': '#c62734', + 'tx': '#1f8bcc', + 'ubq': '#00ec8d', + 'unity': '#ed8527', + 'usdt': '#2ca07a', + 'vior': '#1f52a4', + 'vet': '#15BDFF', + 'vnl': '#404249', + 'vpn': '#589700', + 'vrc': '#418bca', + 'vtc': '#1b5c2e', + 'waves': '#24aad6', + 'xai': '#2ef99f', + 'xbs': '#d3261d', + 'xcp': '#ec1550', + 'xem': '#41bf76', + 'xlm': '#000', + 'xmr': '#ff6600', + 'xpm': '#e5b625', + 'xrp': '#346aa9', + 'xtz': '#a6df00', + 'xvg': '#42afb2', + 'xzc': '#23B852', + 'ybc': '#d6c154', + 'zec': '#e5a93d', + 'zeit': '#acacac', + 'smart': '#fec60d' + } } diff --git a/components/brave_new_tab_ui/components/default/binance/index.tsx b/components/brave_new_tab_ui/components/default/binance/index.tsx index 862790e3ccca..6dc6b1852cb8 100644 --- a/components/brave_new_tab_ui/components/default/binance/index.tsx +++ b/components/brave_new_tab_ui/components/default/binance/index.tsx @@ -3,6 +3,8 @@ * You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react' +const clipboardCopy = require('clipboard-copy') + import createWidget from '../widget/index' import { WidgetWrapper, @@ -23,17 +25,94 @@ import { AssetItems, AssetItem, TLDSwitchWrapper, - TLDSwitch + TLDSwitch, + DisconnectWrapper, + DisconnectTitle, + DisconnectCopy, + DisconnectButton, + DismissAction, + InvalidWrapper, + InvalidTitle, + StyledEmoji, + InvalidCopy, + GenButton, + ListItem, + DetailIcons, + BackArrow, + ListImg, + AssetTicker, + AssetLabel, + AssetQR, + DetailArea, + MemoArea, + MemoInfo, + DetailLabel, + DetailInfo, + ListIcon, + SearchInput, + ListLabel, + BTCSummary, + ListInfo, + TradeLabel, + Balance, + Converted, + BlurIcon, + ConvertInfoWrapper, + ConvertInfoItem, + ConvertValue, + ConvertLabel, + AvailableLabel, + NavigationBar, + NavigationItem, + SelectedView, + TickerLabel, + ConvertButton, + AssetIcon, + QRImage, + CopyButton, + DropdownIcon, + ConnectAction } from './style' +import { + ShowIcon, + HideIcon +} from './assets/icons' import { StyledTitleTab } from '../widgetTitleTab' import currencyData from './data' import BinanceLogo from './assets/binance-logo' -import { CaratDownIcon } from 'brave-ui/components/icons' +import { CaratLeftIcon, CaratDownIcon } from 'brave-ui/components/icons' import { getLocale } from '../../../../common/locale' +import searchIcon from './assets/search-icon.png' +import partyIcon from './assets/party.png' +import qrIcon from './assets/qr.png' +import { getUSDPrice } from '../../../binance-utils' interface State { fiatShowing: boolean currenciesShowing: boolean + selectedView: string + currentDepositSearch: string + currentDepositAsset: string + currentTradeSearch: string + currentTradeAsset: string + currentTradeAmount: string + currentConvertAmount: string + currentConvertFrom: string + currentConvertTo: string + insufficientFunds: boolean + showConvertPreview: boolean + convertSuccess: boolean + convertFailed: boolean + convertError: string + isBuyView: boolean + currentQRAsset: string + convertFromShowing: boolean + convertToShowing: boolean + currentConvertId: string + currentConvertPrice: string + currentConvertFee: string + currentConvertTransAmount: string + currentConvertExpiryTime: number } interface Props { @@ -43,6 +122,22 @@ interface Props { showContent: boolean userTLDAutoSet: boolean userTLD: NewTab.BinanceTLD + userAuthed: boolean + authInProgress: boolean + hideBalance: boolean + btcBalanceValue: string + accountBalances: Record + assetUSDValues: Record + assetBTCValues: Record + btcPrice: string + binanceClientUrl: string + assetDepositInfo: Record + assetDepoitQRCodeSrcs: Record + convertAssets: Record + accountBTCValue: string + accountBTCUSDValue: string + disconnectInProgress: boolean + authInvalid: boolean onShowContent: () => void onBuyCrypto: (coin: string, amount: string, fiat: string) => void onBinanceUserTLD: (userTLD: NewTab.BinanceTLD) => void @@ -50,34 +145,247 @@ interface Props { onSetInitialAmount: (initialAmount: string) => void onSetInitialAsset: (initialAsset: string) => void onSetUserTLDAutoSet: () => void + onSetHideBalance: (hide: boolean) => void + onBinanceAccountBalances: (balances: Record) => void + onBinanceClientUrl: (clientUrl: string) => void + onDisconnectBinance: () => void + onCancelDisconnect: () => void + onConnectBinance: () => void + onValidAuthCode: () => void + onUpdateActions: () => void + onDismissAuthInvalid: () => void } class Binance extends React.PureComponent { private fiatList: string[] private usCurrencies: string[] private comCurrencies: string[] + private currencyNames: Record + private cryptoColors: Record + private convertTimer: any + private refreshInterval: any constructor (props: Props) { super(props) this.state = { fiatShowing: false, - currenciesShowing: false + currenciesShowing: false, + selectedView: 'summary', + currentDepositSearch: '', + currentDepositAsset: '', + currentTradeSearch: '', + currentTradeAsset: '', + currentTradeAmount: '', + currentConvertAmount: '', + currentConvertFrom: 'BTC', + currentConvertTo: 'BNB', + currentConvertId: '', + currentConvertPrice: '', + currentConvertFee: '', + currentConvertTransAmount: '', + insufficientFunds: false, + showConvertPreview: false, + convertSuccess: false, + convertFailed: false, + convertError: '', + isBuyView: true, + currentQRAsset: '', + convertFromShowing: false, + convertToShowing: false, + currentConvertExpiryTime: 30 } + this.cryptoColors = currencyData.cryptoColors this.fiatList = currencyData.fiatList this.usCurrencies = currencyData.usCurrencies this.comCurrencies = currencyData.comCurrencies + this.currencyNames = { + 'BAT': 'Basic Attent...', + 'BTC': 'Bitcoin', + 'ETH': 'Ethereum', + 'XRP': 'Ripple', + 'BNB': 'Binance Coin', + 'BCH': 'Bitcoin Cash', + 'BUSD': 'US Dollar' + } } componentDidMount () { const { userTLDAutoSet } = this.props - // To Do: This avoids errors in the storybook, there should - // be a polyfill at a later date. - if (chrome.hasOwnProperty('binance') && !userTLDAutoSet) { + + if (this.props.userAuthed) { + this.props.onUpdateActions() + this.refreshInterval = setInterval(() => { + this.props.onUpdateActions() + }, 30000) + } + + if (this.props.authInProgress) { + this.checkForOauthCode() + } + + if (!userTLDAutoSet) { chrome.binance.getUserTLD((userTLD: NewTab.BinanceTLD) => { this.props.onBinanceUserTLD(userTLD) this.props.onSetUserTLDAutoSet() }) } + + this.getClientURL() + } + + getClientURL = () => { + chrome.binance.getClientUrl((clientUrl: string) => { + this.props.onBinanceClientUrl(clientUrl) + }) + } + + componentWillUnmount () { + clearInterval(this.refreshInterval) + } + + componentDidUpdate (prevProps: Props) { + if (!prevProps.userAuthed && this.props.userAuthed) { + this.props.onUpdateActions() + } + + if (prevProps.userAuthed && !this.props.userAuthed) { + this.getClientURL() + } + } + + checkForOauthCode = () => { + const params = window.location.search + const urlParams = new URLSearchParams(params) + const authCode = urlParams.get('code') + + if (authCode) { + chrome.binance.getAccessToken(authCode, (success: boolean) => { + if (success) { + this.props.onValidAuthCode() + this.props.onUpdateActions() + } + }) + } + } + + connectBinance = () => { + const { binanceClientUrl } = this.props + window.open(binanceClientUrl, '_self') + this.props.onConnectBinance() + } + + cancelDisconnect = () => { + this.props.onCancelDisconnect() + } + + cancelConvert = () => { + clearInterval(this.convertTimer) + this.setState({ + insufficientFunds: false, + showConvertPreview: false, + convertSuccess: false, + convertFailed: false, + currentConvertAmount: '', + currentConvertFrom: 'BTC', + currentConvertTo: 'BNB', + currentConvertId: '', + currentConvertPrice: '', + currentConvertFee: '', + currentConvertTransAmount: '', + currentConvertExpiryTime: 30 + }) + } + + retryConvert = () => { + clearInterval(this.convertTimer) + this.setState({ + insufficientFunds: false, + showConvertPreview: false, + convertSuccess: false, + convertFailed: false, + convertError: '', + currentConvertId: '', + currentConvertPrice: '', + currentConvertFee: '', + currentConvertTransAmount: '', + currentConvertExpiryTime: 30 + }) + } + + finishDisconnect = () => { + chrome.binance.revokeToken(() => { + this.props.onDisconnectBinance() + this.cancelDisconnect() + }) + } + + renderRoutes = () => { + const { selectedView } = this.state + const { userAuthed } = this.props + + if (userAuthed) { + if (selectedView === 'buy') { + return this.renderBuyView() + } + + if (selectedView === 'convert') { + return this.renderConvertView() + } + + return this.renderAccountView() + } + + return this.renderBuyView() + } + + onSetHideBalance = () => { + this.props.onSetHideBalance( + !this.props.hideBalance + ) + } + + setSelectedView (view: string) { + this.setState({ + selectedView: view + }) + } + + setCurrentDepositAsset (asset: string) { + this.setState({ + currentDepositAsset: asset + }) + + if (!asset) { + this.setState({ + currentDepositSearch: '' + }) + } + } + + setCurrentConvertAsset (asset: string) { + this.setState({ + currentConvertTo: asset, + convertToShowing: false + }) + } + + setIsBuyView (isBuyView: boolean) { + this.setState({ isBuyView }) + } + + processConvert = () => { + const { currentConvertId } = this.state + chrome.binance.confirmConvert(currentConvertId, (success: boolean, message: string) => { + if (success) { + this.props.onUpdateActions() + this.setState({ convertSuccess: true }) + } else { + this.setState({ + convertFailed: true, + convertError: message + }) + } + }) } setInitialAsset (asset: string) { @@ -136,6 +444,172 @@ class Binance extends React.PureComponent { }) } + finishConvert = () => { + this.cancelConvert() + this.setState({ selectedView: 'summary' }) + } + + setCurrentDepositSearch = ({ target }: any) => { + this.setState({ + currentDepositSearch: target.value + }) + } + + setCurrentConvertAmount = ({ target }: any) => { + this.setState({ currentConvertAmount: target.value }) + } + + setCurrentTradeSearch = ({ target }: any) => { + this.setState({ currentTradeSearch: target.value }) + } + + setCurrentTradeAsset = (asset: string) => { + this.setState({ currentTradeAsset: asset }) + } + + shouldShowConvertPreview = () => { + const { + currentConvertFrom, + currentConvertTo, + currentConvertAmount + } = this.state + const { accountBalances } = this.props + + // As there are trading fees we shouldn't proceed even in equal amounts + if (!accountBalances[currentConvertFrom] || + parseFloat(currentConvertAmount) >= parseFloat(accountBalances[currentConvertFrom])) { + this.setState({ insufficientFunds: true }) + return + } + + chrome.binance.getConvertQuote(currentConvertFrom, currentConvertTo, currentConvertAmount, (quote: any) => { + if (!quote.id || !quote.price || !quote.fee || !quote.amount) { + this.setState({ convertFailed: true }) + return + } + + this.setState({ + currentConvertId: quote.id, + currentConvertPrice: quote.price, + currentConvertFee: quote.fee, + currentConvertTransAmount: quote.amount, + showConvertPreview: true + }) + + this.convertTimer = setInterval(() => { + const { currentConvertExpiryTime } = this.state + + if (currentConvertExpiryTime - 1 === 0) { + clearInterval(this.convertTimer) + this.cancelConvert() + return + } + + this.setState({ + currentConvertExpiryTime: (currentConvertExpiryTime - 1) + }) + }, 1000) + }) + } + + setQR = (asset: string) => { + this.setState({ + currentQRAsset: asset + }) + } + + cancelQR = () => { + this.setState({ + currentQRAsset: '' + }) + } + + handleConvertFromChange = () => { + if (this.state.convertFromShowing) { + return + } + + this.setState({ + convertFromShowing: !this.state.convertFromShowing + }) + } + + handleConvertToChange = () => { + if (this.state.convertToShowing) { + return + } + + this.setState({ + convertToShowing: !this.state.convertToShowing + }) + } + + setCurrentConvertFrom = (asset: string) => { + this.setState({ + convertFromShowing: false, + currentConvertFrom: asset + }) + } + + unpersistDropdowns = (event: any) => { + const { + fiatShowing, + convertToShowing, + convertFromShowing, + currenciesShowing + } = this.state + + if (!fiatShowing && !convertToShowing && + !convertFromShowing && !currenciesShowing) { + return + } + + if (!event.target.classList.contains('asset-dropdown')) { + this.setState({ + fiatShowing: false, + convertToShowing: false, + convertFromShowing: false, + currenciesShowing: false + }) + } + } + + copyToClipboard = async (address: string) => { + try { + await clipboardCopy(address) + } catch (e) { + console.log(`Could not copy address ${e.toString()}`) + } + } + + getCurrencyList = () => { + const { accountBalances, userTLD } = this.props + const baseList = userTLD === 'us' ? this.usCurrencies : this.comCurrencies + + if (!accountBalances) { + return baseList + } + + const accounts = Object.keys(accountBalances) + const nonHoldingList = baseList.filter((symbol: string) => { + return !accounts.includes(symbol) + }) + + return accounts.concat(nonHoldingList) + } + + renderIconAsset = (key: string, isDetail: boolean = false) => { + const iconColor = this.cryptoColors[key] || '#fff' + + return ( + + ) + } + renderTitle () { return (
@@ -161,20 +635,535 @@ class Binance extends React.PureComponent { ) } + renderAuthInvalid = () => { + const { onDismissAuthInvalid } = this.props + + return ( + + + {getLocale('binanceWidgetAuthInvalid')} + + + {getLocale('binanceWidgetAuthInvalidCopy')} + + + {getLocale('binanceWidgetDone')} + + + ) + } + + renderDisconnectView = () => { + return ( + + + {getLocale('binanceWidgetDisconnectTitle')} + + + {getLocale('binanceWidgetDisconnectText')} + + + {getLocale('binanceWidgetDisconnectButton')} + + + {getLocale('binanceWidgetCancelText')} + + + ) + } + + renderConvertSuccess = () => { + const { + currentConvertAmount, + currentConvertFrom, + currentConvertTo, + currentConvertTransAmount + } = this.state + + return ( + + + + + + {`${getLocale('binanceWidgetConverted')} ${currentConvertAmount} ${currentConvertFrom} to ${currentConvertTransAmount} ${currentConvertTo}!`} + + + {getLocale('binanceWidgetContinue')} + + + ) + } + + renderInsufficientFundsView = () => { + return ( + + + {getLocale('binanceWidgetUnableToConvert')} + + + {getLocale('binanceWidgetInsufficientFunds')} + + + {getLocale('binanceWidgetRetry')} + + + ) + } + + renderUnableToConvertView = () => { + const { convertError } = this.state + const errorMessage = convertError || getLocale('binanceWidgetConversionFailed') + + return ( + + + {getLocale('binanceWidgetUnableToConvert')} + + + {errorMessage} + + + {getLocale('binanceWidgetRetry')} + + + ) + } + + renderQRView = () => { + const { assetDepoitQRCodeSrcs } = this.props + const imageSrc = assetDepoitQRCodeSrcs[this.state.currentQRAsset] + + return ( + + + + {getLocale('binanceWidgetDone')} + + + ) + } + + formatCryptoBalance = (balance: string) => { + if (!balance) { + return '0.000000' + } + + return parseFloat(balance).toFixed(6) + } + + renderCurrentDepositAsset = () => { + const { currentDepositAsset } = this.state + const { assetDepositInfo } = this.props + const addressInfo = assetDepositInfo[currentDepositAsset] + const address = addressInfo && addressInfo.address + const cleanName = this.currencyNames[currentDepositAsset] + const cleanNameDisplay = cleanName ? `(${cleanName})` : '' + + return ( + <> + + + + + + {this.renderIconAsset(currentDepositAsset.toLowerCase(), true)} + + + {currentDepositAsset} + + + {cleanNameDisplay} + + { + address + ? + + + : null + } + + + + + + {`${currentDepositAsset} ${getLocale('binanceWidgetDepositAddress')}`} + + + { + address + ? address + : getLocale('binanceWidgetAddressUnavailable') + } + + + { + address + ? + {getLocale('binanceWidgetCopy')} + + : null + } + + + + ) + } + + renderDepositView = () => { + const { currencyNames } = this + const { currentDepositSearch, currentDepositAsset } = this.state + const currencyList = this.getCurrencyList() + + if (currentDepositAsset) { + return this.renderCurrentDepositAsset() + } + + return ( + <> + + + + + + + {currencyList.map((asset: string) => { + const cleanName = currencyNames[asset] || asset + const lowerAsset = asset.toLowerCase() + const lowerName = cleanName.toLowerCase() + const lowerSearch = currentDepositSearch.toLowerCase() + const currencyName = currencyNames[asset] || false + const nameString = currencyName ? `(${currencyName})` : '' + + if (lowerAsset.indexOf(lowerSearch) < 0 && + lowerName.indexOf(lowerSearch) < 0 && currentDepositSearch) { + return null + } + + return ( + + + {this.renderIconAsset(asset.toLowerCase())} + + + {`${asset} ${nameString}`} + + + ) + })} + + ) + } + + renderSummaryView = () => { + const { + accountBalances, + btcBalanceValue, + hideBalance, + accountBTCValue, + accountBTCUSDValue, + assetUSDValues + } = this.props + const currencyList = this.getCurrencyList() + + return ( + <> + + + + + {this.formatCryptoBalance(accountBTCValue)} {getLocale('binanceWidgetBTCTickerText')} + + + {`= $${accountBTCUSDValue}`} + + + + + + + { + hideBalance + ? + : + } + + + + + {currencyList.map((asset: string) => { + // Initial migration display + const assetAccountBalance = accountBalances[asset] || '0.00' + const assetUSDValue = assetUSDValues[asset] || '0.00' + const assetBalance = this.formatCryptoBalance(assetAccountBalance) + const price = asset === 'BTC' ? btcBalanceValue : getUSDPrice(assetBalance, assetUSDValue) + + return ( + + + + {this.renderIconAsset(asset.toLowerCase())} + + + {asset} + + + + + {assetBalance} + + + {`= $${price}`} + + + + ) + })} + + ) + } + + renderConvertConfirm = () => { + const { + currentConvertAmount, + currentConvertFrom, + currentConvertTo, + currentConvertFee, + currentConvertTransAmount, + currentConvertExpiryTime + } = this.state + const displayConvertAmount = this.formatCryptoBalance(currentConvertAmount) + const displayConvertFee = this.formatCryptoBalance(currentConvertFee) + const displayReceiveAmount = this.formatCryptoBalance(currentConvertTransAmount) + + return ( + + + {getLocale('binanceWidgetConfirmConversion')} + + + + {getLocale('binanceWidgetConvert')} + {`${displayConvertAmount} ${currentConvertFrom}`} + + + {getLocale('binanceWidgetFee')} + {`${displayConvertFee} ${currentConvertFrom}`} + + + {getLocale('binanceWidgetWillReceive')} + {`${displayReceiveAmount} ${currentConvertTo}`} + + + + + {`${getLocale('binanceWidgetConfirm')} (${currentConvertExpiryTime}s)`} + + + {getLocale('binanceWidgetCancel')} + + + + ) + } + + renderConvertView = () => { + const { accountBalances, convertAssets } = this.props + const { + currentConvertAmount, + currentConvertTo, + currentConvertFrom, + convertFromShowing, + convertToShowing + } = this.state + const convertFromAmount = this.formatCryptoBalance(accountBalances[currentConvertFrom]) + const compatibleCurrencies = convertAssets[currentConvertFrom] + const convertAssetKeys = Object.keys(convertAssets) + + return ( + <> + + {getLocale('binanceWidgetConvert')} + + + {`${getLocale('binanceWidgetAvailable')} ${convertFromAmount} ${currentConvertFrom}`} + + + + + + + {currentConvertFrom} + + + + + + { + convertFromShowing + ? + {convertAssetKeys.map((asset: string, i: number) => { + if (asset === currentConvertFrom) { + return null + } + + return ( + + + {this.renderIconAsset(asset.toLowerCase())} + + {asset} + + ) + })} + + : null + } + + + + + {this.renderIconAsset(currentConvertTo.toLowerCase())} + + {currentConvertTo} + + + + + + { + convertToShowing + ? + {compatibleCurrencies.map((asset: string, i: number) => { + if (asset === currentConvertTo) { + return null + } + + return ( + + + {this.renderIconAsset(asset.toLowerCase())} + + {asset} + + ) + })} + + : null + } + + + + {getLocale('binanceWidgetPreviewConvert')} + + + {getLocale('binanceWidgetCancel')} + + + + ) + } + + renderSelectedView = () => { + const { selectedView } = this.state + + switch (selectedView) { + case 'deposit': + return this.renderDepositView() + case 'summary': + return this.renderSummaryView() + default: + return null + } + } + + renderAccountView = () => { + const { selectedView, currentDepositAsset } = this.state + const hideOverflow = currentDepositAsset && selectedView === 'deposit' + + return ( + <> + + + {getLocale('binanceWidgetSummary')} + + + {getLocale('binanceWidgetDepositLabel')} + + + {getLocale('binanceWidgetConvert')} + + + {getLocale('binanceWidgetBuy')} + + + + {this.renderSelectedView()} + + + ) + } + renderBuyView = () => { const { onBuyCrypto, userTLD, initialAsset, initialFiat, - initialAmount + initialAmount, + userAuthed } = this.props const { fiatShowing, currenciesShowing } = this.state const isUS = userTLD === 'us' - const currencies = isUS ? this.usCurrencies : this.comCurrencies + const currencies = this.getCurrencyList() return ( <> @@ -206,6 +1195,7 @@ class Binance extends React.PureComponent { @@ -226,7 +1216,7 @@ class Binance extends React.PureComponent { return ( {fiat} @@ -238,10 +1228,14 @@ class Binance extends React.PureComponent { } + + {this.renderIconAsset(initialAsset.toLowerCase())} + {initialAsset} @@ -262,6 +1256,9 @@ class Binance extends React.PureComponent { isLast={i === (currencies.length - 1)} onClick={this.setInitialAsset.bind(this, asset)} > + + {this.renderIconAsset(asset.toLowerCase())} + {asset} ) @@ -270,24 +1267,78 @@ class Binance extends React.PureComponent { : null } - + {`${getLocale('binanceWidgetBuy')} ${initialAsset}`} + { + userAuthed + ? + {'Cancel'} + + : null + } + { + !userAuthed && !isUS + ? + {getLocale('binanceWidgetConnect')} + + : null + } ) } + renderIndexView () { + const { + currentQRAsset, + insufficientFunds, + convertFailed, + convertSuccess, + showConvertPreview + } = this.state + const { + authInvalid, + disconnectInProgress + } = this.props + + if (authInvalid) { + return this.renderAuthInvalid() + } else if (currentQRAsset) { + return this.renderQRView() + } else if (insufficientFunds) { + return this.renderInsufficientFundsView() + } else if (convertFailed) { + return this.renderUnableToConvertView() + } else if (convertSuccess) { + return this.renderConvertSuccess() + } else if (showConvertPreview) { + return this.renderConvertConfirm() + } else if (disconnectInProgress) { + return this.renderDisconnectView() + } else { + return false + } + } + render () { - if (!this.props.showContent) { + const { showContent } = this.props + + if (!showContent) { return this.renderTitleTab() } return ( - - {this.renderTitle()} - {this.renderBuyView()} + + { + this.renderIndexView() + ? this.renderIndexView() + : <> + {this.renderTitle()} + {this.renderRoutes()} + + } ) } diff --git a/components/brave_new_tab_ui/components/default/binance/style.ts b/components/brave_new_tab_ui/components/default/binance/style.ts index 25b561496846..89548e32ae43 100644 --- a/components/brave_new_tab_ui/components/default/binance/style.ts +++ b/components/brave_new_tab_ui/components/default/binance/style.ts @@ -7,12 +7,22 @@ import styled from 'styled-components' interface StyleProps { hide?: boolean itemsShowing?: boolean + isActive?: boolean isSmall?: boolean isInTab?: boolean isLast?: boolean disabled?: boolean isFiat?: boolean - isActive?: boolean + active?: boolean + isBTC?: boolean + isAsset?: boolean + isBuy?: boolean + isLeading?: boolean + isDetail?: boolean + hideBalance?: boolean + isFirstView?: boolean + hideOverflow?: boolean + position?: 'left' | 'right' } export const WidgetWrapper = styled<{}, 'div'>('div')` @@ -49,6 +59,23 @@ export const Copy = styled<{}, 'p'>('p')` margin-bottom: 11px; ` +export const GenButton = styled<{}, 'button'>('button')` + font-size: 13px; + font-weight: bold; + border-radius: 20px; + border: 0; + padding: 5px 10px; + cursor: pointer; + background: #2C2C2B; + color: rgba(255, 255, 255, 0.7); +` + +export const DisconnectButton = styled(GenButton)` + background: #AA1212; + color: #fff; + padding: 5px 20px; +` + export const BuyPromptWrapper = styled<{}, 'div'>('div')` margin-bottom: 20px; ` @@ -140,8 +167,8 @@ export const AssetItem = styled('div')` border-bottom: ${p => !p.isLast ? '1px solid rgb(70, 70, 70)' : ''}; ` -export const ActionsWrapper = styled<{}, 'div'>('div')` - margin-bottom: 15px; +export const ActionsWrapper = styled('div')` + margin-bottom: ${p => p.isFirstView ? 25 : 5}px; text-align: center; ` @@ -163,6 +190,19 @@ export const ConnectButton = styled('a')` } ` +export const ConvertButton = styled<{}, 'button'>('button')` + font-size: 13px; + font-weight: bold; + border-radius: 20px; + width: 100%; + background: #D9B227; + border: 0; + padding: 10px 65px; + cursor: pointer; + color: #000; + margin-bottom: -10px; +` + export const BinanceIcon = styled<{}, 'div'>('div')` width: 27px; height: 27px; @@ -198,3 +238,406 @@ export const TLDSwitch = styled('div')` cursor: pointer; color: ${p => p.isActive ? '#F2C101' : '#9D7B01'}; ` + +export const NavigationBar = styled<{}, 'div'>('div')` + height: 30px; + margin-top: 10px; +` + +export const NavigationItem = styled('div')` + float: left; + width: 25%; + font-size: 14px; + font-weight: bold; + cursor: pointer; + text-align: center; + color: ${p => p.isActive ? '#F2C101' : '#9D7B01'}; + margin-left: ${p => { + if (p.isBuy) { + return -13 + } else if (p.isLeading) { + return 5 + } else { + return 0 + } + }}px; +` + +export const SelectedView = styled('div')` + border: 1px solid rgb(70, 70, 70); + overflow-y: ${p => p.hideOverflow ? 'hidden' : 'scroll'}; + height: 170px; + width: 250px; + margin-left: 4px; +` + +export const ListItem = styled<{}, 'div'>('div')` + border-bottom: 1px solid rgb(70, 70, 70); + padding: 10px 5px; + overflow-y: auto; + overflow-x: hidden; +` + +export const ListIcon = styled<{}, 'div'>('div')` + margin-left: 5px; + width: 28px; + margin-top: 6px; + float: left; +` + +export const AssetIcon = styled('span')` + margin-top: ${p => p.isDetail ? 5 : 0}px; +` + +export const ListImg = styled<{}, 'img'>('img')` + width: 20px; + margin-top: -6px; +` + +export const QRImage = styled<{}, 'img'>('img')` + width: 150px; + height: 150px; + display: block; + margin: 0 auto 20px auto; +` + +export const ListLabel = styled<{}, 'div'>('div')` + color: #fff; + cursor: pointer; + margin-top: 5px; +` + +export const SearchInput = styled<{}, 'input'>('input')` + border: none; + color: #fff; + background: inherit; + font-size: 15px; + &:focus { + outline: 0; + } +` + +export const DetailIcons = styled<{}, 'div'>('div')` + float: left; + margin-top: -3px; + margin-right: 10px; +` + +export const AssetTicker = styled<{}, 'span'>('span')` + color: #fff; + font-weight: bold; + margin-right: 3px; + font-size: 15px; +` + +export const TickerLabel = styled<{}, 'span'>('span')` + font-size: 14px; + font-weight bold; + color: #fff; +` + +export const AssetLabel = styled<{}, 'span'>('span')` + color: rgb(70, 70, 70); + display: inline-block; + font-weight: bold; + font-size: 15px; + min-width: 80px; +` + +export const AssetQR = styled<{}, 'div'>('div')` + float: right; + margin-top: -3px; + cursor: pointer; +` + +export const DetailArea = styled<{}, 'div'>('div')` + padding: 5px; + font-weight: bold; +` + +export const MemoArea = styled<{}, 'div'>('div')` + padding: 5px; +` + +export const MemoInfo = styled<{}, 'div'>('div')` + float: left; + max-width: 110px; +` + +export const CopyButton = styled(GenButton)` + float: right; +` + +export const DetailLabel = styled<{}, 'span'>('span')` + color: rgb(70, 70, 70); + font-weight: bold; + display: block; + font-size: 15px; + margin-bottom: 5px; +` + +export const DetailInfo = styled<{}, 'span'>('span')` + color: #fff; + font-weight: bold; + display: block; + font-size: 15px; + margin-bottom: 10px; + width: 180px; + word-wrap: break-word; +` + +export const BackArrow = styled<{}, 'div'>('div')` + width: 20px; + float: left; + cursor: pointer; + margin-right: 5px; +` + +export const ListInfo = styled('div')` + float: ${p => `${p.position}`}; + min-width: ${p => p.isBTC ? 70 : 83}px; + font-size: ${p => p.isAsset ? '16px' : 'inherit'}; + margin-top: ${p => p.isAsset ? '4' : '0'}px; + + ${p => { + if (p.position === 'right') { + const width = p.isBTC ? 33 : 40 + return ` + width: ${width}%; + text-align: left; + ` + } else { + return null + } + }} +` + +export const TradeLabel = styled<{}, 'span'>('span')` + font-weight: bold; + font-size: 14px; + display: block; + color: rgb(70, 70, 70); + margin-bottom: 3px; +` + +export const BTCValueLabel = styled(TradeLabel)` + color: #6DD401; +` + +export const OtherValueLabel = styled(TradeLabel)` + color: #fff; +` + +export const BTCSummary = styled(ListItem)` + padding: 5px 7px; +` + +export const BuySellHeader = styled<{}, 'div'>('div')` + padding: 5px 0px; + height: 45px; + border-bottom: 1px solid rgb(70, 70, 70); +` + +export const AssetInfo = styled<{}, 'div'>('div')` + float: left; + color: #fff; + text-align: left; +` + +export const PairName = styled<{}, 'span'>('span')` + font-weight: bold; + font-size: 14px; + display: block; +` + +export const PairPrice = styled<{}, 'span'>('span')` + font-size: 12px; + display: block; +` + +export const BuySellToggle = styled<{}, 'div'>('div')` + float: right; +` + +export const Switch = styled('div')` + font-size: 14px; + font-weight: bold; + display: inline-block; + border-radius: 8px; + padding: ${p => p.active ? '7' : '6'}px 10px; + background: ${p => p.active ? '#D9B227' : '#000'}; +` + +export const ActionLabel = styled<{}, 'span'>('span')` + color: rgb(70, 70, 70); + display: block; + padding: 5px 0px; + font-weight: bold; +` + +export const BuySellContent = styled<{}, 'div'>('div')` + padding: 5px 0px; + text-align: center; + min-width: 240px; +` + +export const AmountInput = styled<{}, 'div'>('div')` + +` + +export const AmountButton = styled<{}, 'button'>('button')` + font-size: 16px; + font-weight: bold; + border-radius: 4px; + border: 0; + padding: 5px; + cursor: pointer; + background: #2C2C2B; + color: #fff; + width: 13%; + height: 30px; + display: inline-block; +` + +export const AmountTextInput = styled(InputField)` + min-width: unset; + vertical-align: unset; + width: 65%; + margin: 0px 5px; + color: #fff; + border-left: 1px solid rgb(70, 70, 70); +` + +export const PercentWrapper = styled<{}, 'div'>('div')` + margin: 10px 0px; +` + +export const Percent = styled<{}, 'div'>('div')` + padding: 2px 5px; + color: #fff; + border: 1px solid rgb(70, 70, 70); + margin-right: 1px; + border-radius: 3px; + cursor: pointer; + display: inline-block; +` + +export const BuySellButton = styled(ConnectButton)` + padding: 5px; + margin: 5px 0px; + background: ${p => p.isBuy ? '#3BB260' : '#DD5353'}; +` + +export const AvailableLabel = styled<{}, 'span'>('span')` + float: right; + margin-top: -27px; + font-size: 13px; +` + +export const ConvertLabel = styled<{}, 'span'>('span')` + float: left; + width: 45%; + text-align: left; + font-size: 15px; +` + +export const ConvertValue = styled<{}, 'span'>('span')` + font-weight: bold; + float: right; + width: 55%; + text-align: right; + font-size: 15px; +` + +export const ConvertInfoWrapper = styled('div')` + margin: 20px 0; + overflow-y: auto; +` + +export const ConvertInfoItem = styled('div')` + margin: 5px 0; + overflow-y: hidden; + margin-top: ${p => p.isLast ? '15' : '5'}px; +` + +export const StyledEmoji = styled<{}, 'div'>('div')` + margin: 10px 0px; +` + +export const DisconnectWrapper = styled<{}, 'div'>('div')` + padding-top: 35px; + min-height: 250px; + text-align: center; + max-width: 240px; +` + +export const DisconnectTitle = styled(Title)` + font-size: 14px; + max-width: 245px; + margin: 0 auto; + line-height: 18px; +` + +export const DisconnectCopy = styled(Copy)` + color: #fff; + max-width: 220px; + line-height: 17px; + margin: 8px auto 15px auto; +` + +export const InvalidTitle = styled(DisconnectTitle)` + max-width: unset; + margin-bottom: 20px; +` + +export const InvalidCopy = styled(DisconnectCopy)` + max-width: 210px; +` + +export const InvalidWrapper = styled(DisconnectWrapper)` + min-width: 255px; +` + +export const DismissAction = styled<{}, 'span'>('span')` + display: block; + cursor: pointer; + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + margin-top: 20px; + font-weight: bold; +` + +export const ConnectAction = styled(DismissAction)` + color: #ffffff; + margin-bottom: -20px; +` + +export const Balance = styled('span')` + display: block; + font-size: ${p => p.isBTC ? '25' : '14'}px; + font-weight bold; + margin: 5px 0; + color: #fff; + text-align: ${p => p.isBTC ? 'unset' : 'right'}; + margin-right: ${p => p.isBTC ? '0' : '7px'}; + -webkit-filter: blur(${p => p.hideBalance ? 10 : 0}px); +` + +export const Converted = styled('span')` + display: block; + font-size: ${p => p.isBTC ? '16' : '14'}px; + color: rgba(70, 70, 70); + margin-left: ${p => p.isBTC ? 0 : 10}px; + -webkit-filter: blur(${p => p.hideBalance ? 10 : 0}px); +` + +export const BlurIcon = styled<{}, 'div'>('div')` + margin-left: 60%; + margin-top: 25%; + cursor: pointer; + color: rgb(70, 70, 70); +` + +export const DropdownIcon = styled('span')` + margin-right: 10px; +` diff --git a/components/brave_new_tab_ui/components/default/widget/assets/disconnect.tsx b/components/brave_new_tab_ui/components/default/widget/assets/disconnect.tsx new file mode 100644 index 000000000000..60aa4817e036 --- /dev/null +++ b/components/brave_new_tab_ui/components/default/widget/assets/disconnect.tsx @@ -0,0 +1,19 @@ +/* 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/. */ + +import * as React from 'react' + +export default class DisconnectIcon extends React.PureComponent { + + render () { + return ( + + + + + + + ) + } +} diff --git a/components/brave_new_tab_ui/components/default/widget/assets/refresh.tsx b/components/brave_new_tab_ui/components/default/widget/assets/refresh.tsx new file mode 100644 index 000000000000..a57b1722e021 --- /dev/null +++ b/components/brave_new_tab_ui/components/default/widget/assets/refresh.tsx @@ -0,0 +1,16 @@ +/* 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/. */ + +import * as React from 'react' + +export default class RefreshIcon extends React.PureComponent { + + render () { + return ( + + + + ) + } +} diff --git a/components/brave_new_tab_ui/components/default/widget/index.tsx b/components/brave_new_tab_ui/components/default/widget/index.tsx index 77b873e0e4b3..90cca2273c75 100644 --- a/components/brave_new_tab_ui/components/default/widget/index.tsx +++ b/components/brave_new_tab_ui/components/default/widget/index.tsx @@ -17,6 +17,8 @@ export interface WidgetProps { isCryptoTab?: boolean widgetTitle?: string onLearnMore?: () => void + onDisconnect?: () => void + onRefreshData?: () => void } export interface WidgetState { @@ -45,7 +47,18 @@ const createWidget =

(WrappedComponent: React.ComponentType

} render () { - const { menuPosition, hideWidget, textDirection, preventFocus, isCrypto, isCryptoTab, widgetTitle, onLearnMore } = this.props + const { + menuPosition, + hideWidget, + textDirection, + preventFocus, + isCrypto, + isCryptoTab, + widgetTitle, + onLearnMore, + onDisconnect, + onRefreshData + } = this.props const { widgetMenuPersist } = this.state return ( @@ -66,6 +79,8 @@ const createWidget =

(WrappedComponent: React.ComponentType

('a')` } ` -export const StyledWidgetIcon = styled<{}, 'div'>('div')` +interface WidgetIconProps { + isRefresh?: boolean + isBinance?: boolean +} + +export const StyledWidgetIcon = styled('div')` height: 13px; width: 13px; - margin: -7px 15px 0 10px; + margin: ${p => p.isBinance ? '0px 13px 0 12px' : '-7px 15px 0 10px'}; + margin-left: ${p => p.isRefresh ? '13px' : p.isBinance ? '12px' : '10px'}; ` export const StyledSpan = styled<{}, 'span'>('span')` height: 13px; diff --git a/components/brave_new_tab_ui/components/default/widget/widgetMenu.tsx b/components/brave_new_tab_ui/components/default/widget/widgetMenu.tsx index b02c64c2812d..d44efb1b6033 100644 --- a/components/brave_new_tab_ui/components/default/widget/widgetMenu.tsx +++ b/components/brave_new_tab_ui/components/default/widget/widgetMenu.tsx @@ -10,6 +10,8 @@ import { IconButton } from '../../default' import EllipsisIcon from './assets/ellipsis' import HideIcon from './assets/hide' import LearnMoreIcon from './assets/learn-more' +import DisconnectIcon from './assets/disconnect' +import RefreshIcon from './assets/refresh' import { getLocale } from '../../../../common/locale' interface Props { @@ -22,6 +24,8 @@ interface Props { widgetTitle?: string onLearnMore?: () => void onMouseEnter: () => void + onDisconnect?: () => void + onRefreshData?: () => void } interface State { @@ -67,8 +71,22 @@ export default class WidgetMenu extends React.PureComponent { this.closeMenu() } + closeMenuBinance = (action: any) => { + action() + this.closeMenu() + } + render () { - const { menuPosition, textDirection, widgetMenuPersist, widgetTitle, onLearnMore, onMouseEnter } = this.props + const { + menuPosition, + textDirection, + widgetMenuPersist, + widgetTitle, + onLearnMore, + onMouseEnter, + onDisconnect, + onRefreshData + } = this.props const { showMenu } = this.state const hideString = widgetTitle ? `${getLocale('hide')} ${widgetTitle}` : getLocale('hide') @@ -93,13 +111,37 @@ export default class WidgetMenu extends React.PureComponent { { onLearnMore ? - {`${getLocale('learnMore')}`} + {getLocale('learnMore')} : null } + { + onRefreshData + ? + + + + + {getLocale('binanceWidgetRefreshData')} + + + : null + } + { + onDisconnect + ? + + + + + {getLocale('binanceWidgetDisconnectButton')} + + + : null + } } ) diff --git a/components/brave_new_tab_ui/constants/new_tab_types.ts b/components/brave_new_tab_ui/constants/new_tab_types.ts index 37d22675cfeb..306049305d21 100644 --- a/components/brave_new_tab_ui/constants/new_tab_types.ts +++ b/components/brave_new_tab_ui/constants/new_tab_types.ts @@ -32,7 +32,23 @@ export const enum types { SET_INITIAL_FIAT = '@@newtab/SET_INITIAL_FIAT', SET_INITIAL_AMOUNT = '@@newtab/SET_INITIAL_AMOUNT', SET_USER_TLD_AUTO_SET = '@@newtab/SET_USER_TLD_AUTO_SET', - SET_BINANCE_SUPPORTED = '@@newtab/SET_BINANCE_SUPPORTED' + SET_BINANCE_SUPPORTED = '@@newtab/SET_BINANCE_SUPPORTED', + ON_BINANCE_ACCOUNT_BALANCES = '@@newtab/ON_BINANCE_ACCOUNT_BALANCES', + ON_BINANCE_CLIENT_URL = '@@newtab/ON_BINANCE_CLIENT_URL', + ON_VALID_AUTH_CODE = '@@newtab/ON_VALID_AUTH_CODE', + SET_HIDE_BALANCE = '@@newtab/SET_HIDE_BALANCE', + CONNECT_TO_BINANCE = '@@newtab/CONNECT_TO_BINANCE', + DISCONNECT_BINANCE = '@@newtab/DISCONNECT_BINANCE', + ON_BTC_USD_PRICE = '@@newtab/ON_BTC_USD_PRICE', + ON_BTC_USD_VOLUME = '@@newtab/ON_BTC_USD_VOLUME', + ON_ASSET_BTC_VOLUME = '@@newtab/ON_ASSET_BTC_VOLUME', + ON_ASSET_BTC_PRICE = '@@newtab/ON_ASSET_BTC_PRICE', + ON_ASSET_USD_PRICE = '@@newtab/ON_ASSET_USD_PRICE', + ON_ASSET_DEPOSIT_INFO = '@@newtab/ON_ASSET_DEPOSIT_INFO', + ON_DEPOSIT_QR_FOR_ASSET = '@@newtab/ON_DEPOSIT_QR_FOR_ASSET', + ON_CONVERTABLE_ASSETS = '@@newtab/ON_CONVERTABLE_ASSETS', + SET_DISCONNECT_IN_PROGRESS = '@@newtab/SET_DISCONNECT_IN_PROGRESS', + SET_AUTH_INVALID = '@@newtab/SET_AUTH_INVALID' } export type DismissBrandedWallpaperNotificationPayload = { diff --git a/components/brave_new_tab_ui/containers/newTab/index.tsx b/components/brave_new_tab_ui/containers/newTab/index.tsx index 4ca9a5369579..697d8c1e7691 100644 --- a/components/brave_new_tab_ui/containers/newTab/index.tsx +++ b/components/brave_new_tab_ui/containers/newTab/index.tsx @@ -22,6 +22,7 @@ import BrandedWallpaperLogo from '../../components/default/brandedWallpaper/logo import VisibilityTimer from '../../helpers/visibilityTimer' import arrayMove from 'array-move' import { isGridSitePinned } from '../../helpers/newTabUtils' +import { generateQRData } from '../../binance-utils' // Types import { SortEnd } from 'react-sortable-hoc' @@ -220,6 +221,38 @@ class NewTabPage extends React.Component { this.props.saveShowBinance(!showBinance) } + setBinanceBalances = (balances: Record) => { + this.props.actions.onBinanceAccountBalances(balances) + } + + onBinanceClientUrl = (clientUrl: string) => { + this.props.actions.onBinanceClientUrl(clientUrl) + } + + onValidAuthCode = () => { + this.props.actions.onValidAuthCode() + } + + setHideBalance = (hide: boolean) => { + this.props.actions.setHideBalance(hide) + } + + disconnectBinance = () => { + this.props.actions.disconnectBinance() + } + + setDisconnectInProgress = () => { + this.props.actions.setDisconnectInProgress(true) + } + + cancelDisconnect = () => { + this.props.actions.setDisconnectInProgress(false) + } + + connectBinance = () => { + this.props.actions.connectToBinance() + } + buyCrypto = (coin: string, amount: string, fiat: string) => { const { userTLD } = this.props.newTabData.binanceState const refCode = userTLD === 'us' ? '35089877' : '39346846' @@ -236,6 +269,22 @@ class NewTabPage extends React.Component { this.props.actions.onBinanceUserTLD(userTLD) } + setBTCUSDPrice = (price: string) => { + this.props.actions.onBTCUSDPrice(price) + } + + setAssetBTCPrice = (ticker: string, price: string) => { + this.props.actions.onAssetBTCPrice(ticker, price) + } + + setAssetUSDPrice = (ticker: string, price: string) => { + this.props.actions.onAssetUSDPrice(ticker, price) + } + + setAssetDepositInfo = (symbol: string, address: string, url: string) => { + this.props.actions.onAssetDepositInfo(symbol, address, url) + } + disableBrandedWallpaper = () => { this.props.saveBrandedWallpaperOptIn(false) } @@ -302,6 +351,73 @@ class NewTabPage extends React.Component { window.open('https://brave.com/binance/', '_blank') ] + setAssetDepositQRCodeSrc = (asset: string, src: string) => { + this.props.actions.onDepositQRForAsset(asset, src) + } + + setConvertableAssets = (asset: string, assets: string[]) => { + this.props.actions.onConvertableAssets(asset, assets) + } + + updateActions = () => { + this.fetchBalance() + this.getConvertAssets() + } + + getConvertAssets = () => { + chrome.binance.getConvertAssets((assets: any) => { + for (let asset in assets) { + if (assets[asset]) { + this.setConvertableAssets(asset, assets[asset]) + } + } + }) + } + + fetchBalance = () => { + chrome.binance.getAccountBalances((balances: Record, success: boolean) => { + if (!success) { + this.setAuthInvalid() + return + } + + chrome.binance.getTickerPrice('BTCUSDT', (price: string) => { + this.setBTCUSDPrice(price) + this.setBalanceInfo(balances) + }) + }) + } + + setBalanceInfo = (balances: Record) => { + for (let ticker in balances) { + if (ticker !== 'BTC') { + chrome.binance.getTickerPrice(`${ticker}BTC`, (price: string) => { + this.setAssetBTCPrice(ticker, price) + }) + chrome.binance.getTickerPrice(`${ticker}USDT`, (price: string) => { + this.setAssetUSDPrice(ticker, price) + }) + } + chrome.binance.getDepositInfo(ticker, (address: string, url: string) => { + this.setAssetDepositInfo(ticker, address, url) + generateQRData(address, ticker, this.setAssetDepositQRCodeSrc) + }) + } + + setTimeout(() => { + this.setBinanceBalances(balances) + }, 1500) + } + + setAuthInvalid = () => { + this.props.actions.setAuthInvalid(true) + this.props.actions.disconnectBinance() + } + + dismissAuthInvalid = () => { + this.props.actions.setAuthInvalid(false) + } + getCryptoContent () { const { currentStackWidget } = this.props.newTabData @@ -382,23 +498,36 @@ class NewTabPage extends React.Component { renderBinanceWidget (showContent: boolean) { const { newTabData } = this.props const { binanceState, showBinance, textDirection } = newTabData + const menuActions = { onLearnMore: this.learnMoreBinance } if (!showBinance || !binanceState.binanceSupported) { return null } + if (binanceState.userAuthed) { + menuActions['onDisconnect'] = this.setDisconnectInProgress + menuActions['onRefreshData'] = this.updateActions + } + return ( { onSetInitialAsset={this.setInitialAsset} onSetInitialFiat={this.setInitialFiat} onSetUserTLDAutoSet={this.setUserTLDAutoSet} + onUpdateActions={this.updateActions} + onDismissAuthInvalid={this.dismissAuthInvalid} /> ) } diff --git a/components/brave_new_tab_ui/reducers/new_tab_reducer.ts b/components/brave_new_tab_ui/reducers/new_tab_reducer.ts index 300ea3fc8878..1f696780b676 100644 --- a/components/brave_new_tab_ui/reducers/new_tab_reducer.ts +++ b/components/brave_new_tab_ui/reducers/new_tab_reducer.ts @@ -17,6 +17,7 @@ import { registerViewCount } from '../api/brandedWallpaper' import * as preferencesAPI from '../api/preferences' import * as storage from '../storage/new_tab_storage' import { getTotalContributions } from '../rewards-utils' +import { getUSDPrice } from '../binance-utils' const initialState = storage.load() @@ -384,6 +385,201 @@ export const newTabReducer: Reducer = (state: NewTab.S } break + case types.SET_HIDE_BALANCE: + state = { ...state } + state.binanceState.hideBalance = payload.hide + break + + case types.ON_BINANCE_ACCOUNT_BALANCES: + const { balances } = payload + if (!balances) { + break + } + + state = { ...state } + state.binanceState.accountBalances = balances + + let totalBTC = 0.00 + let totalBTCUSDValue = 0.00 + + const btcPrice = state.binanceState.btcPrice + const btcValues = state.binanceState.assetBTCValues + + Object.keys(balances).map((symbol: string) => { + const balance = balances[symbol] + if (symbol === 'BTC') { + totalBTC += parseFloat(balance) + totalBTCUSDValue += parseFloat(getUSDPrice(balance, btcPrice)) + } else { + const totalInBTC = parseFloat(btcValues[symbol]) * parseFloat(balance) + const totalAssetValue = getUSDPrice(totalInBTC.toString(), btcPrice) + totalBTC += totalInBTC + totalBTCUSDValue += parseFloat(totalAssetValue) + } + }) + + state.binanceState = { + ...state.binanceState, + accountBTCValue: totalBTC.toString(), + accountBTCUSDValue: totalBTCUSDValue.toFixed(2).toString() + } + + break + + case types.ON_VALID_AUTH_CODE: + state = { ...state } + state.binanceState.userAuthed = true + state.binanceState.authInProgress = false + break + + case types.DISCONNECT_BINANCE: + state = { ...state } + state.binanceState = { + ...storage.defaultState.binanceState, + binanceSupported: true + } + break + + case types.CONNECT_TO_BINANCE: + state = { ...state } + state.binanceState.authInProgress = true + break + + case types.ON_BINANCE_CLIENT_URL: + state = { ...state } + state.binanceState.binanceClientUrl = payload.clientUrl + break + + case types.ON_BTC_USD_PRICE: + if (!payload.price) { + break + } + + state = { ...state } + + if (!state.binanceState.accountBalances) { + state.binanceState.accountBalances = {} + } + + const accountBTCBalance = state.binanceState.accountBalances['BTC'] || '0.00' + state.binanceState.btcBalanceValue = getUSDPrice(accountBTCBalance, payload.price) + state.binanceState.btcPrice = payload.price + break + + case types.ON_BTC_USD_VOLUME: + if (!payload.volume) { + break + } + + const btcFloatString = Math.floor( + parseFloat(payload.volume) + ).toString() + + state = { ...state } + state.binanceState.btcVolume = btcFloatString + break + + case types.ON_ASSET_BTC_PRICE: + const { ticker, price } = payload + if (!price) { + break + } + + state = { ...state } + if (!state.binanceState.assetBTCValues) { + state.binanceState.assetBTCValues = {} + } + state.binanceState.assetBTCValues[ticker] = price + break + + case types.ON_ASSET_BTC_VOLUME: + if (!payload.volume) { + break + } + + const floatString = Math.floor( + parseFloat(payload.volume) + ).toString() + + state = { ...state } + if (!state.binanceState.assetBTCVolumes) { + state.binanceState.assetBTCVolumes = {} + } + state.binanceState.assetBTCVolumes[payload.ticker] = floatString + break + + case types.ON_ASSET_USD_PRICE: + if (!payload.price) { + break + } + + state = { ...state } + if (!state.binanceState.assetUSDValues) { + state.binanceState.assetUSDValues = {} + } + state.binanceState.assetUSDValues[payload.ticker] = payload.price + break + + case types.ON_ASSET_DEPOSIT_INFO: + const { symbol, address, url } = payload + if (!symbol || !address || !url) { + break + } + + state = { ...state } + if (!state.binanceState.assetDepositInfo) { + state.binanceState.assetDepositInfo = {} + } + state.binanceState.assetDepositInfo[symbol] = { + address, + url + } + break + + case types.ON_DEPOSIT_QR_FOR_ASSET: + const { asset, imageSrc } = payload + if (!asset || !imageSrc) { + break + } + + state = { ...state } + if (!state.binanceState.assetDepoitQRCodeSrcs) { + state.binanceState.assetDepoitQRCodeSrcs = {} + } + state.binanceState.assetDepoitQRCodeSrcs[asset] = imageSrc + break + + case types.ON_CONVERTABLE_ASSETS: + const { convertAsset, assets } = payload + if (!convertAsset || !assets) { + break + } + + state = { ...state } + if (!state.binanceState.convertAssets) { + state.binanceState.convertAssets = {} + } + state.binanceState.convertAssets[convertAsset] = assets + break + + case types.SET_DISCONNECT_IN_PROGRESS: + const { inProgress } = payload + state = { ...state } + state.binanceState = { + ...state.binanceState, + disconnectInProgress: inProgress + } + break + + case types.SET_AUTH_INVALID: + const { authInvalid } = payload + state = { ...state } + state.binanceState = { + ...state.binanceState, + authInvalid + } + break + default: break } diff --git a/components/brave_new_tab_ui/storage/new_tab_storage.ts b/components/brave_new_tab_ui/storage/new_tab_storage.ts index a6c385471f5f..d305e0e3ebd3 100644 --- a/components/brave_new_tab_ui/storage/new_tab_storage.ts +++ b/components/brave_new_tab_ui/storage/new_tab_storage.ts @@ -57,7 +57,25 @@ export const defaultState: NewTab.State = { initialAmount: '', initialAsset: 'BTC', userTLDAutoSet: false, - binanceSupported: false + binanceSupported: false, + hideBalance: true, + binanceClientUrl: '', + userAuthed: false, + authInProgress: false, + btcBalanceValue: '0.00', + accountBalances: {}, + assetBTCValues: {}, + assetBTCVolumes: {}, + assetUSDValues: {}, + btcPrice: '0.00', + btcVolume: '0', + assetDepositInfo: {}, + assetDepoitQRCodeSrcs: {}, + convertAssets: {}, + accountBTCValue: '0.00', + accountBTCUSDValue: '0.00', + disconnectInProgress: false, + authInvalid: false } } diff --git a/components/definitions/chromel.d.ts b/components/definitions/chromel.d.ts index 9436af1d94fc..3b6202a008df 100644 --- a/components/definitions/chromel.d.ts +++ b/components/definitions/chromel.d.ts @@ -155,6 +155,16 @@ declare namespace chrome.braveRewards { declare namespace chrome.binance { const getUserTLD: (callback: (userTLD: string) => void) => {} const isSupportedRegion: (callback: (supported: boolean) => void) => {} + const getClientUrl: (callback: (clientUrl: string) => void) => {} + const getAccessToken: (code: string, callback: (success: boolean) => void) => {} + const getAccountBalances: (callback: (balances: Record, unauthorized: boolean) => void) => {} + const getConvertQuote: (from: string, to: string, amount: string, callback: (quote: any) => void) => {} + const getTickerPrice: (symbolPair: string, callback: (symbolPairValue: string) => void) => {} + const getTickerVolume: (symbolPair: string, callback: (symbolPairVolume: string) => void) => {} + const getDepositInfo: (symbol: string, callback: (depositAddress: string, depositURL: string) => void) => {} + const getConvertAssets: (callback: (supportedAssets: any) => void) => {} + const confirmConvert: (quoteId: string, callback: (success: boolean, message: string) => void) => {} + const revokeToken: (callback: (success: boolean) => void) => {} } declare namespace chrome.rewardsNotifications { diff --git a/components/definitions/newTab.d.ts b/components/definitions/newTab.d.ts index e7817b981910..eb9dc36e7542 100644 --- a/components/definitions/newTab.d.ts +++ b/components/definitions/newTab.d.ts @@ -147,6 +147,24 @@ declare namespace NewTab { initialAsset: string userTLDAutoSet: boolean binanceSupported: boolean + accountBalances: Record + authInProgress: boolean + assetBTCValues: Record + assetUSDValues: Record + assetBTCVolumes: Record + userAuthed: boolean + btcBalanceValue: string + hideBalance: boolean + btcPrice: string + btcVolume: string + binanceClientUrl: string + assetDepositInfo: Record + assetDepoitQRCodeSrcs: Record + convertAssets: Record + accountBTCValue: string + accountBTCUSDValue: string + disconnectInProgress: boolean + authInvalid: boolean } export type BinanceTLD = 'us' | 'com' diff --git a/components/resources/brave_components_strings.grd b/components/resources/brave_components_strings.grd index 300c1e4ba293..4680cde386c9 100644 --- a/components/resources/brave_components_strings.grd +++ b/components/resources/brave_components_strings.grd @@ -742,6 +742,50 @@ Buy Buy Crypto I want to spend... + Purchase and trade with Binance + Enable Binance connection to view Binance account balance and trade crypto. + Enable Binance Connect + No thank you + Equity Value (BTC) + BTC + Deposit + Trade + Help. + Invalid entry. + Validating your credentials... + Are you sure you want to disconnect? + Disconnecting will erase your widget settings. You will need to re-authenticate to reconnect. + Disconnect + Account Disconnected + Configure + Refresh Data + + Connect + You converted + Continue + Unable to Convert + The conversion could not be processed. Please try again. + The amount you entered exceeds the amount you currently have available. + Retry + Done + Copy + Address Not Available + Deposit Address + Search + + Confirm Conversion + Convert + Rate + Fee + You will receive + Confirm + Cancel + Available + I want to convert... + Preview Conversion + Summary + Session Expired + Your Binance access token has expired. Please reconnect to continue using the widget. diff --git a/test/BUILD.gn b/test/BUILD.gn index db3d995c9625..54df3e4b73f7 100644 --- a/test/BUILD.gn +++ b/test/BUILD.gn @@ -2,6 +2,7 @@ import("//brave/build/config.gni") import("//brave/browser/tor/buildflags/buildflags.gni") import("//brave/browser/translate/buildflags/buildflags.gni") import("//brave/components/brave_ads/browser/buildflags/buildflags.gni") +import("//brave/components/binance/browser/buildflags/buildflags.gni") import("//brave/components/brave_referrals/buildflags/buildflags.gni") import("//brave/components/brave_rewards/browser/buildflags/buildflags.gni") import("//brave/components/brave_perf_predictor/browser/buildflags/buildflags.gni") @@ -219,6 +220,12 @@ test("brave_unit_tests") { ] } + if (binance_enabled) { + sources += [ + "//brave/components/binance/browser/binance_json_parser_unittest.cc" + ] + } + if (is_linux) { configs += [ "//brave/build/linux:linux_channel_names", @@ -659,6 +666,12 @@ test("brave_browser_tests") { ] } + if (binance_enabled) { + sources += [ + "//brave/components/binance/browser/binance_service_browsertest.cc", + ] + } + if (brave_rewards_enabled) { sources += [ "//brave/components/brave_rewards/browser/rewards_notification_service_browsertest.cc", diff --git a/test/data/extensions/api_test/braveShields/background.js b/test/data/extensions/api_test/braveShields/background.js index a57fdb2d4963..e557b1c9cf07 100644 --- a/test/data/extensions/api_test/braveShields/background.js +++ b/test/data/extensions/api_test/braveShields/background.js @@ -10,4 +10,11 @@ chrome.test.runTests([ chrome.test.fail(); } }, + function extensionsHaveNoBinanceAccess() { + if (!chrome.binance) { + chrome.test.succeed(); + } else { + chrome.test.fail(); + } + }, ]); diff --git a/test/data/extensions/api_test/notBraveShields/background.js b/test/data/extensions/api_test/notBraveShields/background.js index fa2123933a34..827ee3069db4 100644 --- a/test/data/extensions/api_test/notBraveShields/background.js +++ b/test/data/extensions/api_test/notBraveShields/background.js @@ -17,4 +17,11 @@ chrome.test.runTests([ chrome.test.fail(); } }, + function extensionsHaveNoBinanceAccess() { + if (!chrome.binance) { + chrome.test.succeed(); + } else { + chrome.test.fail(); + } + }, ]); diff --git a/ui/webui/resources/css/crypto_styles.css b/ui/webui/resources/css/crypto_styles.css new file mode 100644 index 000000000000..ba32b66f68ef --- /dev/null +++ b/ui/webui/resources/css/crypto_styles.css @@ -0,0 +1,1275 @@ +.icon-byts::before { + content: "\ea34"; +} + +.icon-stash::before { + content: "\ea35"; +} + +.icon-hex::before { + content: "\ea36"; +} + +.icon-crm::before { + content: "\ea33"; +} + +.icon-fio::before { + content: "\ea32"; +} + +.icon-nut::before { + content: "\ea30"; +} + +.icon-eosdt::before { + content: "\ea31"; +} + +.icon-ong::before { + content: "\ea2f"; +} + +.icon-busd::before { + content: "\ea2c"; +} + +.icon-iq::before { + content: "\ea2d"; +} + +.icon-lst::before { + content: "\ea2e"; +} + +.icon-gas::before { + content: "\ea2b"; +} + +.icon-xns::before { + content: "\ea22"; +} + +.icon-pyn::before { + content: "\ea23"; +} + +.icon-ncash::before { + content: "\ea24"; +} + +.icon-loki::before { + content: "\ea25"; +} + +.icon-knc::before { + content: "\ea26"; +} + +.icon-job::before { + content: "\ea27"; +} + +.icon-chz::before { + content: "\ea28"; +} + +.icon-btu::before { + content: "\ea29"; +} + +.icon-apis::before { + content: "\ea2a"; +} + +.icon-rvn::before { + content: "\ea21"; +} + +.icon-enj::before { + content: "\ea20"; +} + +.icon-iotx::before { + content: "\ea1f"; +} + +.icon-ethplo::before { + content: "\ea1e"; +} + +.icon-aya::before { + content: "\ea1d"; +} + +.icon-yec::before { + content: "\ea1c"; +} + +.icon-ankr::before { + content: "\ea16"; +} + +.icon-erd::before { + content: "\ea17"; +} + +.icon-ftm::before { + content: "\ea18"; +} + +.icon-lto::before { + content: "\ea19"; +} + +.icon-und::before { + content: "\ea1a"; +} + +.icon-vrab::before { + content: "\ea1b"; +} + +.icon-atom::before { + content: "\ea15"; +} + +.icon-bnbmainnet::before { + content: "\e912"; +} + +.icon-eth-ropsten::before { + content: "\e931"; +} + +.icon-ropsten::before { + content: "\e931"; +} + +.icon-btc-testnet::before { + content: "\e918"; +} + +.icon-now-e68::before { + content: "\e9f8"; +} + +.icon-awc-986::before { + content: "\e946"; +} + +.icon-awc::before { + content: "\e946"; +} + +.icon-ont::before { + content: "\ea14"; +} + +.icon-kin::before { + content: "\ea13"; +} + +.icon-vtho::before { + content: "\ea12"; +} + +.icon-vet::before { + content: "\ea03"; +} + +.icon-npxs::before { + content: "\ea0c"; +} + +.icon-r::before { + content: "\ea0b"; +} + +.icon-link::before { + content: "\ea0e"; +} + +.icon-hot::before { + content: "\ea0f"; +} + +.icon-gusd::before { + content: "\ea10"; +} + +.icon-eurs::before { + content: "\ea11"; +} + +.icon-trezor::before { + content: "\ea09"; +} + +.icon-ledger::before { + content: "\ea0a"; +} + +.icon-bth::before { + content: "\ea08"; +} + +.icon-bsv::before { + content: "\ea07"; +} + +.icon-bchsv::before { + content: "\ea06"; +} + +.icon-joys::before { + content: "\ea05"; +} + +.icon-tusd::before { + content: "\ea01"; +} + +.icon-usdc::before { + content: "\ea02"; +} + +.icon-dai::before { + content: "\ea0d"; +} + +.icon-pax::before { + content: "\ea04"; +} + +.icon-aion::before { + content: "\ea00"; +} + +.icon-ela::before { + content: "\e9fe"; +} + +.icon-smart::before { + content: "\e9ff"; +} + +.icon-ely::before { + content: "\e9fd"; +} + +.icon-btcp::before { + content: "\e9f0"; +} + +.icon-ebst::before { + content: "\e9f1"; +} + +.icon-elf::before { + content: "\e9f2"; +} + +.icon-hsr::before { + content: "\e9f3"; +} + +.icon-iost::before { + content: "\e9f4"; +} + +.icon-loom::before { + content: "\e9f5"; +} + +.icon-mkr::before { + content: "\e9f6"; +} + +.icon-nas::before { + content: "\e9f7"; +} + +.icon-now::before { + content: "\e9f8"; +} + +.icon-snm::before { + content: "\e9f9"; +} + +.icon-synx::before { + content: "\e9fa"; +} + +.icon-wan::before { + content: "\e9fb"; +} + +.icon-brd::before { + content: "\e9ea"; +} + +.icon-mana::before { + content: "\e9eb"; +} + +.icon-mer::before { + content: "\e9ec"; +} + +.icon-tix::before { + content: "\e9ed"; +} + +.icon-xel::before { + content: "\e9ee"; +} + +.icon-zen::before { + content: "\e9ef"; +} + +.icon-dkk::before { + content: "\e9e4"; +} + +.icon-gbp::before { + content: "\e9e5"; +} + +.icon-eur::before { + content: "\e9e6"; +} + +.icon-usd::before { + content: "\e9e7"; +} + +.icon-ukg::before { + content: "\e9e8"; +} + +.icon-lrc::before { + content: "\e9e9"; +} + +.icon-powr::before { + content: "\e9e3"; +} + +.icon-eng::before { + content: "\e9d1"; +} + +.icon-ngc::before { + content: "\e9df"; +} + +.icon-srn::before { + content: "\e9e0"; +} + +.icon-wax::before { + content: "\e9e1"; +} + +.icon-dcn::before { + content: "\e9e2"; +} + +.icon-gbyte::before { + content: "\e93c"; +} + +.icon-cnd::before { + content: "\e94c"; +} + +.icon-miota::before { + content: "\e9cf"; +} + +.icon-adt::before { + content: "\e9d0"; +} + +.icon-via::before { + content: "\e9cd"; +} + +.icon-myst::before { + content: "\e9ce"; +} + +.icon-cfi::before { + content: "\e9d2"; +} + +.icon-dsh::before { + content: "\e9d3"; +} + +.icon-xlm::before { + content: "\e9d4"; +} + +.icon-fsbt::before { + content: "\e9d5"; +} + +.icon-atl::before { + content: "\e9d6"; +} + +.icon-ath::before { + content: "\e9d7"; +} + +.icon-arn::before { + content: "\e9d8"; +} + +.icon-arc::before { + content: "\e9d9"; +} + +.icon-apt::before { + content: "\e9da"; +} + +.icon-aidoc::before { + content: "\e9db"; +} + +.icon-amis::before { + content: "\e9dc"; +} + +.icon-adst::before { + content: "\e9dd"; +} + +.icon-300::before { + content: "\e9de"; +} + +.icon-crpt::before { + content: "\e9cc"; +} + +.icon-tip::before { + content: "\e9cb"; +} + +.icon-zil::before { + content: "\e9c0"; +} + +.icon-xnn::before { + content: "\e9c2"; +} + +.icon-viu::before { + content: "\e9c3"; +} + +.icon-veri::before { + content: "\e9c4"; +} + +.icon-sc::before { + content: "\e9c5"; +} + +.icon-rhoc::before { + content: "\e9c6"; +} + +.icon-kcs::before { + content: "\e9c7"; +} + +.icon-icx::before { + content: "\e9c8"; +} + +.icon-ark::before { + content: "\e9c9"; +} + +.icon-ae::before { + content: "\e9ca"; +} + +.icon-nmr::before { + content: "\e9bf"; +} + +.icon-vib::before { + content: "\e9c1"; +} + +.icon-stx::before { + content: "\e9bc"; +} + +.icon-sbtc::before { + content: "\e9bd"; +} + +.icon-rcn::before { + content: "\e9be"; +} + +.icon-1st::before { + content: "\e900"; +} + +.icon-ada::before { + content: "\e901"; +} + +.icon-adc::before { + content: "\e902"; +} + +.icon-adx::before { + content: "\e903"; +} + +.icon-aeon::before { + content: "\e904"; +} + +.icon-amp::before { + content: "\e905"; +} + +.icon-anc::before { + content: "\e906"; +} + +.icon-ant::before { + content: "\e907"; +} + +.icon-arch::before { + content: "\e908"; +} + +.icon-ardr::before { + content: "\e909"; +} + +.icon-aur::before { + content: "\e90a"; +} + +.icon-banx::before { + content: "\e90b"; +} + +.icon-bat::before { + content: "\e90c"; +} + +.icon-bay::before { + content: "\e90d"; +} + +.icon-bc::before { + content: "\e90e"; +} + +.icon-bch::before { + content: "\e90f"; +} + +.icon-bcn::before { + content: "\e910"; +} + +.icon-bft::before { + content: "\e911"; +} + +.icon-bnb::before { + content: "\e912"; +} + +.icon-bnt::before { + content: "\e913"; +} + +.icon-brk::before { + content: "\e914"; +} + +.icon-brx::before { + content: "\e915"; +} + +.icon-bsd::before { + content: "\e916"; +} + +.icon-bta::before { + content: "\e917"; +} + +.icon-btc::before { + content: "\e918"; +} + +.icon-btcd::before { + content: "\e919"; +} + +.icon-btg::before { + content: "\e91a"; +} + +.icon-btm::before { + content: "\e91b"; +} + +.icon-bts::before { + content: "\e91c"; +} + +.icon-clam::before { + content: "\e91d"; +} + +.icon-clo::before { + content: "\e91e"; +} + +.icon-cloak::before { + content: "\e91f"; +} + +.icon-cvc::before { + content: "\e920"; +} + +.icon-dao::before { + content: "\e921"; +} + +.icon-dash::before { + content: "\e922"; +} + +.icon-dcr::before { + content: "\e923"; +} + +.icon-dct::before { + content: "\e924"; +} + +.icon-dent::before { + content: "\e925"; +} + +.icon-dgb::before { + content: "\e926"; +} + +.icon-dgd::before { + content: "\e927"; +} + +.icon-dgx::before { + content: "\e928"; +} + +.icon-dmd::before { + content: "\e929"; +} + +.icon-dnt::before { + content: "\e92a"; +} + +.icon-doge::before { + content: "\e92b"; +} + +.icon-edg::before { + content: "\e92c"; +} + +.icon-emc::before { + content: "\e92d"; +} + +.icon-eos::before { + content: "\e92e"; +} + +.icon-erc::before { + content: "\e92f"; +} + +.icon-etc::before { + content: "\e930"; +} + +.icon-eth::before { + content: "\e931"; +} + +.icon-exp::before { + content: "\e932"; +} + +.icon-fc2::before { + content: "\e933"; +} + +.icon-fcn::before { + content: "\e934"; +} + +.icon-fct::before { + content: "\e935"; +} + +.icon-flo::before { + content: "\e936"; +} + +.icon-frk::before { + content: "\e937"; +} + +.icon-ftc::before { + content: "\e938"; +} + +.icon-fun::before { + content: "\e939"; +} + +.icon-game::before { + content: "\e93a"; +} + +.icon-gbg::before { + content: "\e93b"; +} + +.icon-gdc::before { + content: "\e93d"; +} + +.icon-gemz::before { + content: "\e93e"; +} + +.icon-gld::before { + content: "\e93f"; +} + +.icon-gno::before { + content: "\e940"; +} + +.icon-gnt::before { + content: "\e941"; +} + +.icon-golos::before { + content: "\e942"; +} + +.icon-grc::before { + content: "\e943"; +} + +.icon-grs::before { + content: "\e944"; +} + +.icon-gup::before { + content: "\e945"; +} + +.icon-heat::before { + content: "\e946"; +} + +.icon-hmq::before { + content: "\e947"; +} + +.icon-icn::before { + content: "\e948"; +} + +.icon-ifc::before { + content: "\e949"; +} + +.icon-incnt::before { + content: "\e94a"; +} + +.icon-ioc::before { + content: "\e94b"; +} + +.icon-ixt::before { + content: "\e94d"; +} + +.icon-jbs::before { + content: "\e94e"; +} + +.icon-kmd::before { + content: "\e94f"; +} + +.icon-kore::before { + content: "\e950"; +} + +.icon-lbc::before { + content: "\e951"; +} + +.icon-ldoge::before { + content: "\e952"; +} + +.icon-lsk::before { + content: "\e953"; +} + +.icon-ltc::before { + content: "\e954"; +} + +.icon-lun::before { + content: "\e955"; +} + +.icon-maid::before { + content: "\e956"; +} + +.icon-mco::before { + content: "\e957"; +} + +.icon-mint::before { + content: "\e958"; +} + +.icon-mln::before { + content: "\e959"; +} + +.icon-mona::before { + content: "\e95a"; +} + +.icon-mrc::before { + content: "\e95b"; +} + +.icon-msc::before { + content: "\e95c"; +} + +.icon-msp::before { + content: "\e95d"; +} + +.icon-mtl::before { + content: "\e95e"; +} + +.icon-mtr::before { + content: "\e95f"; +} + +.icon-mue::before { + content: "\e960"; +} + +.icon-nano::before { + content: "\e961"; +} + +.icon-nav::before { + content: "\e962"; +} + +.icon-nbt::before { + content: "\e963"; +} + +.icon-neo::before { + content: "\e964"; +} + +.icon-neos::before { + content: "\e965"; +} + +.icon-neu::before { + content: "\e966"; +} + +.icon-nlg::before { + content: "\e967"; +} + +.icon-nmc::before { + content: "\e968"; +} + +.icon-note::before { + content: "\e969"; +} + +.icon-nvc::before { + content: "\e96a"; +} + +.icon-nxt::before { + content: "\e96b"; +} + +.icon-oax::before { + content: "\e96c"; +} + +.icon-ok::before { + content: "\e96d"; +} + +.icon-omg::before { + content: "\e96e"; +} + +.icon-omni::before { + content: "\e96f"; +} + +.icon-opal::before { + content: "\e970"; +} + +.icon-part::before { + content: "\e971"; +} + +.icon-pay::before { + content: "\e972"; +} + +.icon-piggy::before { + content: "\e973"; +} + +.icon-pink::before { + content: "\e974"; +} + +.icon-pivx::before { + content: "\e975"; +} + +.icon-plbt::before { + content: "\e976"; +} + +.icon-plr::before { + content: "\e977"; +} + +.icon-plu::before { + content: "\e978"; +} + +.icon-pot::before { + content: "\e979"; +} + +.icon-ppc::before { + content: "\e97a"; +} + +.icon-ptoy::before { + content: "\e97b"; +} + +.icon-qcn::before { + content: "\e97c"; +} + +.icon-qrk::before { + content: "\e97d"; +} + +.icon-qtum::before { + content: "\e97e"; +} + +.icon-rads::before { + content: "\e97f"; +} + +.icon-rbies::before { + content: "\e980"; +} + +.icon-rbt::before { + content: "\e981"; +} + +.icon-rby::before { + content: "\e982"; +} + +.icon-rdd::before { + content: "\e983"; +} + +.icon-rep::before { + content: "\e984"; +} + +.icon-rise::before { + content: "\e985"; +} + +.icon-rlc::before { + content: "\e986"; +} + +.icon-salt::before { + content: "\e987"; +} + +.icon-sar::before { + content: "\e988"; +} + +.icon-scot::before { + content: "\e989"; +} + +.icon-sdc::before { + content: "\e98a"; +} + +.icon-sia::before { + content: "\e98b"; +} + +.icon-sjcx::before { + content: "\e98c"; +} + +.icon-slg::before { + content: "\e98d"; +} + +.icon-sls::before { + content: "\e98e"; +} + +.icon-sngls::before { + content: "\e98f"; +} + +.icon-snrg::before { + content: "\e990"; +} + +.icon-snt::before { + content: "\e991"; +} + +.icon-start::before { + content: "\e992"; +} + +.icon-steem::before { + content: "\e993"; +} + +.icon-storj::before { + content: "\e994"; +} + +.icon-str::before { + content: "\e995"; +} + +.icon-strat::before { + content: "\e996"; +} + +.icon-swift::before { + content: "\e997"; +} + +.icon-swt::before { + content: "\e998"; +} + +.icon-sync::before { + content: "\e999"; +} + +.icon-sys::before { + content: "\e99a"; +} + +.icon-time::before { + content: "\e99b"; +} + +.icon-tkn::before { + content: "\e99c"; +} + +.icon-trig::before { + content: "\e99d"; +} + +.icon-trst::before { + content: "\e99e"; +} + +.icon-trx::before { + content: "\e99f"; +} + +.icon-tx::before { + content: "\e9a0"; +} + +.icon-ubq::before { + content: "\e9a1"; +} + +.icon-unity::before { + content: "\e9a2"; +} + +.icon-usdt::before { + content: "\e9a3"; +} + +.icon-ven::before { + content: "\e9a4"; +} + +.icon-vior::before { + content: "\e9a5"; +} + +.icon-vnl::before { + content: "\e9a6"; +} + +.icon-vpn::before { + content: "\e9a7"; +} + +.icon-vrc::before { + content: "\e9a8"; +} + +.icon-vtc::before { + content: "\e9a9"; +} + +.icon-waves::before { + content: "\e9aa"; +} + +.icon-wings::before { + content: "\e9ab"; +} + +.icon-xai::before { + content: "\e9ac"; +} + +.icon-xaur::before { + content: "\e9ad"; +} + +.icon-xbs::before { + content: "\e9ae"; +} + +.icon-xcp::before { + content: "\e9af"; +} + +.icon-xdn::before { + content: "\e9b0"; +} + +.icon-xem::before { + content: "\e9b1"; +} + +.icon-xmr::before { + content: "\e9b2"; +} + +.icon-xmr-testnet::before { + content: "\e9b2"; +} + +.icon-xpm::before { + content: "\e9b3"; +} + +.icon-xrp::before { + content: "\e9b4"; +} + +.icon-xtz::before { + content: "\e9b5"; +} + +.icon-xvg::before { + content: "\e9b6"; +} + +.icon-xzc::before { + content: "\e9b7"; +} + +.icon-ybc::before { + content: "\e9b8"; +} + +.icon-zec::before { + content: "\e9b9"; +} + +.icon-zec-testnet::before { + content: "\e9b9"; +} + +.icon-zeit::before { + content: "\e9ba"; +} + +.icon-zrx::before { + content: "\e9bb"; +} diff --git a/ui/webui/resources/fonts/crypto_fonts.css b/ui/webui/resources/fonts/crypto_fonts.css new file mode 100644 index 000000000000..bca90651c5af --- /dev/null +++ b/ui/webui/resources/fonts/crypto_fonts.css @@ -0,0 +1,17 @@ +@font-face { + font-family: 'coins'; + src: url(./third_party/crypto/coins.ttf); +} + +.crypto-icon { + font-family: 'coins', sans-serif !important; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + display: inline-block; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/ui/webui/resources/fonts/third_party/crypto/coins.ttf b/ui/webui/resources/fonts/third_party/crypto/coins.ttf new file mode 100644 index 0000000000000000000000000000000000000000..73d588b366369f164821fe2bd4fec190bc778e72 GIT binary patch literal 149404 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

JD_T7|^5%#R?jEEC=-}p{C&1XBa_h;p#0x zn2gX1q*!4}?J)b&fAqO|L3nYP)!BYIt~O2%nw9!&74y@7tmL?VsFTT^`$x9ehpI@Jro}lAU5jvS{K) zq(3cDfvJ~sVgQuF2q}HZH%aw5SC4D5x_Uodb4(o{l;aeK&Lt3BTcZ01$}Rap$+fJz zpE;9Jdw;pdfnYGQrF&QG=1`It)%LYh(??_Y9G#w8+X*-2R4Ul;oLrJuJwl{%dAZLe z4dz?QqesK6`k*e?V*2ZQ6SG|5#%2%@ejFLVrtL{B#uL}e zKPUbN=tKjzu8D&3HIV7(w`pdEC04CsfrY$GFZ4^GauV#Jp#7Fn-`y9Ue_ls?<%6*aV0iZhK zD1U8Xkj=-GZ?o%T2u%1)6#0{^;?KzcI(U9#!~-0;5)>>Cz@aAd>}tD~4z7Yz3K;Casb z`mZpbJ%CxZt7!-N%@Qk4MrN9Tg@Cacx5%N94Mu=0!iW#8|KbOXXHpBkl71KOIhm3# zo#-~=VQ+`wLOu_}1~DEf_)EG_I?x6#C78)aLoFmBp zEm69cWrC~}DHJe-3+$$Rv=A0^Ap!n548M^=Hl)kMg=|QhO|(SAEw_gYkpdEhgj_h1 z&){@Cp1~8Mp)A?H(30dFjK#0M;`>7!`=NJD&LEn{QqA_^Zq${6Kk;bu7HX zvdX^*z$~MBgDi?{_6m?>P&_R$0|J@h+a+XDlF3h#Zx^X442EeP%bUGS{IlBbWfC}@ zdA5{DijRGz<1x6|Jy5x{`hj7U>bDpEw(KDS{@ldGMCH!*X;g7+u3enUJkFy&?QB?@B`tAW?!{ZBo zg54Z@^Ks>>AW%BV<89R){R{Vctoj{0ez9e%!=l@{^R=zpWT$ca_J7^H*=5u1-0|vG z7|l?>e_p&F^+GYVnk3BDMMx^N&L;L1_H$*0fqg}^F06T2`2648uRNsO_>k~r^0BG= z>590=%56#l@KcK8evD->e3<2(8Q`fUWlZ?VOv-~@z`f!^rsg!2{mIqb5Z{2Tl7 zr$5aiM~_~we2~3yj`G2u{9GA-@<|vxH3_e?;E5)2@Fy&MoOr#n&N|Wb+)uAkUe`38 zxZ(;jgWBCF``ak{m9S5#b9ReT;D)O>x8=duB>}NImZ{BX&BX-`bQ1Dr?oguMvM6p; zB@dN2jS>#D1FB4&Udiwnb9|fBE*`Dat6Klwyi%ApBIeA$dD`p^FAty zqn*{Afnc?{ke`B|e>8gT`gMmOxn(S@4~KV!!pz~==<~$_!FACHF!&*I5aQYF>M%R% zF0z8jU0QzIZ~0d+*NQ)y;rK~5tOycZacC#swJUBYGV)b&MKgD9vvnQ9YUVU5V%49CL zrM*%;MHtBCv4A7eu(yv)4i0V&BIelP$;s8~y@Rz`F5NTL1$jKRx({4BDmTdlBH$@@ z!^RFC=OC$Y8sm~KT66&}q|uqsCu*H#L=faVwN4j~N(oqe0>$-`h`dkv*jkULWYNW9 z%fsOv2zz7EIvmY5*bQ#>__GF0EV?`r-WU0)@(DlNCa#cc%JuB0Om8R_y~i$8{saY| z+0tCF_xF9MT2J!jJ65*jCJhaQ=z9h zvJl4<;zeyhu8VvIAi>Mh(}?U=&*U`IK@f!tNY=w5@!sC^T3T9iM|*l=50F&of!^8K zn_(c@{^7Z~p6F$AJT70BsZ=({qLIj^YPI!AAb`gPydo}kto0)Pju0p;zqZ7 z+sumd&YfAY&F!XS;qYZUcV2hx&YhQsBMVV_pvN=cKd^G`z~H>wtsJ4dSki2%II;vm?Dvl-Q7deSGBQ!k$tPs{{fZ1 zis#nI!6%m~*`}`Up}}*DOL9N~T+ZK(ofq*Gyp1vd@}j_vMd#3O-hev7YXk)0saFxi zXeFHK4_;$#y??DBmd?7=oVk1guxg^9K0J5r3R6&g#$X6bl6R}cYV(qC3W0zvTfLGL zHW*HP1CGtI<&;Zhn?ny>o8&D9EjFuq2b6MpiFD$DNh9I0c#yGW@3=?BB9MPZ8rl^5+9hp+n)ukY~vA6 z)Jr%8QqDdageXbd=A8~lna+0JI(^YQ<%^JX`D26pwLG1Hh-g9>EaIz~2Yiqp{BR2E zo-rIJQ=F~q1k4-f;&jRQnWrH8?nl4o!tgk)231}VH*uLcdwDA&6+#v1+Rn72^vkZg zQ8}@U_I5DMIY8l)j=TIn;8zL(*NG?B&@OG5=Sc+V3Y@lC7XTnXPG9W`U>8^<*hl#> zK|nU72uHAY63u!RXZ1-5c57t&2cy4CC5jWU)q-kKZ};vyzvt1?XP+z`|7_dw z&wTdyr~O08BV~CbBoD6n&^R|dX&LeyPMJ z>X`Qv4~VCkiu1=c48+;5TvxeVE3B3di#gylghL~du*qEoa*fH@yiN=62FYj|f`g4# zhke9v?SpHFmTkTEyifIRzi>xkof!ftA+|lMH^A*D^KR2=%YC@ZlcsXp!PS#fXUC$^ z*x6H)tAXlxgy!`llap(N-P#ziPQ#(gL*bZ4FaRI`B3^SSbXhPIU1=S%*`T1Y1KY@E z!#>1a0IoG#cdrT`xzVTmhkrk`Zj;F=cxZOtMv{ibN2^6TaYTLKnFWDN zv%%+vU@N^0co*kVB%1fbGC$RM{{5=|l$7;^kl3M3sp-+`{ErqGaK~HXUg3GW(pnsq z+-_+UW;&H(anvnI?$KhgvV6~;xKfVxI*{Co*1|1+pHKBxQ^GsO5XaFYi zKfQ5`UH-fShJedE|i=<-F!4&bu>*cQ5PZ?Z-&JED&Uts46lJNHGK`f0zYl~P17IX8(?8R1|b;ZOD~AmRofo271H7q z*Y2w1j;J4BFyNzfVF9nrq1OYc=a=17{u zy%D!3{k={k-VP6m5pmk zu(5Zzv9oxe_;==*U2YH9i$(*W>w1EYs_}r)>UPB~aL2L00;Xh7Oh)x^9u0SgUEN~L z>Ma4&&ire0XCPDx+Tw!HB_(DOx2g3KP_C<= zI9@+-bN$A8O$69fJ;CkuUoQ8%0xNw1<7$ep@>Jb04E;$s1%im>2>iO?TnY{d0|H8C zXjEv?0EUTpCK7*phH4o?Q9<+i$Z14j~_gfdvdI@Wl?# zrseHFE3c?ILW_?p5PJXv_}P1*$vv4?fA8n)`2WnkI`wwc2`+=35r2cY+9Tko zC_cnRO&I$niY;XXuO%>HotqfFEjaL}$@H(t<_6yop5EWe#V4b*9 zmPqc`$>lyeJ$UI13F?8StRsXtl^!9PzJ>!>pA2v=P5ezV9yf(W&Cs&8mEP8TVLqKs z%FDw+uPDo%vfR@unC;fI%awG+=8Dp2rlsHBy0IlVAC2jQ%H;O0u9f!ms`V>IzhD7K zh{LLG=gkggMKDKZGmQv#uwYDZ7>p*Nn*j|CsC-r|R|Ix%s_*=c)CWAFalNKZasi!B zqcz(79(OksgwwsftGC`%aTiPmz-qcpj)*Zh$k<5C-d%LGmfN>G*@s|YX0%HdQ&zbL zsHzFZH^q?K6J`jQ7-qkB0iz7Vm~T5BUgmRR!+Z{sqa4MY0GS^6+#1@Vu8@L+kl1yq zqZrlkcxvyJE$;vG~IL8HwvRl8ksGblCR;BZ{P%6_i$6535~g#^FY#a^qDe z1+|fZw}*%qT$&C)9~A(F{aM*=x3|X;3nZHz_IO21ZNtNN4-70k@h|`Khg-Uy=^h{N z`sR0LcJH1MpWU$VOe_-Z2?XT;!i*5lEDDk6uED`kVMO_3@vdJmX{4uTh;?jTIk%nG zdrbwzeA|SQHhf2cmFJ#&R@|h#rTiBLa#VS%``PE7 zqp@OwWbU63>*~wE!^*;obOlDtnx+jvRX)@N#W|Vl4DdJ_q#z(qbG%*{sBTFtyr8sg z;6YTiybe4t66Oi`jF*8-g>ECDBB~w=M?`{Q`iHYAvVk%ZcgPj!gAwTm|H7wRs;t=F zjX+Nh`0C~R+B^CMlh5B%_Pcy;zr3(b92yiIX>+-6xaf(R&yBzvH5T>7Hp9GWc=*WB z5c|(yVW?Ly0}f)Lb!bTV98M+La*6h+GvIZls)fqe9Xi{du-A!59P-?G>?)T-w|dY# zJZUtaE9KfdQ?6uNrsZMfU)k--+4TQfI`}Ay!P=7ze{NiPdl~4hi~DE>VVN;=@N zNa!SYqOMfgF{H_QmGR(fFO?3!rMB9OYM{81G6-{}zb1(+tmO66w*!=%%N?!cP%Yk~9-XvVUdRLH`> zHpbRFJ?=K6Q|ODvL*^cs*8|qp0O#LeR}@GrmQYqx&DszT#Sx7n;0DE<@fZTbOsGl> zDr;elXf`sP#x|FRs;C=4@zKa2cmjey7640cawQ4M0q;pcbeYa+L?$p|1P0n*^qB<;yn5g?1W$4Xr}#99c%?6JW5Qg8#aZ*A2AB-+}R3>CfM7Ox{c7y=4t zCZz=p!wcw~COt11`-5bWG&-n52;|H{$X*d9d4P_HxNr!hjJekA$T${0^A{^95ElZX z5A8uO%KlLdhAlx`^CW663~)wgU)&zDg*(M?$q_PmSPpULjA|mqi_m1my!#gB zT^Vb!CSczou9{ep;&u6P9qa?RLCJWH{~=tYFsj_re#m#|^1qv4&hqs1ZQb2A0C;?0 z&6=%$G##H)z9})mrz|*Fs_$|m0yim_E?mLRHyXxAM=wOUQpkgp@6Yetb?K_?YMNtM z(-g`;u`Jd=;H82oL!Lo2ss2+g5xqGn>*5PR2ql&z;VG-osVq%d7gLpmU&G1}h7JaQ zU@V(SHn)}50lL7yj11EN@!)n_VW4ZYJA%Pt>&|?^=L-bdTmaT4L2GySXAd0MzO&ZV ztvAGCv$0rvFu0~#t#)*G?JpK+$=^m+w_VYQ*Bc5iYss~>_4l0z7#*iZ6P3MgcQ80p zsf>>8+xy)3xF|;B@xK19t_z2UW@mi0i<8^g{-+{0Mc_%HZ{?T2Bwa$(7G0$V?VDJ%9+ZWS46S zIzEh$lMqsa);R8i6(a|z2s`h0gkaDKlgxJVS5Lah!ClO1F&l}=!e(IdOM87@s=d`YC< z8s{~y-dI!f=G#hKEf8O0VVOl=dN95KVSEDNn(@Z43r9pJy8-^G6n+X_PzxhhgWhgP zVI6}-jPglXred`nACBScLL4Z>b`te^r-{wzj9O?57;85>^<;YLGVrevs)G0sB{u?Y z+-h04nLToHsia}bf7=28V?{VCP-y`cJ#9NmtcfB$X$7FiS-e1iHRFpoR45Z`dItU= z+&cxU|@iWl?@216psB6vH(;STv6ObxJ{1yz%DURbThxxN~j~l+Oi^ocHJ?m=rrOt0=#wb)Ue#9-{~n zNthhLF$Rypgcu!+p{F{uiSa~Yc?vP#v7<#WF4Gk-vZb(KeDYIoPmV^AoM;bt<+?12 zVpm_JFS4GeC_c!1{uR+RQG4qazaSa2QCxD5f%!W|AfgIzJ) z)MgM{40Z$UA+&5;IHY_n*8||*o}Rz#t#V@d8SxnEVGD$#@N)thurCzy86CP{sDvl# zu#}930=)suueI6Pnprjl`=D1x^BHs~pz>?}ylf6O|FBc8Hl-1#aTCV!4-hrO0_=}5 zwgbo>A7-Co|IB{D{wTPFR$)rmD_kYqDLf9GjTeMh1x56V6~tFsEAD|EJ|}~aTVQ;t z#FNaJP@@^*P7%Fm$(4-CWY&}Qkqo4o;nd*NJC`2y!myJMZ8cjkk<^#~`h%y~DW}K> zk~5jO4%9yg=ibAbUQh9iDM6j0Hc7t ztx?N(&{S$E+P#%}K`0e`-s`VekEobIm7!R`T6AN{6=qm&srmM@lDCXVoz+Gq)U)Yl zD3w_6G?`?*M(eXlqi7XsYUH(4Ax~inWRbKv*=fXu1V> zg7A-7FLohr3(n%21*-^>;=vDxd?)nCL8C)n$b>S+4S33(0#td(j1DpVXvHqdjC_{h zL?R5Y5#b_b&>S4NMIX(rPORXYg?k-QU>Un7noB7U}qFK<%QeFP^pLLUy)lmZL)@oecPfsK*J zk-#pdjYhbJ5^3-VAg1<{6jNY}*_pa?wTs?FBaR9t%I!{+iF?p!#l!SK%2E)(7}Q6 zx7g!h{R}np#3gthzPt}2Y^f2e2K=Byq6boSu|xDMJ|Og3m^W*M&!B%=2&G=D{WQ8d zqSo{0Ji8PcQl8Hi`?iYot*l}zzCVfkug!Z`)_ZYFcZ)v_poV*VgbuKmIYmc+5QLx24 z7kFPpzd;4C>!_JE;;rY?Q#GQ5!Xhoa`C!S20VN9OVV9l9YhUoQ>u5(KNK`BWP-ORfAld72%JE9&?p@199WAQtd(m`qd;Oi1l`5$B?$nd&H2uy+~@mq!6&)Yzwlg|*t|>g76YYx%aBWwTthASlDhBrZ|LbA<@+ud zKTobAz|r(?= zr8H%+mOU4%S{*DIEU;xDj|D$u?NRulr66%9zoK^7Z1p$&vFYumcbeYCkklb?tcAr{ z3A(sBb{4yk-NL8`kbw`}C%IGn*XDMJ`v$w~$ec3OaWfs53_ z>syN3kzYn89hbY@UIJ=Dxr?dWvb%(R`r^{kEx0WlvQmQ5l@fH2C?yy7=(r1vR4Iuh zbbufVl!R~9eV(Gqw`DAUO6|18Ay_zU!BfF-_T*j#$8kF6?PgTcb~l}&y;M>LcTSjJ zB7avHf5Ec~fL~BOV4)Qu7LUN$!9>CQi7&>u;|zrF6j`tCW(;9SO-)Jy5+ucm`qC{* zF17Y!fkXOk_M)1-n{wsbEbZ3 z*}(d`ZctvaY;W&c$?ma@6`m+tf4;AGPtRU9v!$?Oz2)F`i}H0_iOuHs&3UG8Qhp0? z%=475ue%ROhqvAbq5=P-`bT+)cCG+65krGudxdhbCned@9e0Pwl z1hPvE8O^Pj{z&Lu!qh|AvMWH|k|10ln;P2T)An~MDdmH?QpbPTw`XD46<#KhXQKfC_$%fT03aDDZbTiCJCHOe;j$Te5fQ}I!5 z#^wUP%P>hmq3C0WE{Q9@_-gRw!`HLcw0G;|jQOiyec^>qQK&wYMzr8{QHcLQEOCgF z(1g4&PlGn=#b&-;1DdWE%k)M%&BDhlY6{rZDCAmJ zBoZOz`v=*Z8)jzCPXN_+xV2cjbaHH~=lSiSL;^Ti1(Jbi7wy-=upl}B+qIjqrd9>8 z4d=K9^l81|64nw}#KMGpY=B4JFbY{|LkBv@GkB%MAWSkpat*swgy?X1`)oVV~T6BpRg~A53{Q9X) zR$!WCGKCBxnOH6ABbN>IZwUk)c95Oc_F`c&5VUV}Bj)w|s?D3`=dWpPb1$^tvMwD6 z=2mvE-%!2xTNpQhIA!QMduQ z5l=*eL$LH0Th~Y!W>y-QS`qI28jB&&8Cv#g34WpJ$z<3Sog^;SsR`fp7D2h_AXz(XXi3oF1XNQw27;QOaBaG2YXL8SAJM&evVytc`HPydZUKq zr$!MT(|D45GZ2Z*@Pv-@k^oTy5-^ihvvB#VXJJ`rW@fwcMxa={c=_@x zYBjn|h$wJDu{AVGV?}6M58r_&#Fx=34`7sV`c9QD6N#?}nXe0!QSn%;%i3y3i9+|N zk_@$~)s{ic@^6@>YM1~aj!-2_JS7<~^5KOb8Xk8^l@_V#sYw@eBZ+^#Me9wgW{Wi9 z6Ux~@71v98EJc(fSYEK(^mZM)9ll{N0W?5k&|BlwC|AN=8U)^9@Kh!6^+q?b=vb=Q zS|y89=h7+PJPXcV21Cl>%S*6IM`CL(Z0`-YqndHnj>*EI_f{nc{+%(a0B|X~Pb=wI zdAn$bwoBAl&GwFhdS^Os{e|#7gA3Aqoy)L*8rHoditp&$KQ<+tj)>XsXRP=6BOt<9 z>Op3l@_<4cd=BD#tU5;&iaV{dq(Aa}tDtpST|t92fJKi+md!DX5EYtVS#oD+xJ5AQ z3l*txo8bowKP>?5LFY4j3QoCV<2N&7#zS=Q1nfG9w>Co?mIIa%tV*GC2Z_U^j)8H? zK9MDrt-y>%1IKyB8A9BsU#wqa50>JIwzfpP#P-sbj+7ybY+@Js3@%rnU%3gt=BVS; zQ$`7=*yq&iIEXkQRAt9}MoB!%vukTR@wIqc8|5v80B0XUQe>pOCiVI3Hsxn$oH~Ge zmDiBUhH=8eu09G^+;lDGt4r|y&`8Nnpi}%|5CCx?Mbz(S-+fE`ym%A#Fkb*W4qu^( zeg4G#?DNVWu(-PE#9raS1t9)_n{CL{weanBW243euiQafXxi{szxmBa)~k^PxYhR3D7$P@ACTPTu&)1(`PFyyX+veV zJD`gJ?!Ze_B#@lWW~P(D;m%HYy@BWX_8oUD+$J2kgI#+@RtMEbEPR1<3cbE~TW7T` z;X?$~iIfEa^Ds4>SI7r|d1keG0{Oyum@XV~m^3Y^hoPn|O&6mc;&@qz`uM9X5^G`@ z5ts(!HR*|Su`tyE#`vP@fo!`;7+iV(MH#Qj_cV(VbIQNlL-Aw)bnQUyf{`)={tkyu zxAhK--~P{Rty3yQ6SI;1g^Q+Y#p00Dr4feq8N8N{D6bsYy6rSuXVnIJ%}JO2x|coo zxJU1^d0}-P5ACaVJM0jpW}H#`3Ir0Iw)kywv=Be13+E9 z{-sX*OZTa6$I(}xMqecy5kOp>f*|720#!KU(Wa^?$eZPA3K$@S9P;!g_@+L@cCwvN zf!%xE7qtngymqM;a>@EwMo4{71jkhe7yNK1n4auyx^9KCqVI`bgfT z(fE8d(XNKuCO3W6JtYIma#HuJa#z4dQmy{mReLjtAwemHzi!TD*RH zgyd$Fre!rykL?j#zM^HY^?<#LWbz5!`|^Ma`gJ%Y++^{=L|4%11MWyFJ(mJ}1AnBu z2=^E8K@@L{?A|?~B{fjw-A(5={R7)CfUZd3en2(27MJ)IPL7jHskD}#t*_mX8URul zSD?$F>9`dKbc{q;rlF}wvv6u|87R4T%tTw!b5xnIRcVH)M6rD&^pyYg-{x~agl5-7=mr1M*^1%xz{@; z*4{W+x}{?*_pt@BA|*NMxTI6HHA}aMWlZ`CJQPv|z9kI_w_zjI2mh9#giybvzJnG81SMxakBJ-%SAz%zN<<@OR@mD-R_mMG!~3Kik4ibIu8;)i5>h(kyiutgfa#hEH3 z642k1E-#(=D@fY3q}u?`qQ|~Tr^61r$pJJ)MCo?0wUj^yt_hlS@K5m4gZa}!5wb%Z z&=Od$Rrhr|lkl&LyTJZ8zyt+DP_I??<1UlV;&8xan*A-rkdQIPAdy-Y1;_lWN^^6C zy^gcdWe`%uNF0cL$t8^=0*&xX#;^YZ=VIfyCuTE7T?k3zc4!Ye@_sPk7N6A<%!I6d zqtP7nz?KULTmGVSIjmHH;pmDQ0Xii*EZDco#$y)H>klE~E3&XSu(Z|HvvX?iu*Vz- zA?yie5hgC^$qdU6YGDHPMB#Ktv=)Z5Oe$C-nGx@vjET1?0`I4ulx*EK_7rr zf!~IV0j~z;3U2_+5O9oGMneJMG2sx^Mko04fXi*ka6n*=uo?V2Es9S0=)HEzjU=YD zqCh?2q4h(UJ|W;1^h#8tMh|S^C?S`w!a2-}<`J0R&0tm~B8I&g0fhvB$(iu8Q*B!O_3fP_*iFw9ru5N(SdaJJnA zEm+Po;E>R3G+)&v19NXNxydk!FmD zbgct(5->Z|eAXHaaKYF76VE3TL?-{h;=zrc9I(Y)b_-=dDm>#x*r7N&!nT09pYqTn zicJs!|Il_M!Nv|M-|V4^$1y(i&W)xb;Sx_nDJcatl*PoL+T(|f9B@%>A!=ZLKc%F5 za15VZ{L&u!2QGsB$Fu1K_W0yRcfp#@4igf79oc#l6b-ZwbH+bn&KQJ+|2X`Mronq* zz=MS0A5@jA)Kh*R=y#eho~WV57Kz@`$EcVz`#+?UV2h_ zjfHTM-CjW$-PQO%{^E(dUnHJ`Y{Z@i?#$i5n%aP+3TPLf@A%u`g+R^Kxv(MR2Xd_h zn_7BVvZA9&>7?5rXrvd%puIXhWdr+`I%^?BL+vhvvJxbBTltFdWpfr|aCW@AXI_?7 z#u)4Bue-uPr|`JIbNw*~*K4v>&?D z2N(YHq95;5{!!V*9%Oet4>QlVzjFDHWRx0v)T&ku7IWV0%k*CFYWHRG@F0CaKUH`OnUSIFcDtW^TyJk$r6 zR=)dQ^|Dw&)N{|MPBlm3&-lHhC?WYC2Y_qTtX$`W!5;wwn^YZif?JVRPmU8@RVnIX zF&jH|lNR2@Dle**X=#g9PkNHQk8t39jqZfI80G%3)WA_{jc5oy#R?^M#ea$?!R2;w9jTsE${yt{py-0eWGD)( z)nG9hdbB2>QouRiW<(E#jW}KowgOP-Q0a*ag~ch#0hR;B1CK_$CNB;ppC315LFcxpAb7jrH+TuleA<5N~W4B!@$`x zJ{77$U^5Ik!OW4?pUvUig=t`TAR3Y(8Ob_Q_vFzO>9cUAo~mXM6_m)QCuZB zoSm^)B-_`2Wp7Wi@0h6Zcn6XRZ)#}hhQ7Yw&~|ndVOhrzJG(MF`{?vkF`dnhc)fx) z7+hJ%hg;`Ye`du@a^)q6Y4-oj3H54h>H+Qk2I}=|!nAOQ@TBl{L^t~*BJZUe^oc+S z7l{I`q_AM5DkcJ>QgD^zV0-c^EfOigQu?$sy}D*p$&gAr0B}e*s9)ZwXiIMR*q~Zi z9ESPwFjAk%my#iruuh3M8RLC{^F6AthA7!0G2?4gdXO$nHiOV|HJ*#92>y%=0y~n~ zHJqMKI;L}qhqojnK;+jZkP*MFPQ{5}3p^+Ft<}m=?F{Q>x^(&E?+!$GyGXiK=+ssMzS2>|EFtL|5z=0thp%RRYHz6#+R@fTc@)sp5V zn!n_^TqdChnj|lzS~j(XJY1Gi&jiT;vwp_ZO;^<%#8Y2#v7OaUqUM5EXtW@e0PeK> z{zl=rjy@yvc}?XK)Y(RzqmpW6)DM;3LP!bY}h) z@j*`8X~7_v5bG7%bB#}u(lFcyy%R#U5W+i>PMzo>qK*V?E{b={(}#LO@K9H$2uvne zm0{IxgjO0-2&bjK4nZzZxX{|!C%MyZb@IY({7#xx95z65+kv}Ergef4>I#QyVLTet z7K>b(Aff@>+SF?#zm_Yucd<#I=+;X2=rn4g)19mkPg2I?#S5nJ7^l>>wVm2 zk2)Mt*lt?k+2wXYHh^i#4%p)e@LTAK_L$S*#zTw-ugxAXnXNQ;!gNjaWZm!_fIi+z z6D*1et2z^I$7OnPRKJESQ}xh2K~Q3*T4MlOB0z2WK-dFU2XMPobWuWPSvMMqfmFM# z_8=g|vE(-z0GLSbTRL9Rf*a7yS(JuKfQ*fT5#E59&5#K(p#eI=8`C?vVMS;(`fR@; zr6^7UAw5WMY7bgzX{gtDlXjaC@n(qSr@Gb%K$^3LZ6w3c0!<5|2^}U__-jL~0hsY$ zVCLo@%;(s{S@0#w&8MEF1UiHkr+FI*z%XIDr$&XZ+|l<+dOc5oH1IwouX@OBbi!-2 zi^-bqr<1r}uZ4#zsvawJm>&`sql0_77fctG+!HYvMf4-+W+6$6Ie6c}6*M5T`QR7C z02Lp>Yf@DpmKM||lo%c_s5(UEma&Mjm?g+_sL7SLd6hEsF$@rQ#K`k*+(yl1G&>;k zg4DnUha$+bs6CKpBU_keFpAsmG1~)(_pJviwh0k^P%1nC>skatM+YE77#fM{TAm*K z0#y~H3?`Ss;x-se0=#dG9vcRO$;QP~8sXcN1UHlQ2MWvWl^I0J$VDR{AhgMd0p+(s zIA`Lnibw+oOSoJz526caTr`q2I0Z;4cu!y?3Gh{O0T|2znISs4hokVKJ;0oa+$To{ zkBj;z14b}yRBOU_sRx3cv`{V$LDphOFMjVGwlU(h{Yf?C8;AdHu{6pmG-)VfEXYdj{zkC*f6y^ABLQgxXcFBkWKpybXgLq zqTk`8gK!pm!q$`B7&U^vkFG5&JX%kSgj7rjY7I#DgQYp3%G4aLK6&5CyizB3@nR69 zkv$YxAjp*VgSR9Mb0@HN_BMS0(HmeR!|f_C1yu-M8FL20SJiF9Am8U_ppBp)Q+&!- zcVO5C#({#HU^3+ANf&@w7}+4o1xF1f)JPsU1?Q{8JV3q!X}8Mdl-x-`ODu}>(ZpuW z4OI8&4SL}QjMg~9hRomux`tV^0!tL({MfBiQzJkO-qYURQ;Wr_Ccy*>~A#~byc$^(TTuC$~7?nc$%XOqCdItlwfi)J_BlwiA>)Ip91j}A ziGyfmlJ8;EE-3})!_S)!K3!!lWbK~MvTkbPYaOV=-ePVb#(C4^XL3j^1)T} zt1vagjc?1SuD0ftgng1;9eMu z>)e*BJV~>VN^iuySZfQ>=v+GO7-k?;sWie7kuW!x-I2=yHanZ!*^-0jAFQ2Ism+z^e|ib{S1-vx!O{U8%gd3nUVQn*7k>$yte0MXiEyz#_$J51 zx=8(LKx4gm5kX`9`ORY-8tcuAKJw;8OQ5k1ys1KCy?GJ08EEct)VNf{H*2{=D>!72Ls>EDy$ z)y4bkJ7l47+7|+Mbsbc9q*7NK@3ke$SK(H>G(^)}8nmSBcm%mCc}_14jdd^lnND5j zocRD~UeCPsJOyw0aAC}f5MIG47}6X10IUZ>DxI)EiwDfFV7t&^FvmfFf?fhC&iM z0k%LV@~QxZMFc~~w7?gPi`8o~JAh<_f(cpTNg&Fz8Ob|m z$*p)AGRBPN_q777S(1`4U9*vRi&ilFE$vCSyWm6NAaA6CLc|DJpv|D;?Li9zm>;@O zDqF&0`YV=spe5z0`w#B^= z6vnl%w4{X%raTi^7~;t(bIObtlY95!t*90FDhREM`o~*iMWlXFY~HDW#6sPS_Ia7{ zSmbDdwF;PUn=MQ%Kh8K1-$GAJ3*HGVFr>fybMXAkqV-mPj{;fsd8L-Dw=7RqKO{Q>3eoK8Dz*&YhntAUo> zEb!`$1*7uJ*w4n<(Uz7Yot^#_#cFj+6o%NF0NwG*E5j2L7o{^N&N}t@l%(JVVHbqD zB>YH1{_JnM5vcx;G<~t@HCS``q4NVoH^^@l;?BjDM|;k#ke zDSPxKG94+4@1uUuFY%C)PQf0lajgkuKujhwuAF)T6ZNSlR7SG_o+~cN7}FS-PE7M) z>qS_m&?H|av7bJJwv!k)^0?^4Yk6dR;lYCuL&LCEE6%WzV7KVr(1S!J5rvmq5eVCifWVB!eV1rnzRKbYI zn7FEdT!tY~QpQwC`|3@Qz9JRBk|8slYEmfLorP$$;Iz%nw6@M3KMmg(!BDxVf1lam z0CFgZs z>>OzhYs54NtxdTSG%?+Jc_&n_*IGl?&ObF{@q9P!UCWcw9&SWHUhKScohc?o3x%jF zX1aAxC?K|;r9qW*ur!R)p4cX#iGbKDPS1`hV-a`R{$f8Mdna~!*ypIUW2pkDW3aB$+A>PqUPMnaWAyHfq zd>uB-4;w*R0bouB$h+Vt7lB_~3s5?iHSGfZ{AknP10}X86<1kfRRK|f{|vgi$H_&? z$rPugy>(_xH5UvMf+fCM&OYZ=3jx`qa;|ZerE{u$Z(L1@F-!!M7(Z0G;jk8*1Nc735O?7XASx=Je3yk+NXBfMf`ON) zmRrl-J^4lvr}{=zLYJ;Yw%kR4Gr57WuROo>Rm$}K5kM`0xhXda$cz2nFXL&v40{yC z9?4`z)#CFP2?bx+OwRG>lqmmEa3$(4WT4>Du+lsW^Ui&kck=MPZDR3i9Qet+i!E~N z*iril*>j@V)yB>IrBxgpuY=0#zcj$$)^2Y`efz1uc&2|SugQYa`(#+3%R(*Gbn2P{h)d>-bS_T#-4XGeX7+#N~XRlx+8QYV?XI%}wS zsEeW#ssL|U#qMfIQB*B!nc#{sNU4MO(kzCl4H;s-6A%+XLZD6SIHGSB5~at=?0hg) zk%iAjBq?JyK~@g?NM|V#Z_DIz&t!~Tb^T|LkNqssQ3?y;5 zty=BxpO`=(#07-{(|p(NwREjq*=6zCN&W<$<2lHno&%z0t?3@rQU%zgm1?|hgUlPd zu0i5dpxz5Obs+9Qr7x`%k6fh-i`#k^X(U)oNfUm9xGdcv)}TRXmhRMv4wD|g^>HQr zX7#Mrw)tcNc;9ZfN2(=~^KGqXRq5=iWb)LrEU$N&U{Gic+wC!zGv$)fP)dC8;22aVP@2E6vuIU;*!3+??3lervp06dr5WGUC#2~ zbIv{I{M+|~QK79p{sGMZAzWcQgg9kMw?|9u@n$`4o|+{4*vH!_HDMpHpD0hV(OcQ- z|Bj5M#4VhETGcjQvxutyCxrgDsY#wYg?ITe^yirFOx;05fPtBvwoX)dZ}E`3=nV7| z$vgm&a{6FL)AtKl61-~P3^URhQm4|n#_Frxglm#oB)J1n|Ekv!%a_uNypy>%-ePyF zchqn7qP9*gme@=|2GscQasll|rSmWGV~Md8*3h=L){Skgt;*LhEl4giarIor9t?%L zf*~j&y#@S1X8`fBU3~JmBj%9HL2%cWSJD&dHKFbhS=eG0haDN_rk2(DAB4l{ zxF_I6MSscf$$8nmxv6xj+f6D&)Ir=6WCf*lrP5p}Ih#+8rNHnyV-5nB&{Q{N|KLF*(*BRU-v6CP`Fbk8D zlQ&OJj(68;=K-0|ggqehe;pWN>1Ao8@Jr_cE_p+w5ha#gFPOb%Fyqh??gBLIFlspf zWgb{}Y~!$^o|~J^=6k(xm`ON;m5>2?D^N$EWo?1(3mbq&!>a*gGGLgY>4anbm-Yec z95z1S(!Dtk1PYMmT66j3Stu((GcLoKdG2HQgCGD?2`ucI{0m_n6N*YqidrDdnF7jj3SzeFqYIM3TPs!qa-e;J^hXPtx|%Vrh45S+n*~2LBGNU9)BL zS{$c=rnYwNX1tkjY`^T}1@^bOl1|`T?Bwre{!} z?PQ8mzT?eLB%D;AU{HBqX>hQFlZOY-jkya59Tb}`l{!cZX_7i&T!?^bzJfcpbMVJ< zBpP%DO*xqLNI};IEuXOQ&6KN8v@pRs8;F;?zCYCiivZSJ?cR`1`HHn#(U(eZ=&ttS z&GOX~{XpHsK41d#A^B4g7KMePo5Q1uBK+kL+XhC@LR`tSmp`KX0iY@`BWR)p2A{uI z&Sf5@%KG0subhVfi|_lQ^7rrM{!zM%z@I0vR;GX#O_-K$RS{!Qhk@)?haSm#wJX{T zN#l)Mji{@fq>3*1kW@#LHfCYUQRS4 zoY~yt^JU;ZmCXi+vd(C9ARLBdO_HKsZ?Lty`|Nz)soZ&`wd4zilj%UGFENqrn9G-- zt`Kzk*ME*Sd^bG5xfiNBv17-X@~8Yw-k2kXvjcP`;13Qej4zRCoOe0dqjao zQxq+RyNp(m4O#MmK+eL3;Hl$<`V-LzmRWMJzqMGz!_a;rtdL>h3Lij8h3nzUgN?m- z@`z8Ced?Z0l_ME|v2qrO0YF~Ho6`hrb+vU*iBdXHKdrTk28ASKqS=gJ%&2XT3AAZ% zNKe2AZBrH_d~p5Bm2>whmqw#|mM=fES}D&X(y`Uzg3zI*lA6k>abcWJ>?Ar{%ffTH zVk&vT^z^D~rLuvi^!cVMl|HOQ>V)aAcV#ne!ohERLpfBLS~hiICJp4^ays1>8;m!q zU300F&umd0Hi!HB_qMlZs#h-)DwWH|)-Idgmw=HG5cgVR1BszV&zY&DQ`D0jV(UI| z_UE>>XL$eU2Ecn4brf$a)1Wt29eowew}7nMWL#FaqWz~ItI{=4q7G0XJe)eth(9^B z`hnP#-53ft0$3q>LIB>bcYJ)!bza)s!@S9Uv-^+_edsg5is9;!t(!M*8$M|Bz;qJU zwch=%tL<*1f!GAEu<{4FjC*S8y@P|pL+_iMw1+!?02O7~ZnLxpZ1%FvZux9S`7;)N zDbQ}=mk=D-3UAME;yrr^vtCd)3IyJrx=RoZ;+?wtbx-KN3q@m9t`R1^pEwo@nu`PO z3W)fxr}sSV*B3i~vF9;hIWJE@eku!uauC_&wDZCn&S5F{Ke-P_(8VGPK#W)60tZ>S z8xbhzwec6CfB{dpvJ$JMPo-tgyzrhoo`1HK%C$cI>iEt>oh$RDXyokCk(uSAquZm= z*4#LP>%C>=%K7<~E8mhztsYk{#g0p48?g-+@%34Q;VM1ermGBw8VPXpdSR`ozd)C6{D-!2bm`R>UQpHx2QDavLZ!E!wRg{Q z#pp=;cr-rvqf(qcU)tg6jz@xW7N$d%tQ?HQyFKh4vQdGafzx&!QFS$al{HFE(FE{0 zB54Rygz$H&Vt|d(6^Ncte=Gmy)=Hp`6473b_`Yv~@B17$1`kk~D2^~(-?98G3X!WY z6IAy|bV>N#Z~g#(cue4mj$HHUk6p-}^x^FB{`)5shKQ-;^{jjS*Lcp`Q7?4m zV$LAv3|`jGs*Rc~KKS(Uh4-;ApH)s&(Nz#qWax&gT`n5KLu>>`*O~ualsw zp-QB9(SV7erGKfQk?gNwfy9Y|$e;eJ<*QVv#(`} z5I^D_$6F3Y5ypRJr__RAH#QNd#bz5!Dw{TwurNac_>Dl3so%SM`Sr@zS1#Lo)+F|G zu-ZEdg|l81z88W)NCQa=sv2BU*#tldb48a_fFRE*NHBr4gV99M>Rqyy1XqiKk@Wz| zW;K+(bCi6r{Fa@EQ8VXx!JCFHAi5oAAxv!`BDuV{MNQ5N=5=z+ zPjsIil8Z$3i%DK|6h%w=crR}SvwHFIdm z=|xvLDz!|UYN=ETI5S7osC1}rze*8Z!Uww}9I0C(iGZlolqwBFOqF?2{rK?u=~$hp zP^Fq%y9ad4rI~SjDmTxn&KTNUN{@n1lTMm8s5DN_Vr7y#lW7#wqt&~JaD=(gGON!` zCRY4eF?$=~d;)(8J`>dn&E23}!fdahmWhc_X8^)`As&g&bikU!AzzoC$rol)nVDQ} zxpFbQF=71CJ_}HGU@Tbzox%|8B@kv99{UiVDv!XJ*$qqSlnb~rYf>|X{7f2Npt%`# zt64^b4^ziXG!hq31;X+zwBo&?FsLy7KsXVL+bCWJF`dzEg|i#pVtQ*2(C=6{O z+X2AAxDYXbyyhfmZUFvunH?Z$3E;RH+;KY+VyY3IM6I5z*B*p%vPJnR+9NfVbS2$? z2=|;S8um(?+)kTqp!8`(;BbB#6JOilUv72yd=6j+3hdae?odd0MQywG*=Qst^0qsM zwuu`8E?40FYLAJrNOZQH_t?Tu(Pp3%#?Bycxdo_Kh^&Br7g1a5Ph~Qrn-|-XU4uYA zYG2!ATc{F(aI=g{_`wPM40vZMH{7Hg+N8L)smx1Y?aKsA-YOm3Ff!KrN`fCb$u>W8rMHEm|7_d~*MYyM>E+ z|JodGfp>R$X&dL%{zN%ccsYn3R<`i&lc+ZN{@LMUY^41`L|O5@dwt053DSO4r`wLWV&4b0sSa~prhxEY zcuTRE!)&^{P$^_!s8D59m}q1Q6&M6oBa@bY)!BmPqc?o8F@ggw(on23YzFzCh+i_UU>Ok>W>v4ddgRPAg%aUR zM4WIRfJFY{`#2AmsA zk+BnBq<|~BiT^__M|SPf>!SZ5Mx(zBvtMn#$OY#Y4-E0J4NyLe;$Z{@!z~ybayu|Y z7$I`p2sL6lkBs=pE($9t_6H#3TlmAG2vRUJVFN|7b`HP6%?|%zb9!_7Otv2Is9;2Z zmB6;+uR)y3>(7Fhcnn(qc^;vWH#1mXYHwP-G2A0bI0aq@%qD^9cE-{yq53PI^DJIE)pDL{(jM4M&=M zj=xjHyupZxx;tO%dnOp_4~CuzhXzBTXHu!%Bf~>SkN*9kL%!a=!<}VFxaY>R*%bE7 zTNXYfuw*Vb#U4=3*fuh{+U*(Lv}@O1~`lp_rKVcqax{HBZxD#z~9EwfOda4{DUK5r7 z!+W7=hZkQEKK?a^ifpwI1^k%ZVb98PTfh%3$y72`b~yq5$8_&UpX?C* zG>ZW{1~@Uw-NILtyGO*ilOL!1HQ*_J3wVkoQv&OcR;1Y{Mf*&|&rCtujb4L2Q@!RE zZu{iIYrv6}LMlYdFJvawGI=7q~$CX+b_y=c6R#ojI4%*Y)*HvRfwm z`qptQ7{ozO4#!{ni}HeG!h4O=HPg$lD0jvdKK1*BPXXQKis|KR)VwB1c>&{;=5y7C zf?_QJ1>|&f#p#h;#paYkb2_A7e9CW9N6R8E<7Mv7pIUoqOhQEB(;|M;#Q#Pu<2Kdc z5X7D}xBf^jo6Q|rKX=yl_3Mvj@$cyR_1jawx&uS*iaVfy4qGylSy(yz_^k)uL8sQt zRK^i?ji^tns`#3!KPp)K=9`OCt66y9vT!-P17WD0VS90H(G_L$7YrzCbLfJY16r z%t^SZ7EupWHC_i{IRJn9ymB84BVtoLU+TYU2L8Uvz0D*y;I_!Nwx|i%uWqyS z$FMs}3`M;u=~I@N3Fc;#d;I8N6Rg2uO@eh23=TF-uzx_*nHVesAXv+LOQWv9#wt z)ii7UkUF@qK~OFvKAQX(Pe<1DYGX#ccC=>kfzCY_h>E!f8%#?85k_SZnnDKMIZJ2Ow9)Tm-Y4t|lBN zVkKhjP;G6z-CcvUuHXadYIhwvjl2p!sG$#HY&JiOdpt=S(loni8#=f$HCL`hpC;}w z1i@&X;#<|b2vR_;N&Qxf<-xl$hNi&|kPqKrjm497VwSETx|Ldo8t@C-P)(>w!+-Ik z7GBO*G-*UP_?%|7>KaQvSJ-hPDwR5=sgZyAG)ObqBUlLWA9#O`Ai*JY8`aGr$-}M+0$4hjzihF^2+R-iEquuR#cC+dayu`dCy#iYfsSCX5+=tH zi=mAw5@7TIqnN@>CNmP_E-VtfieO}cij+Z703MYCAjEKw!7}6~@Exqvh&3ANjWuZd zX~#-yC6cv&f(e{odxShVOa^b>hq~GK$c8M6Xt@H=30$yJL?OTh@LKXb6DQB>DR@^F z*0;6Ix3;b;v`oc?mr3Izf`xTuyd=*Qg(_GPr}7PCk~@NOSBRYc&4x10@qTmfAh|t& znZnC0+DK$-k2+w`q?E~IlA05b%e`t!eRx9HDLLYJPHJ1In6-Zfzthzk=AJx89}MIQ zXf@p0#ssx#IaLfzVRo7EAfOCMT2|#(BN>`DT#rCZ3yzCg7cNOAxa#Dm)2X2-S{Y43 zjT^;=eG6bAsBO?Fr-*~eQkIi})&hY@*eAyUE@eW{(f%CXEd$=Am3o)39+JfjFt!G{ zzP1MT2NS-Zt1j_F7@1_kkHK86z=IGxCJDAwTv8~n{OT<=d5-P?+@DlkV7O};y{mhL zUR=@LF*e$%9gU85EQ~OOz?JH&Rslc<;q5VsYf+2RfEh6akH1c6uB+C8wG@hI}DKyI+2*siV)GW+!p_#v>z=hIesSp>2`* zOQ{}1-K{2Qp8B;6i|2!}q{_4m4{)8kvJ-tbRBxOi2Pb=#UuD=mL-qPvI$^$eT1`4K zFmN8!^X#5#Tl*L+^1Ng1ZPlg6yAJ6+c8k+s!kkQ7Xp`M+g((S^tElI;dOt)vq@2~; z%mbr{q)a>pvg!=M`hs9@m+gHZU9ne+%e3RDEiG!M}V3D}_mDA6%qK?oSccG9JZuYXH_ ztN0~gf&VAQ>ChXeMS^kR2K(4!_|lXs!WG&;@I(5lXf_GR zCDSP-QEy_J#)&!$tFtt@sp67)LgU3pf&-1skMp@#&0F9}5W0mA9ZjN)Yw7}_mZFx8 zi>e2|Rw*YK+Zolhi~Tx1ktwXoPG4ShL6-yXJO|cCs9p57gc}i5;5!|OzPx}l|$k?3e^jQP_<6w40JBvGN#>Z51!fShvFqOmVrMC z;&c?(mSEHcL6g;Qk7Dh#nT@h7GT)D38Lk9#sZ`w$)E28`?(a+$OPhhN4a3jUn2g<* z%>^A7kyZ_%EVJK|Uzx|U=t+5EeKCltqctq&ur1ZwfoGXXmj>$f`FO$s*F@Nm_@KK< zzW%Csj(8d7r8ZqJP)4`u_91%gyLBJeJp#X!f5#i|0n^{jM&W_BneAs+v3Ie1fkFNv z`**}NWkOQu70wcN3zrCQ7v2v{ttW(UU}gJ-XcIfd<>Fc5MdDHMHt{3CzW%28iugyY z#zlQizgoXpzej(m{s#UY0d|6R7%-}YtbuEyDNrX)@!g%5fZ3-W=j#aPsU1W8OE-|s zn8v$6H+T_JI!~_#HuT7hL`!oRAi2N?g{k8o!(L5WVlj2TUNgM0 zdi7=nrJznEzEPVZ_t2m)Nop<@X+>C87KmGRZ=)@xJGEw z>P5=3SeBL_HmgW3FR3v@x-_&cYFbR3Lr#3O)Ub!w77zX02hU0t(%b) zZ49^c)7lMu9Mw)jXKGm_&!%6ph|H=8%Df;8(J%&8e+h~<*B8sqegoqUYs^6 zPy}@{XhIf(yia4hP6Gw^PkYEygB3Ro^#DXlryiPgd&mz40cD{`9OE0u_~6+nhnfO3 zE#5#|(;}5IM42jJwpWwF&S7U^n3Nh^ZT&&!RR|PHk!g;2}YS z$0m7^dm$62x={w)+~Md>>^K6{8W*0GJ7l?2W`L|AQdg3G0)`YIn!NUn@UMqsxlK}@ zqr4WwG-v=N%3*t$V9sq30uR6ePK7ZPj1N7?LMdE}P$MAHC&JZ0W!8auh%=Edq(&mk z1;8N~gzY8~+F)${fifny$%1JoZ!+T)k<}q98&Etx>@EHlKOveL1n6zCj50_?z62i0 zXs0JK0)iAu74Gd1VucYzgx6E^fY-MeKnF-hWeXSi{p0)rjO;Hs2MK5+yXvU{6)Rc} zv~R^9KG&#EIu2bl3Igv5eq*D0>nXJ@ltwL5?sPHQ}xxsIm9 zlOf(402ZLjKs``YE&PNyFl7F5pgll63YQt6$%A-2+3hHZlfmAC0wA2W;JQ2Mc0+Oh z1vtUrGpmhpj1#-@R!~f&SBP|U9-%EakIFvg9q=69zNfUtb ztO2LLHDCmpo~%c0DHm^h;yI~Y3!w8QpjP0Iz%?+n5`Ab4_}%zv=!9)}PaGjcBZC^) zEb`2TFc@KUKY3+Rcmw3a=-Y&T!l#PpIIVuNK}fl90H_U&9B{0BSo4Cx3**JY$6@sY zNyG|8w2L8UL>({*;(W2OrLLn6^r~Zqs>Tiz)RF~`)!+lATH~nX#^EW7G^|WtdI_vB z^$u?~{#Kz6>CFqOeaCDPdDa*S3=f)haQNt9c;ir;V$X?I3xoNv2kqES!odi}(gc2i z$0Y;{5bR1*PTq!BV7Q@cCASF@5*+N|aD5i_W#&{O3sR#AFy2jY&GcH_>O*mDoJyAy z4a#Yui4QI2PJ1xm2u}mE82qVdCU8gvjDNg!n7)t;^9=e-kIg)e)KN15?K}ltXQ#mmBUsyssT@t*qoZ-PvS;oQ^FOuGe4`fV+Hg}?uJ_exB=ou4=;R~ z-K-oG29+c1x_9EAvguAi=zk|dBzy0C;>2Cbf86!xGk1=?Yi!}wG4}kJ&^5}GUyVUN zKzhlhPNDa|)+jVFy093!v2gjw7s;o&ofNn~(kbl#^cGD`fpQz1k!ExGTy|L&wj3-L z$Dl#rz(qB{VoG>1aW#N@Vshf9$%zReU8~ji)!~swerfd@$mKear}fW6o?%3r@VG^O zS@Hw$5AlPl-ju%p@ji5xSXg0o&Sd51pZ`2N%>Ge%n{wlGAN%BU|E_%Z(eJRE@4xZn z+n&+O58cUr&E&6r{FBeIwl&`ZBhkzPu&sqgF6FLP6kV&e6CPH zyhTXLaL(fJc)?tyJUXyb{*yyMB!Ost=Ix0;(XB`{53?F}!+6hw`9fh5h#;8%s04vM zh(hRsQBnpbdW1{pek9_E4liH0g&#)&WTdm6{0!tknhTcAMDL*~3q+k3hUi4ab485pqY0hgyM_vmht{N}o=|21j4_Ra0exi7x>uqZn92(Aq%KO;h)L0N16 zbTA0BuVC;c`o*7cPjs%WZBsUtGh6hCW??f1Jf5Q6W7FRu`lGSA*3Pr(5n-k&22)ph zjUN^acK5o4G8u5yVV!E=UssU}58{tFt`hGcGp`z?UIzYk6i0BU@;Q$#tabX32np#L zb_qVmRo3REtDt&OFa6lz12zvrwy0$qk?Ixok_2A{T<6vNiZW83QyKsD&8fr_<}Vvs z3i)$d$0uxcZWwtN3I?n8QHUE&$svN*}Yr+L6nzF3ekc>fysQwS>?>KlsjfL znjF6FP-%TC6NimPBD%h{YiFf$c3~_$5|0gpQ*7ZrpQ9V@{M>`7n_43cn)GRh?~r-} zBpMCf^tdVh#c!x--jG_ozci)K@jttUs@`1UU%N(g4C!}Nf2tnc1iV^zVV(gO1eT>K zUQ=h0rc@6iJd&|-&mjl^>2-sMlJq)x37Z5caY#2ne7|quEE45=JPjg*|KN*-1?)r9K1R5!2%YyeBrHIw(Py&?Co!BDMCnDx@GIu zy~;~*$p7MgUz~mU&f$$4hle)4vqpL?pU(>|UiV+{1AxO5ti2G-ml$9QkFKBFzGusp zoAP;4Eab15+pvA+Sf6*`mX&+AZoLIj$^mZpw(VzMuy@Orw-)m3gT6SgK)L14jYBB^ zapr?|u)WsZPIdedDzr%-;%%Vq*z3>05AF%fZ`bJFuKOt3t~p6*vQc_)-a5^h4DE*| z!$+hhBQd2GGOGq1ETN)9u#M>r`S8JFXAF>{3-cZqRubn+GYs+2=}dPW%%oae0ml#? zArClEV2BgD2(}%`HQ5F@QceiW{d)njRAbN2-ZkDb9q%3i^lu_C+|v8D70YfNt*lS8 zSWr%xEez<8!I47WEi-4hE3sI^hoF;&fq{WT0|Wg`NUts#utrFRuI)u<5muurnr|0H%iAx;74UW;S$fYxxiuZ#m0|O?WNd4{SVOy?9%&LlU1b#j=6kG}ZmK(ehlH zQxlhm3aMOvZEI`W)p)`!RB(MNrPbk)% z-WZE|+5#;#g;SFWD4-(~cf$VW;R0FmKBOK3B@51s6ReQP)zsvyYyIGe!znek&~UBwcKQk9jG3en46tBkV>-B!rOhmsg5^ZZ_OX@ z`b6D8sdZqWwKQ<@8}vyb>yixU7NhJdLTNHW$#tX z7&v*aTEu{6mxBHJ3GB`!?)faj(@{303Wka#99_6pxJI~U;l*cnZ&$7shAvU=zet#0 zI3WJ{q>kOO|G-0+3K!A-DxmuoHoOl3ndlzuMuxcMRe^6Z)UAfL#~=wEUM&(2Wz^SM z(-zy!*_wE2*4kqFcOYc!oK#fEA5T+MS$P{w!+<)XRf@TMf)NNM5_#i%Aruq zmGL`si2eqE*g?Ms9vRkd>=IX|eOUr8$BRht)uG<3Gvj4&>t7a)x|5N5WLc51ugi@% z$!z=u4=YarorAIEZ(sg1V=9#1)C$ZYd|;&!?#~Gi!n_=og?-7)ifpV$Ml3TkKp)y; ztT^h2h6|1)*1Vj^WR_*J30KU5yUdu6ut0ADmg0CYw+pEAVW%_YCIJXGCCPptd~u*2 z%Ee>($#>QxIb+BUTzSD}mN#_UqfRhJ4ACjmz5bZrZVWiQtunHu2B3Vjf`9pn@FhMj z?9i!hL}c_kV7c%ypb$T<`+Isr{N$6ww_yzc^cmq#MSwu36iS*JN?X-3UV?n0yQq7Ke?KgC7T-B| znJyknRh(3C%5qp%3>bM*^MK7h_88XP#$($;Wm$L(VhMvInb?48YHe+UE~mi|lxNH3 z`VTXW{Nou!@l2Z?4s*JmV~^(>88gsfN~|sM$wV6~!7jwaPm*mVXeOmm7V_K?Ho|?$ z^I1=bJ?H~I5&gZVdGLwDnGg$QmAA89xe1H%-uyT$3KkEAr@As-U&+_&`LT*yIc_#} zbSbZaL$$=6-KApP3ai0LbWK-Ry^dwn>r)v1%W~Lr7m;_@MRp(T)4g7Y_B$NPW`*wUmO`HXzR*0NxzdQW z`h<84e65U`at@JZF4a9qqk-OEdc}-VM%4t;D_Jq{2{J);R?uJa`-=dLdv9O~0G z(v=)39+0D;ajobL+AK8{*cpS2mlzkMziLB==$?FbSk#Eq6clKh;}%qnW~L%KBHdSv z&5n(qn@kuBhD_$dndR%2-`uPGe(GSJMcS@kHP+WRkJTbH-`6)bdt-Z~aB!-xT3r#q zzZF&NFj^zn_eP91(?S=v2$X&1=ANa63ZILZY)E03lwY4czu}r3{#~)tZrj(}sPAlR&qmV4;?`=lCMc0oTQqXk$jHpPWz&1(@oHyr zsGCLlCPSeOeZ8Y9iQ@y&+OSGgZ&!6T00kaIcM%gJ?0xx+>1C9yBif3fYgEoJ;i@Ll z-*eRGEXgAM*lvw(1yJ zBTD$Q!fq38leMZ?_2NJLI%RSH{{8PyJpStQ&%gS3;{E&g-|^^+kGw-!K6K>}g&W?# ze7}-o-^UNrDa+Zz3(N1g1LafP+b6J=oWQ=J6UYQLT_0=!C*Uo%3UkmFSWfQNoriVi zQr%&o&>zQck?=1+3IxnA(k_Fni(z66$qHXYu&sdoF*icSD8M+Pb%ab6q)s?dN>V}2 zNe%ojG>}Rnr%U+f))K6c5O9bOGU05Pk*h9u%WWWAqagDH2|+5r|C8Tx!q<}mgwc7e zfx-|_PcFz#xb`+G4g3;SGOx8k4xzkEycD>Ea>WJZA<-gy=x$h1EC1OK4JrGGXcq7O zAkOdpAlU%x*+)pgA3v)te3`Kx9bH&)?`?G-@5{ z)gx}FP?y)O8)$Q#(QvHD^j#jg)RyUqT(y%0L*t%~Xrb&q=U6*+tg}j1OmMK{raBP8FmR< zA&U3j10%lQ_o;}t9~Oyl{oVHv!5=2Yg2?QdZTdro`atjtx$Ex>?Q6iLb8_Xa9lgdt z@r)MhSz}j5&l_20%R~dv6-;z{6RSGMqgy*Y@tH`z-4nRzDn~&4)%d3*{f09idd#sh z4zjHW&4FusMDd=9tfeQf{9&-)-)MV!+?n>=eX#mLdvPi*iXW6Ot;@srGGTT4NXt7S z0=v&&+_QB#h8TI?K&H}$d5q%Slhx1wVr8y}bme?RE5BZMJ5aGcs(VOx0@zqD>VBg8 zweBP{!5lKl+7L~1f~{d&fT?;3yPDm?-pxM5KEoboUuFLY@9H!gg6rAH49yu=5HQ1 z)7SF#syGFR8`#@~v!+DApGf$z*$sPWKi~;>2Ar-yz~u}Kc6g8gr;Xtq!&|>?LqQZ@5zi{0*>iWR%VK9Ntm4TDE%( zab^JPYH%Yn)(-bCSkqK>FMt4ZO?W+d%}*Zg@B<@gIm*kC>0B=dqMnP)Xya%Hn62byFm z?{3mL>5>KJcR7Ph@ZZ(kysc`II?yE5X99-;Qtfcj60cR8GyAyOnu)vBM-E@B_F%xZ zUTsV7a^8V%H`}X@jl?}#%4^h-5!k6EoK}0e%y6X#@>w1Swn9 zffsI;xLn)jPEtZpG^kP3WwHZ)-ezO!YmuGODoRmB_!G2>{MwV}G>R@q7cO?EUVHq- z#2WcItvx$5s@|zl-=g*)^8w?zNNMqxqrry4JFE_e73mY`xn?I~?P`EGJ0aczo5D0~ zul8ZRdMohrJ_rQ86VMC%2;LHZgcrS+rGcI@#Aet$+X>*Z>)=O!H~R#8gguLO>R;He z*q@;-4G1}*OBlu~wHfwU2Zd{4k@W%L6T+jySA`dap9#MM27q0Rimf7mxnQKtqYzer z-iRftP=!M%kA7IqsVsQKfGgnIqy|!u&p0>$zVB*OkOE2bg-Xr5H zm1dn}X3tASMmmOJ<*E89;nG5hyHG2jGM3apQjUtGWu!VX%@bby(h^UVfjmvJCW+Yl72KEvbgu->+rj&qxn| zY>Nah*8GNUJpDnnT&JE=t(i*1EsYMGRF<|5wfAZCo;u;Ts4=M3_!yy+MWWP3Rcpgt zAPTAVAu*bK=_z+a;~6zt=y@uf`CQV9eDuK5`m}+d^#wgZMLgFX@OzYFX2eDCnVE9T z-n$;L;o8iILZ!M86S8f_CWWoVun7H{y($|KDi zl;REwZ_Lx;i42?ovUkBP<mY|90~$?fW-h#I1Fks9MW7PEkw2i5ERlC-p;c>gS>Tzcn;W@!}g<>kdBr?vmz&TX7V52$us zrzb*JHb)1_)Vc<)Of*1F9|EU!E*}9wDsZtg>5(zk76Z~Omd5K9zHiC;mDPc)*n|G8`$dM0RHdpP7LSG4K=hvA znYHHmuQL3tvc$z3eTzpZk)mZUp8wSYQ1IpyoS-E)O4@OA0MjWQw=Q1S-t;ATmX=D< zHPu^cb3o~b(vNXED5?$bS{ z`;zV-b+1B*1B)40!Ovpn#8qAXil+Ih)Zos_W#55n1!_jRTu#?21#k0mFSpRZtX3ma#-Yk*RtklNL2|3=xUN7iY&3k)pJp+_Fw9BK4h}c{XPJwFr(})$- zvPs^n2@dG6;jN1B6yVQ9=LDFK%yj5B{ME}|;e9kgz&1@#R>5csQl^*4;Y@h>W&ZO$ z`ey8VFGBhRo4p_62d6*yp@BWDS7wyu>M#2Oioo?RsJE3Fc0FN<3G5!S4@B1~E_kZJ zMDP(Pcld7$Zjz-KWO3g^8DwvW{On<^faS_G5|S!t@zOM8LOjvS+7IGAK#<43L^Ct9 zhe0)DefW3s9aJzq41NbG(@FjXOb{dx*-`#|gk7nf(vNWTYRVu$2BrQSH7U~Te@0j; zNm*E+@TGVP^_IeNFt`L}(iWV91TRVIM0Otk-p3I3R22S1bo}WG6AD=1wJGZN0*{$0 zfF4wNlG7+0Rf|0$JV}%AxA>Pbq8NogL1)br*jS?Wzd)^s$h8!ORA7I^J0l`;;ltv8 z2wy|&qE@bDRfV!-hXto5j8L!+(1w$WsG%|fkZ(XQEP>rff#1OYZmw0r8vr@1ngO>F z_nT~*xO|smd9z;}i%gMy7R;|Kn?8NbCJRXf@vn#UM7#$v0UTeuc{&=pXC0D3ac2z2cHyoNP<~L-) zm0SzWA!L{KwD=(A5NZz{OS$2b=uT~7g1#Z#4sMekkTqXEcDM5V%P(geW@kS? zJG*@OqZ{VK#oJzZ;U-U4*WO|aD;4)v$_`+7+;`!^w*+B+&z`S;^}K!CjMB*`*(Dd= zV=|48j~~2rc%)YkyA}9P5;j{6bcEr}a}+Xl;H_cjOpF{U+EgD&Og*aXJR_fWONMfe zG&IfI71(b5&8V-~BV#}8$F`c=VGLt}0=riEAH;n%R+h$n&D=w;kx@7Xt4w$V(k}*C zn(RuA!U~|r;4M}Uaa|1nJMOTz?NkH6cEeJaM}RGR)0vAq>9y5Dz?viq0b5I@x|C}Z zY6w^uqB=4zc8?m7wbAl-ct^z(DBcJcKl;@h0mq0zzE3^I1fU*+yuTF;7IxukG+2oC z+5aXStfqhYJ^D%lq6Nl`%-oGk%`N*gHL2<@AOaO*e_^XXrbtgeegFN>{N0DI`xuPg z&-s{QV!M+=+N&`9& z2{4oR0dE9U^$h=N052LRfUc^@QZ>_HTMzd44_t_^Kn`CA|D%sgTyXHq%LXpOZ~rfG z^)foRtnc725A`&}exa|wpB+Xh-RF1j+Wq{l-MP`-yPn^*YZu#%-{*HT9O5eepZom6 zz1z<=oO|xBT}JZvef??uM?rHGSQ@h&MNmfy;|8~E41f*OY@=v~pru5EUe_!-VA|8X zkMRg0gyh#UYUT(WKuNvS%%bWvNp%Df#jvKBl(qCiEi;iaTyoZSVEV%#Uk=O-4zBQ& z{Or0gpdo>GWPp?o9@HMsWTi6Hv$t~cbMad6$R!tlV}5?3G%@+m`R8A*?@Ekk1uu8< zGa3VKmp>be6)Z4}J{fP>QZbyj`OIT&ZI05^)WZiZJR+We70&cIJAQfNTklAYrY_yT z|GCX)t`vlG8-0D31L?K5(KxTWyE1&;#GV~H9$CMB#$JrHAtD%a6kCq2J7dRzv$x+_ zt@_`U8;uH;YIR?|-qYXHyT4Ye4o{5VKD%O7%c{axOgn4AJ= z0mm^A-;TEL)_q9#ZQai>W}13m%oH$}@~RETJ^DGI8Kw)qQ~E;5%dpm)3a3N4jSnEVtw@e`GnLZ90$27K;fXXoC&zF~OIN+#(F?_mN8 zH3*-_qRY|--p57>QTd2Nz)jo1zTw_T2j>=D@LV>v$JIc&CB&F}WUM~!yRo&zoi z)@?oa#&{Z*>f}oDUhm)i=CdY?+4L0cHNFmh%J?vus9W`Zi}RTKP^y0*bJ%&zg&+k+ zW6Rd%mAtvw#?|WQY?G;f-CO=0Fv2dR34%YnBa3-x6P$X+d^TtI~>~9lAPOSrIi-CF3BbzaSyOI>6A?v!effCX7w zgip6z@M_yz?1WP)w#%Y1X4B}va4Lze37JFo@d_e;&@Yz$uVk`UtXsEj+q!jEWHajV zLl4dF-8(zGSGmIF?#<_0zoY)N=JUO77u$0e*=P=LWG9wb=E*Ij2R zKel6dCyKD$rhR{k-Vd_=!A79w7753RA9d$3Ez|Y^p&L?UT1}8nFd9m+4?NRbEcTvL z?CmX{)2ke3`;ax}@CMTYN6z_b0mfC!wq)eGVH?o)NkOaW+6_${*P+JX4YF z6S|S-kHucLuh^@c!;996e@1ym%_?*w%QGm%6o@P5P}zGD{%-o^F*Q$!w5D2N$(DrO zK`$cHtkP`)9{vU3>8{q@ggw!FAYuO`>2#f=8?HL#F1s*Jvofe?^nT+ZaQav}TY$>DtmKSoS;@C3%Rt6J7jy$vL! z8vJCY+^F$OIT$laRT-un5;~1yLinpSxeE7ULcx&LtspW{hto7k!O^f!#B~}6WY(3@ zkr$TW9M2=X5sQf7QM z*pR)rJTdIbC^zQ{bL|;`r?4-LhTB}*>`~>rEi1FO*hrcktXpFq%ugqOo?P9QtZr`; zH;)5Ynr$Kju*;&=A>k~Vgo$!BNEzcO3EBAP5Ad#!Qh7~JGmb{;?|@!gKNYXyL|yRWc> zo%;}G>>gkeu_FeFE!^;3cRBWXPxAP@%PYLgX8)&$Bg5(2<&yGV=CD2MOW6fh%#K6_ zpf*Xig@tUte^v~5*oUjZHlKbkoTdPJs=w)0m;{Rl{8^Xy`NN0b-|^eu{`M1}`0Zyt z^SRIc_Jf{SA`$B@s^+lyE;JZbLe)+lo1ZF4kPn) zJ1>o+A<2O{ZvcT$!mz|JqJ=+SgIcJ7RT=M(o60J~Pzw8`tX)PWlEu{UjKybd8yad84lmre1{K8-G>ekV%^dBFCLn}( zQ8HV}G)ip;;m4RqLaxxDh-|Lo1ZZzd3!t#7O%=l}EzeF^WU)l+F3^bbIm6p(rVFt?;fB}kN zz>-(;8pJBNA?;OG+!SzF&6doupKOQc&uTzfVz35Utt6axkP(DYg&!n%< zvmByW7?|>Z?lVCD0kavMt{whj4~zdQJO^~R1ZL=AScGlD&i|n9Mp%G-2p&;SK==6r z=sy1h-KQI%gB_UvRw4TF9(IV`$nInxW}jzIvTw2{5HRD(km7GT8=!;1o(`r~j3&r<{D(-~MG)5z^$e#pa+ zl5!#?#xzo*B)X}d(=h0!T+lah{aK){Y8rWBwN5o9-VHS~3acTtRLB#&TqWO4fr&Np zc+ykJ)SN)PgVL&Dr0`@FV*pRqOihu_P+WtG zGw0xi0IZH)MYSGeL%X3k7;i%pMwP(E#ep@4US^!Y09j2*PfaOnAc`IoP8qzynoJ~u zCf;PVf_fK@9BLCH$08Aw0U!#XcJXl#AOrwSU0hgb}O zdSQ_&HF*I~Po1D9K?hQc@$Rl=kf(trB%Hp0goSx2NsU}Oz^UO9EC^SrZBz*d+4}JH zk=j;$bQ&2PWiW#4hi?}V@YjIUpS>A5$=)>=9&PKV%i>5X0! zqVoO9T=JMD19t9ur%^C&Ge*27Alv}L?uSA)6tlw#4AtuGmXOV-xB3JLP9_d}H1rAZ zIzZ<#!aBuZXz0BO1H-DhLWrjP3BB7xrQ3bK5j-w0h_LG&aICWB zGa;jLlV5MLwi$(p$yw41A4M6aum=E4a*G+Dh6kK^i_zqz_=9GIJZLkyj6$DZAM`3u zqcQXzH{gWO9Cq8EpldSv?0U)MZZpBd-fEF8qQxCG>4iU7ys*x3*kW=)u-Wu`Ee zkOjX*@3s45JJ7Z+v&g(AA7VIK`viT$jpny1AFu#~)#sGLax5u$0+(7M zSHjC4&0y3ceyB-Meq-{Q1s7Bf2{|Iz!sbaZZ{!be)LTr>uw#=0alZj7QL;jgyG@7- zQaB}q1?7XjfZ+B6l~58q7G#F_3*1e~1~dYp!?N&Ry=3XI3ogGmZ!-$|YelOq)&BY#{3qeD`&C7kI{6AYzT>-Sjy+N30)AM`P1g^(t3+N zmiWDEu#__8Vi2iC($+b5-YP_#mORcAS<6hjY>b42sH-I}%m-*_dV8grJmE;l_71Ng z#M&%#zJYX`DIF06Ic0SWB^LdIMYluC8Cu@y*@q7cfOeSMOCz+z0sOt!9^Ahfod()peX`2p5|f+;exV?v+0 zLDa8y?LgypxD)Ndu+dUJU=TuaV{UXLW33tn(|A%jX51~TmIn=jKWQoq3$r0(!CUK< zEJYbDo;Ig4!dNjX6lCM1kN}*gw;>4U`GpS0tdI-Y+B`^<#nT8+rY+X#wvGs-dj#AvHXMRoHp)GEp(A4K6`UBh#=)A^(=%c*!^Mm5 zf0!Q|jPrm3oyTgLDFD7c)2QMHToI0|GK7VVsx%B>XowTR^Wa3UJ9pXiT=AC7WcMR; zwN0JnGgnMjt8>+^$rbMR&0RbEm0fIjXSs9xS!{lAu&Zt4pGV(%<3GIZw%cy|2mhDu zu76zlB_hI~!S`NY^xud-`6^XXPX6`f&y;1GnRw`w- z?>hINF1qND{?J7ieQ(#U?U`a}TeZ7xfH?FZ?se>$d|))+!`=^T=KI;l*{6k1q7M1DF=SY zD4(0dQ>kPY-#`NTlZFK(83vb&Vx&d`82}hSsTwkVWKcrdASy`YCi_^&RhxT>NF$Ux zzRRiNA{B=+k(u)*B)Ud6%1b-HYE!3Fkzk-NH!@h7RR$MIOQa()H!^gxY7c!Xh}?uf z5*w=DjdE8nB23|n+(WzY*TZUEU|fhL;y=)AR?2nl0y3&8yVJc)y{{#!s8=#Lr9?tG z&$IO4odN-fE!Dtgy@0n#@)pS30Jz6!3$W$bzf=l504+@II?0V5;8%DW&Wu5}pDtI* z-JIYweRBe|BYuJ!)XM^-r2GgNuT(c^P=BFn?iKvFrzNCkD`P%T6PJBUFZ(LZQXpwn zi^BthhX5HpBz>(oA<%%-nEC++K2qzc-BsZNWYR5bU5fr7(Js1nnBg1JWIQOkhaN#Z zHrneyC=b7_ydt{0XZFba{Duub>1^yOM~xp%jK~Lj&i1XGn;0;~4$32mPa%@buEy-q z4IAd?f701=XVmyWa#%iCS}_rXqq@i3nfHt&#$E_sq0bG-9S&2%9U7UwbENaY)uT^t zx_0sWD?g+G`{Vht~k!tXKW+uNv^Ww-{554 z+x>6u?!@gkPs|)$KK{N~TB#;-M&YTme6v|noSg&k=Hq$DSr|LM3$*|~dDrnJCBQ59 zI81MHyGSR!3#w%O@f^^U7n6$uPc9ns$3;D*VmHKlKakikqWpBdZ%?kpfA$7h-Y~hY z5q*m^ef+YK4T)F9nU!O}DEeY@e)!;rcp~4~^RhfYwXSF0wBq)I!}H0%u%y*yj+!!^ z<0Bs7;vJi>NR5_c35eCUpxf(pcg7nR-VkiZrY(fX5B1ACM-JR9{@eItLNtzzpb(OB zohxr2b+gO2Z@)Y>-X;TbN^gajxij8#+3`?&z$}Ek=FxFvJH~!CrhNUtY6{o2ku}aM zjCV#d;w+vPy1mpT&f8lEu;b`n0sVp1@8Xu%*i4OekD)crTPFa5W=`zdop(;F8FE)T z@5+T9=;`t8Ijs%(N&DsbKVO*$Hk8-Ji>|u#5PQfpurO{OSEW5SCPwl zfs4q!waIO>nI1mp&A7Bis4hu-12r*a6h20%r$G2TY<4(f7tEO*(9xRp<*|8(0AAww zk0rZ7QGAX)W(=-rmXHCS1g48r{upojO_gPjiZ<*yDdJ=Ft#q=u(P%cAeJ14{%14M9GKnTGlcCs;Cxo9t-f=$A z|24-_2h4I(TKF1*8o}&{&@>$6nxsuw2>1tVA^D3@AS^z8qFG`j)8fAn`;t?t`?PZk zSwM3V89EF9jHftgFr!73@BWBC0S--uAHnXRD?z~2A(Lbg&_HOlS(r`e#m40mlW%En zZ=dA3sPPPw%ix)XBi7>D zv7^1E*s^=*US7&}WB^IOpqz#av=Tuovw}v%X28Bf{1Mt~a1l|6_HY<475vcCH6Zz= z1`5cM+AJxFkYrNC$`X4ntNW>pFtciM@}kz(@Y}l(cl4aDAx}%;(3;iTC;I!($>oEe zOI*>|cp${aE6VrrBDHiqmppg0_wwioxwUQo(9pn0ICN(Jz~o_P#&dYZ;m1eQ_n#ej)kznyOPTmc^6U-BS->BR|&^* zgNL9eX=-`d?T_{C?^tD!gHu3F=dE%<87mu*D#ByV-B&2I z4PSMGwKe`;qtlVF+2YYsE|}c9Ve4h`P1jjVfp?fklgUEb@AP=O^SN5RrKRC=OI-i- zlzutrEW)>O61%o_*k!3B4B`o_h-Bo&0BcgNL$E=MAlAkz24I06iKK`meTT(_XF%bNax-H=`56yxtnv~$PIRutYX;J zq(_v`D-Z2;%ybm;iXr{r8$2RGWote z){uim&+tvkh}}b|5hW%6pTB3z+Z$}mU&wU4D9jiCb$>n&ap!%?L#r2H|MU9Sm1o6I z;@K$zAS91XHM+I|DQ0whte2%=?Q?&e4xxWaenOvSAtlz1&^g}v0@*MDhWU;k&X zIQ;3MA?#EJu%YfBKnNi=Sg-d5x_5Uoz(-UO_mOSu+SP@aEPzf7i6_>qUGqWw-L_-L zt<`GhpUR!(v&!Yp6$Zna>FL!citCF#Jv|5T2j$#UCX-=-Mx*CIqo+|iqnOF0r!wgb z?b8j!=YZ$g1|P!h@ELppJ8Ezrd}hT1kU|`mbdcykbhQgL@RW%1lKQEcH-l*%VL@qJqds0a+D^`31o5;K zYLvFFS`wlUf6XbJQ>IZxJ1kXauTrJ6ncg|t3v}irLfBf(uEB}7n`{@F3}s)8;N?Uq z?%OzzK=5P@W=aM+fQrb@H5ucHASNL~7s||TvYOw%tn9WqnAK$nww!(GoFM*-&AfeX zIOO&Fy`lfh-kZnCbyfGiz0WyyPSvS-JT-K6cXf4D^;q52Q*~=@(36wm@oyC7&{OKXCi#AZ2~0RFec<)APhDk>hk-p zeX2B=A@7g(*Za_2!>Kdud9S_p8h&eK|COQ0<)!ve%njrCGSn^^J3=_lgs}NFRR2iO zsSFt^UU1`?NKmC-z|e%yV~U6N3*6(6L(TLr0c7OR9?9E;y$FN_c~Hbe){j*^6jsSp zbT|#)lWZmeY4h5{0th+z#!#fckeMs92D6Tr=Q4$ofHnGd)XL8id4%F^$8_t;W?^Y0Q>C2<_8SbR3wJMQs&`YOL0J76&3&s|SG^I2Wp@F;U z(gru_bM*Imp)+zxNTNV2=&>b(iWupqvJ)H>|2Y=Hw%*KliM>-3v@pNSZIZ%1qwCe* zfNA&F1&~u2kiNV*Co71II^V%UXU-W(BYC#6y$5A48Q0BsO|E2J_uB0{uG`Tz3uB|p z%}XY4+>=-J+ROIuKY}m&%WAc?KM0JnOSg^Yz+m&XLB`=|WoR^@nxVkxP(=;~2e@>2 zcI%#{_8vl;lrL%m{D@X4J3%za0e31CyktM9n3$kv(u^cC9s#o@Zg>(~LTGBSZlhvWE)yIp7l$#ZQ;}L}X({fxk@4!9lSPc_spm}hq{G@KlT?358Z316Mh=5lexPLF5{ex@`PsdZ zBc_c6edb6UmHZb!x@(|(>73eJ@gEhiY0cSC242EPaF6~W^GFmJk|#Iw2q(B(R45+j zCG)n>bfE}BLV(c{mZ@7tPo6!Mk9s7(yO3~$iKw?Z>q0I=@#l>19p`05Oe`~VIbwt5 za>1cwJb`HuD1Wvs$g)4}W55_rJ6 zGU3IEOA?PKoOn>#pwbc?)OHev?YhzNcy*;*9V93~E|C&*IV%z#&6`0ZSz-;~->|bK zrxXq6ooFG(-Wg$cX})NK#N%456Nu3XCv8VJ*vSwtaRVUo&-buh2nSu$Ui}>4sp4d{ zM==E%SY|3}X4I>al~_1tflVt@;SWoy7#qz*%i*-?g-g-wSp2iI*UimcHw%2Rn+}bQ zk6koAHclH@rReD7#HEuH?8 zW(E_bg#rkzAkJ3K9iE(+ykv56LNpsu8*egrG9X~xvgA06I}73S1Wf6dfCD=c^NjNXLmt!G`fpzBqQh4Ktc$;COC5~VIX zS}0VjuV35wk@#e*#5jMwH z#=&+ZxHcH>FA*{~99&DJkwUuE&yn2S8P!?5kTim8VM4f3UlFbmT2!yyPVVOgyat1W}~={s!bF64#4-y9HCh*1_lYgsS-Kv}PrZ*Bsom`QX9Ln+~e= z7p+};k-S&q8!(fQw~?N!38q?Yj!45sempKj*Hnp}^TgyRY^uph#kdRyFt^g42HZYh zEvc=@&ayC4gZp9Fm`%*iiZv(MzWiiLuZp4&D{1g&>A@gSQI;;%#6LqIgq4pKR-KEI< zlKqJgcPPD2as9;rqANyM^5zyU`+T_2OwHrT%|4q%r>1jqi%+@rGQdT+uUcOR@b+fhlVlo+OWwX)Y%G%{^+kW@F^EUM_^{19=JFid0Z}Q@)pqq;*Q7sxq zuK3)|J8sMY+W!3Fd9A7Kh;g&#;Pk}aSbTo4IvV_w(HCtRUy}?GAS=egyE!SgPEIh9 z^G0e@Q&-K-Hp|QXg}V-`M~Mkg$v|Wuh*ZINY9iYCtjdn$7cy=>f9~}3 z^0lwY_qY3-v$IF0rfMTzE)jI{=~^naK{~_iyYrbx&6gs(8pfsu8IH^ghA=A}Q^P@i z?6rkumHrUzK4%?PPAmxOz5PETa#>E>@(GJPlS5A7zv9<6HLG z=;$i`hE*h(()8o0qR5nwK$MXaP z#r!Uxh}W{&W?}smL)$}MG*-1?J- zus5-JOm0y~wT}-gtaJrFUhACdZKA9$RJceRgL_7k3akK6F*nj&N_SawyY6s} z#oSzjA(Bo(MIK}M145dhaew2MRD3WVP6vrxJ+)M3$8_tOHERMB1D%t}Sy8RL@kV$X1i)N44u zSWT**sjuAGdEYOLAFe*ijEWE=uD5FqoJQb)5OZ)2 zc?UyW#0vqh*1b_YWjRlun8DSpBV%JDA22>8U#nYR=@pBf@izUn`rKD~rIM%iFLfT) zH~qtsG5moqE#0S2cRskJHp}UgpH*(H^OW(WyK1k-gw#^sFPuL)d4AzVot%GQeCCSe zY`K3f(V6KTeCJ?7pZUS)zjSYY%cybb3*XYX;b3B}zdW1xVWWGYbN@pN554Tb!Xb78 zw2i_4e#Y(uK417oqzN*D-T|O||2vyjpUb>MK1^%%0^^C!`#X23TRU%1cdAQQ$Bhs9 zd@0XAf)zmtn}NO90i2S(GI(i|8l#z`E`uY6g+fB~YT)0%J&+EDJ+ISpT7KA0my7Y4 z99cR2mn0xdg8tx}m%hFC!8b2SR}&}s@L%d^Dh)4^PNi$<_DujXE;ynj z72q@%Z&)N`<@yurlOrG)8}rvKuwB`7*JcT<)6AsV(CEK0K1WBvFm8;l9<1*eS$|>! z_NTzKGkb8z$$P*SgVgTY=iYrTk$fCC&}=pj@D7E!3s@!LVRKpHf#kBL8$9<-=Nf39 zwonQw$4jUH00+^CKhbHuNLMRa0%v?f#kr3op99+1KQyUaMx{?HWQScmW06bi!8ZH7&4MZ7eY2<-q7-7|4u2g zUlulB+LXN`Ahxn9Fdk-Rg1 za18-kw~mrHwbQ;b5I?E1ZyxMV9*_3d%b6e+QPwlBEu6|imNhOuJG%QRL8iLGF2-%w ziYAm1&)CxfZWabUC~P~Wd?4n4_K$UEu6T6k&Z9?n?zsMw1BIb;28#`Zzowl>Q*R^+ z=t~au*X#YIMx!)onz11)aLrghNF|+jXUEb*SC=GtZR?vYkT;{ag^sh_P%W zi-El%HyI;L)$IO(k*n8Kx+QR5T&y`pIRjBFZ>(J1=0Q^s7cT|zMIt{pD|#7A7CSK6^)W+axH!l#no3jgoX#sqv7Q9A|@!eLE5C4+aa{#>ThhG1C|f2E8?dgKLOi zetmK{7EJ|GAc3nDBzIKZFvhF%7lIwGM~WL?2#yvr{VE(Yu90-5qjswPOmQ@5#KJpy zF3$7O*l^O5GE?ols$+P4W@FJpuyEo08~g&RqdPkPz|BL8Sp)#l*1&DpVuNlK%)}_> zZGMRxrGqLivEG0rPL@JnCG>z*-d<0SBqy($XA_<2mFb>LoxnP&m;{ZE*w;{oXn!l~ zBtns#Gu8#wbAKnkvqg?kk8FtF7yNrY#*ZGI0uspaqFie2j0c<5son#dqjb8Hi{Mv< zBDAmX#n1fnGhe~)^pE=P0_pW4yhme)Wj@mij5ISkxFYHXD~3&o%Bc-fXzOciNw{zh?j3sW=;*Yn)d&A8{Ub z{>J&H^W&f$911Q4_XaOVZh1%W6~Q+I|0MWG@QL8JgFg=bSEv|T2<;9X4c!rXOXwq^ z&xO7g`aae&)9yxhmwVW~-F=n&7WX~w``wSbpLf6Fek&XfPlnfrcZ4quUlYDF{5x=k zaRcu~gd<{?=Rk8D3^seeVBxeqLSxW^a)ER#pD+YaBLRPCXZ6R5$)UsHiXn2a9(yN* zkN318*R_aDUzX$Q-03`oCnQ^){eu4CkP9z_Hhz|pt)I1&)mhND&dIH^Db_{id8q~) zH1=g|i;+9{Lb+bakkGXq_I)A{l5+WVo&QQ576I9jD@SHhy;G zOaOdl-s4f=jbDGrmxDgru5DNti3mF%5_OlUOGMfDEa9k z4Fj?=>p698ed0GYma$UFZiyRSeI9d6zc_NvkhEEqI7#{xVNeg6>H;Smt==NdUMKim zz=FHx-y+-5V|PK$N<(HFUPY=@mExS5U{ZHe7#S(jBC(#R z0mjhWC|+OP*(9JZx-Cv0&|nMt8JURPB5UafIq90~d}V@5H7RU+ZpGG}ktN;(`O$0m zed(6S)+7ljrwmg0f*o~mEju-Pd$VbA)-NElMo)7+yD6OCXN)d?Okt$XFpnEjLcf{G z!CS~m+F3fpe|BNT=ry-LWj3zB+FfyHD#2JV^Zn+^}zI~qf|-{ zjck`p3RP@mP&AV)%Wt>X^razfD@n*(F6wgPTPJiwMuD`4pOM(GNUf35OX|LkT$h{{ z?)9Fh+oYviL*}DTNhEQ4NXWcjRmr8>ymak6J&GqHT_h!FLz=k;xId3cS9nr6J&^g9 z8OSZ{bg2ZdmM)AmjP#H^Mq1vWAtal(u7)Nia!HeKjPXfSXuhsBKPhyV)~u+CB$a{W zE!YGssL50Ew&xf8K9M3&czQz;$jUS$bb;I=t?$3vNV`Zz(<8Z6j=Q;ezytY*(`r%o z2LL6EWj&I+)1SoV*riK?6b+XhMi0$}EoGxBqaMO2y(X=q30UttD2UTO;w$?$hk%HNWqbhGfMSH)h#b$i_wDjpl+Ckj99rU%}w!~c2=mh)7G>H!~|WT z1@Bbr^Efj!>*aG2 zd`jhyxo*Y%R@SYn37=1jvf>jh!L6hWGF>%6o+Bx{u1Q9Fn6W);$1PoVf0>fPQvz=8 zPJOAV^r$A2(%=5d?m=ECpH5nmm$gn$DZd@$sVd3Jl1tO#qatk}W5Sq!u57W!9m(64)Mcx10u2W-zRdu)b z>MP$ue}#^~Q_7I%P2tF2>Mhsx63zv$A#9eTmj+2sesrm+bQOYoUf^GCU_z4=mw}); zj5YxSA|n$aY_2gdmp9`X!cbd*L@+Q`kB?a4Lc~g>oN&al>|kxBY*~?VGaIz7$65;6 zVVHe6wvvWnkF4nrS;WW1mm_Q&#kJEj7gk5A>qj@1-N;ZfAh+20^+Pedayi6#6Mdoi ziN`IAov=~m;h>vx(SX>7*fb%gG+ZN8a#KNLV!4=&S;1Vy%IzG>-+p2K< zBgtS9V-ajWz!NkiTpR`9g&~b$Ei8V!mPdN8)OR3K1$3 zdMhTPSmIy;fD5e=jyo8%5)(WJ5Mdy7o@Eo`*ucDpxFN$NmW$xR8ZvSO(mK~n17dHO z$xP^Bi&l{ehuK^vVk#540}ZPh&i6+WL@PFn%^U>>^YCr-8MTfQZ5@pfh6f^IRmHJX zC^48o?{9UEBVfhhB7(O~Dkf3cUlXSv=|dnq)HkF|)Fa>)V&=M_bbmG%a_((4eGNInu67^D8+7RydmlSVu|?j%PdW2cKT(Mr@v*F9 zSeB78mV)uaXxf2Xax&D6zYoS1N26_GU}h63%vGKfPDck_o1H39C6sQPAS?2y-ARE( zhcT2zlOeqk+Z++&to|=P+~I-nU_6@`HO6UH1Orq)9>y2OP^+Dg7qJ4xWZ4K=36iuV zbDKEHG>ZCXN=2*D$HYrEF!0v>t%AW+bJIYk115%DKs~vk{TP2J_7VHo-UZ_h2xQ}syM1y#0Dkh^= z;9ZHLgeOkWO3V$17b%zee#{yq44aLa7uZ;0GRZ{4$#&L47Lp$%yaQSRovpIvGJ}sU zXZ&evOd>Wth$Mi7sIMT?P!;+`(S2@ZE{}>>y_VSF+(4V6b_|$7%)Dr6og#L{GIntX zcWYCboc(}}FL*Rzhg|6v^qZtZtoAUqVSwu)M7Gk$Rxqf(i~AOXLaCFVqtG_a_vV&h zmLT33oQdU&fR8e&PJWbTBrZ7P09_oij8L8-#@}v$h>g*d;Z~N4p)jMre*jPoh9TY@ zL`7#sPh0Nlcky(m(P?uW(@f*rgvbsC<3VfnF#vYGh{_f1dGzE)YM7xDiaEyIo)N@r z($*OB8|pqG5sBtfzKkbLIvj9nIKmsj)t_rn#Z;`CpLgwjrOLbUSNeVQF%kRZU*gry zZ~@2cLhfW7o_rsc6O0dvW<;>RLXB~iP#_BBN0v8Eej(W^MF>7m3~w@w1t}^rNZ?7U zY0}DD^b24EG7M>}1ic)T1QJ4f({m;FHmOz@&RO%3>t|TFS7w3e+&=x(2}Xc z-G?x-kO=DpN;iX`D+oo`iNW2Q_D3KchBT=3JO~2i8PA^D*q_;RQ>$imtTp=^o}CJN z%NLJkDhw)S3RcIIhMLL*TC!aF2q|uB%RpX|Vq7V@janJT%`(7)5OusWpvCxl^heEH z1=J$o;%vr^C6mzVe27-a%>eF3@bckYECu06xT#d@yy2v4)B6Kcs4s~tCzwOZV9H8M zQk#_YfdzpNNJdtwmNclmU_=XH8_fsp(Okq1Wg}*^p2f9Ju&o(ll%9g}JiTHN0-ok$ z8s*e+7DO78ZaDdnw3(9DWNhCdS%)Y!!W+76O> zNq>lSWDMib60jF2W?4Ry(}SDcli!rFL`P72D+2xmv&vq{+PN{XbKHPMqQu{-W``Z24z%D1^lGd$B{3x#go1Ic>4vd-B2-1ND!qy|}whhS`%GOer+M@4{6(Y=7w^b@wbhYt8p+{sb z8A`hdY2bm_0hM|)qed0tS-lQfHUyjr*7++WxxyQSF!P4dKsjz1*M@+ z(bI`CR)hT6v4sIx=b%aON(^y>@pnG2`Y!A1sl_r(jO64+0_lmInUapRX&DK%$tu-p zo6WdamPeCVz}zNx^7UNOr$d-Y=x6@&A5{d2coGU$l8>I%2zQ=lH9CF zCTyyQ<7KEZ2?cPws(T~2uo!7fq+{_C2q~)bq?3$bThBsBp^c~$d>0WtBa9>Ix*05` z0@*5#fR-BxIJ+n|R+piO%%UYLmdLpfqhJ67SHO)~Vx@EK?bb-tW3e;;Z&H;mi@CBG zWNI11jYIolv6)ixcU~}gTc)&YEQv6#Hq4DS-nWqEOeGP(dx5n~R{V&t0<@Ytd;Un& zPL0BLI;oK5CJB`qiAK`lEKCPEP=41Q+&7iQe%P?E3y0Az*|rre$IaQjwFr19VZ9pZ z^q39?G@WKQBWX}lV0&rhV$^PwLSr_D)l8glk@3Zm0KcHQw`G1iGeb85-Cos}#xM)TWhsp*Xap6w=z5zQmZ9HH-BCbXVe%sb(;EhAzPx|ta$9J>q` zp$ayuwon89tY{^L*_jL=0SA}acIp%AC1X85o)$tTE+OLhNr%JSU^wmK3eN4CK=P1G zHNWL2Q7!qH>o7Dx{z^faYIuz-a@OoH#G ztn@$({>F*W40;Aj272Lw_!HJo(>Yl$V@W4C#3{`yGo(_?C|Sai<(LoxD2Np-!CDh2 zm5qlkM7#*=1HH~p<$(#ZjhE6ruQtHnDY1zG1CVJLij@39HaAXZZ6YGmh#l7#WT zt%AvN6ynE5hm}h zXft}fwEO{o z8!;N1){^a+@k1&CZB@1mJUc<0WBj#k;cKNq=vs>1bVswGCQ!6P)CKAhsnJTssOby1>vW$WlnU$tJhAQ;{YJuGq>yZJN zBBB?DiWN`6sWZW0Hi?QBo^Ap%RbiwA85qHsm3JdeWn*MSsjL)EgAv#iT8DlLJFqE0 zDWWd~7>m7!E=(X7D*)PLr3fOSfNf$K49mE;Y_`|TD~An(1^g{^HE5PwluW1{9081* zrc;3&>smy3{XFvy>tLDO^pdVAFj_Lh=?2SY>12w*WM*fIDwn9I*%*bgjD=usj17+; z8z?(nP_j5N_l=_Ku?@E)#mTH0rv~|0aHJHhNx_Yb*H$I4%+E1I7_%4$Z)p4)XHS+hp267TF~X zu>3(X$;5#Q^|w4DXxx4al$n08y;d<0&xRc6Gqg`C|9{xW$W~LY2B84luA#}P=2c~G zTE!;3kg)M~W&{EgqmA*SgRuz&^8@w_tFEPR^1%lQO2tkNCmzUVBmxg8!i-{+Q4qQK zKy4+=B8XudnX+jw49ghK=1@Nq#uR#E3fT}I2r^d2>j}EbUQ15_Mq?Uwd}p?v zwli$H;Ek9m?8uCWz`{W)QWJS6l^)Gn)_v?k!YPCgLa!j8mb_zGc0TZ`5h;_!JV1k; zvSN15ZPsIxdt~`>t?k9h9ArNs8=hD?=m=#aryLssj2Emxc}>}Vo(KtIn06tgO7xSQ z<}$6Nbyt~?WH(Yo=wZX+TgCN*GK-AuJN0%P0*9+Q*JP!En>LJ{qpFn1A$5qsKr@1v zl>ynjIVxLWo5_-iVRvF6st(P?AtcZ%1}Dq_?E@?;PF%{KRFS!LK^A49sKH1%9xz@& zqrsjEfn~5Vi9}Lk8;~e?BpI;6^$L+FjAhsqF_Tz98HUK$AVpBPscLviZ~bIx)YIRM z)4(JaBfxvO5QXHptoS^vsiTMj;2xP#ECr0I1PiR43I&)_5&N=c__;#)FtE1>7awJ( zOT`ic31vg0Fv5|Qm5eo^W7!j7l1mF~-hwQ-EbO<#>HrsGDp{Ikp9%SsKV5(n3LMN??@LWTttUh-w&qz75ey#~gTTr~|ib*?CZ%9Y(V zIp9Lfv&?*=Y_ThUFZm>Xg$q8$tngPLADa+qU)daBJ5xT z*h(ge`-?k7NM%E6LDd57S|E_bLO7`!$wm_GO2Y}%)TcUI)u+^-uZHocTJwU=1)U3w ze`{X7dv$sG<-3hTKlO!nUe$n}_H>rSp{K3aQHvOOfxu-n`IB zO^(0*^=m%+S<0Y5;JU%Qnlj)iR|BS0bvo}cKG%8A4d(unAME0@(&eAVU*KFFVf{B+ zZT7sSyGFQYJ8pDOc4Hk>%`7O~*j!fRd%;lJBngw$*B?BH^XqB*%MEMS9-Sl-`1shB ziwn!wTz1*3P|SIIm)hr?w_)XqLUFY5>H`OkepFr6`Mr(b?R=H*f8IDc_PRro%E8x@ zU@yhjjg6W=iN_Zkjp?zWN;4X%H(qtwW!LQ9w(SMYss2K$For<3HTgjVu0GlMEKE9c~nnOU&|K*K6(5>X?~*e$++Wc3dv zXG{lCcf`{hu8wd7i6rsaUY<;V?u%V7r0bsQErgXQh~@C3fVi>>zj ztmrd2k{@!7cNQ9nd{WhjLWJ8lEA&W={DIY95=incqRB{9@5~78;L*D}f ztJre^*dXOV=pr;22X^DOmAfqUC+wDl3~~{60s&tnlj;lfCUBS}r=h2}(ST+{dv z3dUG{{#lEwt|2a8B0B6+x8wj{cwM-{ToU6H(OF?j8T{B(^pXS=faV^OEF-jI1*HydzS5N+IM=Rw$`HQD0>P(yPDJ@>B~@e%1l* z)B6b}p7p$_s}zkgD#44na(MVk4s>@&3&CR3i1HYDMQH{WIZ2a4KJf&Sc8HI1Il;n# z(>>>osJaqodI$ffN6r6)p2eWIqr{lGup5Gc;zH`&LuUR+zSafo5yxNM*Uq4Xk`?~3Y#TRYpTs}7Lx{Y$VR<9I`)ljHT@!q#({kj7{&J+)Eo^K*36D9 ze>-NFc{45lxHYq6t{WOImzD=B_1aQrwYjf8Ja%|4-chqd^~NRBO!4;Ob0#Niw+Dme zT&{n%wNNOm=`a7=hP!t%nGcSQk2HpA^$RDbnwwwR9#||EFJHfI>(+JaFE5r#r;m%q zC0StNTJ3hLwOvYa`q-zt(pTRyKiPLRNZQgvxV=`LSx#yNEQQ zHsru|o3<6Qr(Z%#=fFi23p_EzqruTi>$jw*-9FR|R^-}1uS^?Rkv5a|EJsW z2mCRH`@6x49bhNXtkhero0gXs^OM;?uAX9Siy{YB3VA_s-H?$U!-pBq9HxZA8PTkt zQ&yX|hP+VNo$lW=-FYFWXFPNB9yd1^)99dM)ka!k&Pw?LKkd))mRb#(^#x9)SP~9 zd#N=uHo7@qh(`gWA%;P+P}ne1Yt+%ZDn#uja!$~4KoEerPhl{yHI-W0w(SP!1e~~8 z-&HN|p9^~VHRW<99b~om9<<$y+vbeusr+ zT0rL}Aa&~2=H%3c^;%`9kRJ|)RGx@GA#?tD7VuZ)+|rM2i7Vnew#_2T-Gsg?ZHp7UC-S(-aE zIvPU)0WL+tVXqOM+&i#&q<-nl_-HoSo}bt`Fu5*SQTd<76C0+dH?C{9_Z17-+^)H~ zwd-c*pq@r$;f~D{wPbj(IN0{aTblqpuGB^w7mZI$WhcGDd|~U0hQdK}q{gU@MFulb z#8rv>?CqP5wC3mbiD%EAg@twN<`?#0B$e5tc==r6oQT5x3=hf8RW&^ns0wKxwJSBrH*My~%< zueoI<)8keMX_uA)aNlcmVb}Dr@UXbO2!s@wYBQDzq1dNh{=D$Zsfn&+%<%V(Z+f-y z`&e5{^{OULtNyVrM|Y|0(ch~2RxFiG#pmiHli_$OpH43g7N_Ej_jD{iMJHsU8J|5$ z#lw>$^|^RFgUaHeV5>6z@?Bf+n#VMR7})Id^2J0fl#2=si;9o;Cu+HiSITp_ku@G0 z=l;n!Ac*XW4dY*!rSXxp5eKttO@1OJGCwWe15OedOI&0d!K4dwIIyR+<^l}hNRaI? zd(To*wvy*J8$*{Z&h9IR@r*!l1t6%h_T!Gk{j)&Bg=K@&58s zI$rR?3DBF`DXJQ~LfD_uc#L|dBN9vt9*4Ot@)#H*Zz2))3h~5LUT!B}$g3&-c0I*1;l6GRj@{Tb;ElscKp)TtDABq?Au+l&R z8fE5UiGe%;wlyD1O%#VWmtEF|a2DB9AlF~dXE)SYmauxkD5x-8_q^IrqdE{7 zWQ}r+>5D2u=eX`rHk&I|Dm#IL5?bQ)In4K$F}~lVu2U~ouS5WTk$I;c;~IZLR$Q!8 z8SfhR0&YnUe2sCzNaUXQsWo1fV?D4LY5HMduz0vT)O+Jr%wc)yRbLEWbD2h$t`N<_ zCO^h~Rr^0R>KScN%s>usqaK6e>Fy8gy4{@}dTG#pQzRy9y)F{NSu)mT(uL^FI&9Ts zOyj1=c1F^w4`(xyDD#0-{3raRS#@NN>zw=obJekhiuo?Fr#`8&#F5Y?;}LEUCWlE` zCm&nGv2hXWI6nN^>RX+_qD*K%?yq09wx^qwyW2IEK3xLesZ9q!9rjb?-ma$`lfHLM zPC`QPKUl2$w8YP=m%V;ME^13fpB_l5hHdfklQK5X`Abxnta!qn%xG()=G zudICfqXBa}pJ3%OJ~|vJ$|ap5mwcmI>2R3sMSoDNg~&O}2kv?7&C} z!$hM2`~k`@*$TsWz~iAAEb zW+wu6IoJ7^nTjXYROWXF&15n@QChdHkVS>Oe)HrsI8vc}h!E)Gh~}N-STs(!2xQo3 zqmaLo3v?0P7m*&qW;&G4qKk!4_M=$BKms$#5ZW(x)i8$$$6;^5=}>@Yl%mOKEVF^g zA6eLJm@+dhGG<6PDnaDhRE$vYMh5vQn`}hw$cDZ6P>SsAP->(Rjg^9CBFL85jRmry zJjmcXlL!>4E*K#M5sF2D8Y8rj`TMFGBq<9R$rw!~WZaN!5KEE=@>pyEFdJk63DbxM ziW>?d3MbBl#^b4eFPe^|8xnyd6^Jx3(ZJ>bBQNZ~K*?bfz8DJ6nt17j=?XGOZVcaU z1Tem0ub)7Y1vW{RCPL9|2lpb@fPY4U6d?psVb}^I(5GTHN@0vHV&O1b06RrR=^y$D7&YX& z;eoZe`SI~JaYWpDNCYB8utXGq4$ck%$A*a>Ak;JwN}O0EFkqPB0U2So;Y^3!km~@r z6oz;p?Bz~`@z|HGXKh1Ei#bFvPdn0>;M;_f@sfkO72s|+n$4Tp(hPeg6l29)+zg@c zj8WfOdT=P697{TGER3}K8%7KOIZ-g%@d%p06stLd0g*ks8Ao&z5kRKnruk{?lW2EJ zP0InD7zj{kiYZ8NC=P-c=}?7OFpCi>k#9mg7Ab%gO=!$aWINcPi3l-I^5G!ZIcP6J zX)BP5g>!*maXOtyWw45&6sQ|6#FDK>s$m4G%8IaG+4EOD0|iu4EC6CBkUmcb}34reND=0=MsQRq;B z-7?rS(ql4?Q{mB6a@daK@&HE{M+ei14Xu%Jn2G=+oIy)kyolr?#-J`y;SiREm^>NU zM!X} z(`Yadz;bmn{rGS&rYyUhFQShCb=8>&MLoz4`zL3zFp#xF(dB|ig_JRmy-_X^4u{a} zB!S&y530=AtY}3GbtHD7+6rSlfbN(uCoU+-2`e%x>Iso!NhYIIAcCz2#%OGfnNTL` zD>7xg%MR8FfteQy(Mo7=wMh_XWdNr`&WP-I1%KAIa@qcfXe=-uz_ZoEMuMBe(>0>; zTon541nZc_#A2f$<&d*v?w}FJu!7LH7lAv9VOb!OeP@VGI5QrHW2Q``LW|Q#KsHCk zE>S2A3Q;;*CTx4hqPdB5NCe!u1YAgRCQ{bW1vTi)-tyx(tmzyF`}eu72$ zsPPBr5G;`3c7o1)9IJ>x4eN?PZP{1|iQomB03;Z`^g}Sa^gHW|X2g9QfyV?srXU^4 zdvO+MD3N#|EosbXVT_25`Yh@rh47q||?P)b(r%0~Xux0%D7FDwQfBzYPR#5FL*i1UWLz{yd0# zRU{SI%nrf`V||7+VD=97NXwLg`&@Rr7^PsbLoA_3X}?*b<6Mcn)K@o^5hY}k#9`6W z%2N_YX%=almP7;UX_`S(E35i2p`RDiKEQQXSw#%B>Tta^BVwpJUaJeq`r@2Ave0a9 z&17mDaK)IOzP34)7Hf0MN;jL=HK(x;Twlv%wl=5dH=Mie&TZ$ezvNCfdxL{J2L?p= zGcdSga8Sh6cV28hBKI{H4m+txCys1AnXDzmSb(igB2fcY*h;AXh@_muevY@FyWyfh z;GzxZcK#$9={zQl6%LmOcxVEGBtc0{)s6s%FSw15nE!?DNU#YrSS?kEY|}@0rAiWc zWCg*O1c^6+%3Ki9EK1~TO@34ms!2$B^YYHP@j`WY^Xfa!-+a|i_%ogJ&$;TSotOON zC!ISEZ$1Af=EJM6Q(r#kh#Yrr|H)5QUnfUD`RPyPHj!r&wTN;3Y5K*a6oOCLmo}Xw zUTbZYRqb>(u<)?)DdTOAbiT{hsI&Q#|I~S|`N+vvz0!Q={YT&Pm6QMaJt9o1-A{b& ziEe!~R${4dsINxEtZ9(+exnT*pk#nf@+Jf!A8q;2KaF1tYxjqvPmDgH;(Uy5l^_jU z$DSB_qVw43lVeZ(T4Xx2C=YdhUmn`RGh-(aPPpt19~`ADa)gKp61 z>)=ZtFpnOU$*6WC6f!#>k#-k5tw+q;$WyH8hxEszYTKR?v@ABWVH zhdPHicm^G&>U#zl%EiYzeT$DR-n(eO@ni>2i5wXj>ycnGYs$W_I^tN;`DR*6={s3fzvzQnVGF zsyCL?sQ}(T&78OcWcy3Ql|55at=za+qGva!*UimcG(fOoH9 zNO$g4`U29&Rtp8 z`6NFYD<^*K%u}D4-9EcrU9oj>!-mDJofCX;DC5;&{M(FwsGzE(D`o}RY3KJp)w%p3 zHSsFr4z*?Ve|Iv>lgKlFWB%0qXx|+1S^HQ-#hd}nuoRW$0J^UY5?b2Efs2HulPCwG z{E$#QYr$aY+TF2obd|l@Kth`tUl~ygwB!=PR{*A)2~4ni}@gt{ylEqm3f z*B?#ov!DI+*hQnid`f5pwAq@Xjgo?eNz|p5R+uq8MZF>s1d}a2Oa0}O3I3Pn4J^=s z%D_PLyu*h-clhuw$0Ql-FHh~d^6=+5V~(A?PrYO7hLz*76rcBfI=Hn=$n;$hV7qH;KKk-;}3RPc)v--FQ`W>GXxHz=Ayil@NBtrxPC3 zLBD*?$VZdsPR3sM4j&nk=U5Ij(V_R+#!JwqV2g(q0vw4q|OeOO(gKh&Qe?)(8CtLGj*eAvwM(b@9LS2=z1U-?k~J~KT% zGc#ES3_Xz`$X|UDqS`y2n%TB(rnPNb%Y5XSzQr%z#D`k!9Xh<&yvUjyk8mzw;NRlJQV7x!h<>0)@UT$m^$%J0IKHYHgKwA5>6mp#$1}vY)um zYe0~HMc@1TzS#F&<)~4$SG@?7*!$HN)c-b;#=LRBxWjm-@n^$$)5yme?WUC{3oNm;g9JG(*oTm z$SJb!Hqz+#nHS861r}h3g<9rP*R%wS6`3>w%_2cauvTG0Eo-P&`R(g#pLLTCY}aUE z!-pZVe&(#(1UN?0JC)Gcx#?IZdc$$*28p5QSE!p4PfIy;7Tu@CXd2ZqM7USpXWbwd zPgh26|BW}Csx4-Dj9|Yq3-Z|6#nlw0dS-H`+JzP2m+RrT>ltN~luxJGyC8}DzboHa zt#EcF{W_d(=x&{URRwx^N%+CDT3eo($w*DQeWsh@>>iPOennMJcUiX~eshcKtI1zC z`Y=M*9pQAFOD8U2t98-rVZWAg6^m1W`RX?Gv&yC)*PSSfgg+pp_f9{4cGb=(IU&RS z)PJc9dslwrq-n|1zuW`eQgrLloo4igE@-bO@DVtxg?mZqd3Dw>^NaYbg7l`#ue>O> zVcp^T-`3-FD@lQRrR&YBZsDnljHc6NJY(*5>wkJQ`B}=S^E(A|P(6)L&s5U#Z~Eo7 z>n-SKm;dYz^v9)?=)b9*lo{x2CZNz3MiGXkGp7fM1KeJB3g}*xNd&id_I#D6y1}t} ziKru^m(EOII(GA>d-k0F=1n)hu|#mO(y`X;?5uHU?2?(8%f@cre9zwV-?ZuG=W(f2 zJl2|1vvbEl`bP;Y2EL;D7r{0vL30K_c3jj%cPQ2r7yuj7qB1p6lcF?7!Xn;h{NhG^ z2d;sf64kDMYcHX0;@rveqK*d00hMtto6|X>q(r_VKG59wtOD^QuWnXSJPbj)Mb@Rl zqCuQ7&M1yPb5>d83{Uz6c~<&T;WJC36Ytg;pM2~bx@~h-9-JiGUt48elT+p7KL5_M za)N#H>wHG;e&x9vo4#p31HeZ_Z%z3P%BdS4!vMcRlFg~k!WY=TN4H0>)VfEd`SHNS zLa^I%y2jM}*>~}%WF{@HJ3^u#N)}Rn3ev5H6hs8SI-}D!Nbh%VCEc^K5O)*w-?pjy z*B@rxe$-8kf?aapF+TxGN+;00_0*t{>f*&K&N$D$Q+$FbD5t5j9&+8{a^^SI+dQw* zY*vPdHFNSS#AoUcyO;skxj2@VmFCotIaZm%;HHrunwlCq`DE{2W6BFY`~PSpUs)vyhpa)tAODYfWF$IJW7{d-mS5`PdJiT_Y*+v~FF$ zk{JpC9-Aw`{1d;j`l{POv{Z#L|Of9LkE*JJSxIWOh;%D3IfV1k< zolG(*bYcABo=#Q@LsJH9SK3CZa&}2{yBVjOU$XBNrPnLue7a>x&%ydmp6hmSH;LX; zenCz*>#y(NQyt*W&% zeVUzPtMm7rzc&uu_?6Ck@4ovNci*ix+<4My-`Vm(6J3&+OB z^1Et_3s+aGw`Om7Avy*2i-Bi8V|~K>bN0)9HPx%%|z;Y?>{j#!|oa>?qKFIVTk zP3?Wz>Wl7DFZr%|$@^A|_p1l+g8twG@drESbw1hI`4Qt6AL`uxbmz9$8}EK?=Y8+* z{L$6M<|{k@^U}`aYt?s_JDu;TSJg*Xzn1*tZ|1U{x2x-pT-({LKJwvDbVBNB8sitM zpE1^}y`B44UuwKu9dJ7DUVXlCk2=ct$Bgq-zVn0C|1_L0e5SLa9=-jR4nYE8qTuF*@W|CpJKwoap?bst7{NXeH{cJ=8V(ajp1=OqPxUOCy=}N*J|+{NVD>2 zMN&eDhpjYEi2!l|cwr6~;Qd9&rvZcIATE1niDgPQ>y*S-m)$`{E~uxkxZr}@s{Gw{ z!39@bvG;=8<@ojs_FiEez2b`3ievR_uejpq^#`taU1M~#@wzJxsNaJs5#~dUVSK4n zjbFsWl-OW*8fy7PeBa3X0p6LL4da9It<*^8e;Ed%2~(UkN0gl6f?OmjhPqACz=Z7= z981co2*pfTBh?pQc;Uf=7hd?{k!p3McU&DYKl|$fDRi0&f&b~+So z_r&R&M3WG3X7mtbl=ViTsg2RGV%I`it?fd3BASO&U+cxloe}JqKZ|WxdZ1QZjCw(5 zI6IZA1Tk2hDK*XsEe{fo1#Wu};YT*FU3)|w-MDEdPA{r2oP-5;I|mlynL=_ZRmdkF z_7YR^d@8ye%iNbsOvk-=cw;nUd>hN$&JTd@Tv^$0le%oNz1DTlhZ6`S!kJp&5=w{ zhq2}5AqZoss5WkT=cY}DBA#d{6fpzY%)emz`TRmH5J==OUXG0ogtJz$92vnAF5DMJ zo*aipZ0NhK?~Q%`g9u80+xMNmRkc;USN#S3Pb@#blWB6H4&ou|i*DtUa`Yg7MOB0> zn5Y4#FAL^!_ZjZ=(~(dAR>oFi&P!`-Xn(lq^Q40QPdlW@y;TD9a+O!*jFZfC|D*;W zb|mv*8~+8O1Nfo2=lqOGL15FxXQYh;Srq70L=d3Yf$Hujp%KO0OsmSN`kYdf`CbuHVrdG&T0ip4W)!)6RdCQ2*M5^=EP^W(c3#b%x(0Q_ejlrTT2>%XKVDg1+_iDz zHMLp}16|;gK_tq|Q~-Iwlo_`%U{t*E&Vx-Xj0t6&97+uLm!=ZQa5xf0sZ$=5jYp{_=xy0)MT;$Ivu zh4VF(lx*1vOA`o}2zHBeNjP3iG+%MPlOM|gV_=w#&~-J}WAgzzGC(&p3;VxjAZsOS zxtW*j%hr+rZY77}@q^?0N8>~B01HQPu5jPrhJjc)60IahcD5EviP>1Tky?L!4Tyt4 zw3eMcdLd5K^!H$_G~<<~bD)vt7K_b8%|p?0>_F!kmB<#?4#uE=E7@(APsa&A6%MS9 zDa#9_>lv#o%SBRWz9HL+mfS+ISta-C#rz^u(ZdkuF)yUQlSEI$qx`UIka@fIXArS)KV&V9U zgB3v{reej)(C*2p*36wVt&34b4=;P{LIIB4CR?INrO>=%A3+UEJBsmgYMQ4cTO zyac+yC4_i?%M##T<+bIRnVBnRrf2RRN3jpf9;qaT&!3Luqxr44fuu$@mjvmDV0VS^ z@ObB43(sF_TY&trQUWv;n6B??0Pq-yqE_|o8Xh%HphqyU&}g7ZxS{VwaGR{o@JJY* zNCecb^jQmT7x{!I$(oHe1i(Y3uX*5l1KA?-XOWu9hg=-cE2k!#KH(7GrbS%N@2DHJe{(6AC^GYV*9Rk8`Ldx?CL6> zC4nmOtnK(zJ_TIvAi$|aunxxY&?+YLOPOHUikIUbd8`$k=r1kWXo1yyQv zrbN{0Nwi^Y(Pg7iY_Oxr5;)O8vxN$=orTA569rYHWDLqvl10%#cA z3OP2fdE$vDDks#XPpHQq|L_whF8?UMU%CJN``@oFf8z1S`SHXPtH_QZAP&L2G?Cs&_pJ#}RE=_B`@kUy@SxOL-+6Pr%lp?<*6 z)$g3RL&l4??da;_@1yL$fzb|B7(u-SBQ7yUa=z!iUKnP9e(0KO2#7u`mM~?w7{dZ< z!oS{t-L7yGR~c0L2F+7*55fNQYJ!YchjMCbr#U|nozAaS@~btShe7sA4ktHnf;E;u zeAhOv^;(7c?go)`Qw_Xzz#~9Dgo8+baL3BaH)fk&Dg!K-`des0GQ~6a?8KZ-3okZh z4%C1F8=n~8H$FZNBw{RM1oDHR@B{p&%twA*K*?VSKWoKzZ03>Zs|EFsHD>LmD8d|) z#}P*1<{R;Fz60Pdn-uhQ`LhT{2MErPpiBdTGXxoy(?%vXR7C{AVIPPpihcAIj5Xix zDb|pOwW{=u=sDhG?Dcsi6-Z;e(iTjZwAYwI8%tOtMw+u?RNEd=86<>^*Y+Aj zMUV)(3+e-hI{*2A>U-~%cu((v6g#_Gi=9uZdZqJ;FCOlECD8bT#~SJ1dCla(*ETor zJMT!+Oyx!|KUx{fow#SC8D0JOXUx>n0~O zT)mo*t}49d+qdpGa{v7geDmfPzJK{!w;HRT|MqdF!|R_DQwL?+`*`R6lixjVeBhg%wW@#V_(}D?d|a$L52(*Su=q{BO2;_!SI0XKnB`-u`@h8<%fEPZ`8b6=_RX7(TfRkp9P<7w z9~?=}e9E7uZ;xWB`Dw;n2ur(k-v|2sxbMSo;eBa_5Mj!&A~n4>4IH4MA#@l_u>Z!& zMaOVCWm$j?Qk(gc1i@j&9}YS+nVX!Ro_=>Yyg8S{1zTp^HD1n`Z_oS9AFw7+B>-$g5%ol36&`(z=nl~6o*s0#9uKXe?Ow=cM{9~ZS24v==(_Dr~Cf8?;Cwj(HIiNwhpeqZR(Zk&FbCi1L`yC@734T z_lTYdlA_@mv&K4On{lCW$T(^oH|{iEZQQHlD1I0t&CeTOGQMs6$T-QI_MNS(e&)6HbhD;rnYUP@`v38aTOsn8ds6lEVOoukr$%jJ{wCAscj z=|oeM92y;Hkh79=QnKe@Ff=gdV}v9m4u@SN?Q0RSUXs+v`!Y~2LGnW>&ladna>%bi z*aRC|Rt|_%ppsJD;YLHSj2*HZk_+>0#blhdf?zBIhm@+oNOJDhz5*#zEG0&4&>&}r zlj2+I&Qy<-xfGXwBtv5=45FwSbqqp6bQZ460|5BBmruQnxaC;HP0*Ya1?fF+)wsKOx%Eiqy4H6QKj1c z7=-qv92|Jm0~ObZZe?!l1x%#5xEc8Y2P60POXC#|3KHQu@}T zlz=GN3)|2Pxzp|UOk7-;zBA|#{@WwS9%VE%Pm(f0kVWdZK4v4h*Vbq1_ZTf%IcR+c zeDsJ*5_)8i#kd2DRBtDdf0vZxL)0ONl@lNRWdx>#Mdb!N=yPDVw4Hp||EK!MGkpq!!3f8aTUK5zqE9-eUx;n!;|IF_>i%AY2`{=m1H z2IO))!lQ*k00kmK<#3zY`dl-9$Y$_aCxak!$r7C<1vxL1=WecGn^}YgpjUEbpDaIY zEhRAGo%Pd1wgE}cs3_rfzrG!fQk-ZpZi|W%*bUJ*;r9&)&auGrL6N&q`YZg^yn@9RgSN@;7`@Lb!n{^B;{a` zM3C}of9y*&-#Q6JGiUzf0M*X+P#SIErgB|gKs)jH1KTJqWPX7|m&m*Qu z+NDw1iHDxo^(dACzO?AykiAAB>k$eCtEtYJKa^9|e{@R+;upyhbvfZh;2?4>lNDbE-Uwyr%#dD63mZ02oXVPH42 z0Xmz)L!5%Ak`{2`=qLecn{5|-nW7sn{q0*o{n{kr^}ZqTf)zFEzwj)uqIuWteTH=d zT_}6s#ZMEvbXxLN`||DhcUcy9ZS*|bd)J--2WfQPy&~eV}=Y7%#C^&n&&JL-EZzQ+oGIz4b-CRqx(h`fcgm(lxm0(B{%j?AXf= zhYPZm^-lX^a!WRka&ql)D{d&RU{y@02 z>Sd0r4nFg^Kka+Dw2J#k`k?+{tRnb56r`2n(JKEFfr_a9k-_)7_FndXUEEO_Lte1* z7Gwz_|4cfCoFnWq1BbXnwC!_GDTE}Gsp5)ECG6KCT360j=Pq*ebC#|z6j!%(b*-qb zL1e^+<5pi9ZHQia+;JN#k@mNutE+8wp;%AvEcNT>^mC>D(lY&!eyFsJ_n&0ZET-R{ zKmW_8p1Qg%mm7%15L`3Z)xFH`U)J3<7(@g2KrYv|`qWduJb%7z|M^gf%OSATt3#!C zxcXE3&O7=yn`D_=)zkzFu~~OMX!ae>9iK+(XR%tNHj`@x+n|+CQjzuuTRcNraM$+C z{>>lww^)ynef?>jkBxFC9eY0AE=w6Ju&-mbUL)c&(|cEilgziqI#WM}S-RtJM< zqpvL#YV85{qpnb>d&ad#Gr>@>Q2U(wc3q#Y%jfG{`$Buab+BN6_2Bb%uP&XVA8+Sq z^m}ck@izS#;?6`Ry1M)vYiSkN(!@O!J6g^|Og+2+tp-{2t5S`~nz0e#qmh~0 zIMIlde>o(GRfpO|!Wf_pgEL#{Bw_v@ooZHZV_DAbs&?x{w(6G+D6&74H*`~d1D z1L4l5;#mvq$J*MiMJoB(8#Y|q-mZ1K`ErW1C11Gg#I1!`yqVCkIZG>P3e6T8JBL%g{X;=d;v zh&TD9Xq)4Zc2SIS6{KlOfR0u*P+!%Ns%lGys-P$$9S6}$P&-(;pwTksNw+0^o>qo+ zP5z^GB3;VXXrZd8kJa{9FKIg8~6`eA|%qjk)NnW2V?tR!f4tY~B* z&Z&+_KTH-=kj1^S1mxfAO^8NGv_cSTsv})LkP*^%2$`$0>2Et@xNuCN=fu{o$*4nz z{;qusbA!x>Iru5ob6Wa2^?CYx-dlv1CSRGUqyfn7McOWHnpaNvAu=@L5twR)66hSD zBd(2>ExM{@!vr)CbQKgr_le|k)R+&kqZulCjml=}Z$#_t0R3DYHr;}d#4#bKnX-@L z9;ocThx$e;(Ru6T3`x|3+q+AvOUGsPf9PkKPzI;;bt|Ju|JEJ8WF*m2v8em(`J?Cb zOvQ7_WmDVQ1LNubPp{A`KHZ-j^S5tXxYg^esA!(3i?=4Gk}dIMvVLK1QEFa#tKOR; z$P^b`YA2d2Dtz7@cCwgcr8!i{4=v)TutGP^uTGs=EDo3M($8-@zn>tp()5hAO?=`n!USRU_l$Bk63Qv;THZeKPHp(RtZq zHquLoC<(tB& z>GOq7OY}EZ>+h8or_&9OeB?bVy1#$2PsVTC+&BAY>eNS_atj9u+?DV}QI+{5Q@Cv| zUB0H*mv)wR>Id{yJU?aexZf_VDrNQmv2WaU)6A~xtxdPi#BS2VH_m+JmwMrC)|0n< zeP-j=a*b`Hk~%mKB5UYJ&Qd+UF}0(?leG)%4c}tl-4ND(L^mtpE zkhNy}gM+IPGiC=86{)7CHU0gg-DC5+S~H`A11A>>4bXr&?NHo(K1AQ1P@housNX@c ze@Oq-T43$4?zMhwb2Nflh}mPd57daM-|`~gbUIV$PIq&tj!s>zL!F!I;b_dYQ>Q68Pipw^j}?(f(qz$P7ED82`GT#I0K(Kl zoQ-Af7^WHK2Y1P4npegMpOv7pf-{_c262ddAjxir{INB2GGm^Hu^>BUIC1A7;6L;; z%F1QHnG*#El7g?Ja-7ks9LU`J@#TCiX0<0k?#U+5wW66f%F+AlMdqv{@}0vR(sArU4mMbsoo$O>|0kj| zn_@>@I_$6Z4J{I}f7(BNt5AoYXpEu^ohgiqE?l1`Qh!Y{Qkw`vjY#|E zEvb!VkO&Lu$m36nR<_dCBd_X$%8E!8Z$&sY+*^@=XDJqfL?|gT6#A2f_J?rDOdwsu z+P^C4sq|OG*r%f0Hkdtx3L;XY?r(Dsv-6#8)Ly( zAi`822l_~1guI9<tWzQQHMYkb;MGex1 zse)r=W8z1!j%+_^cF{l}8S^z%R2Bot<~WRN(P~uYR1a6x=czx zHspWSJh2>w9YT%;v`A>vv8F_r>dPcE$zTk@mlRrG5u$0x$aoJWqCynbFqu)RkVHks zvFzid)zP-3?r^1%3Px012QA|BH?%}0@;RFX4+ImTfgAOQ5F3K7GeO9st z;wY3$G;~y^D2SG1&l-f{LgJ>>T$u@3)*@MWFUFv+IW(V<{s-uK#t?`YjhN?eAYI zrnIiAYSqZ-5l4)S9+#=oGq+7OH*G)Wn3Mb3+Q;O6{8)Q?@5#p;v%RU=zItHk(g8xv z{MlMGfBu@9n%bJR^A}9XIyhwLghA+pS=9>my+#tjB$UFVka#6Jz%pE>D8P2hRF+Ow z`FbeDnBhYm8A|5EbY2@CnEXQ6>1}VGjdI4DM}$yrD4DT~cG7Rx!LAKwR&kkynpNwQ zeNbsoI&RIk*qzVpUWC$1IIZ|{1LZx4ZAbNi>q)*qMFTD*z zc4g&P5zy0U%H|^b+my)(YMt5u&%AKYT&Q-dJ?ctz4SJbwRG)=L@I_Ab?^It^cdPr< zgX$&qGxdu4wR#VYS8ckJmFQubcfaVNn6Q}B^Fz>?%U_6Z_qt!A8zWz6@n^hgkre=Q zbE=VZRmkZLmXOVHt<1x;F*Ix7z_(13BHm$CSr3g!@neb@&R5|tkxM~xQ^pROr-~&WIVA2A*o-VdatG&lKIpdg|2DMtxgl_2t!9RDVJS4@~KdYWZ7JZRI29 zMP7`Y7queO5v*Hy{XDX{T0ex<7nzQ(osLeQxivcdEaoCOkxx1+vPIuZqIwkl>oO&I zwlgy7(o-)Xlv+lSUzDEyapcFb>Q6+@uHM4wi(YV!>vO&V+y~uo2dkC|b~K#G=+gHX zEf~_iib!W-)8~0a`J$(^@x{nX_P5_-!&8DHsSj|J#7WH_)-%>a8wRyr#3AP{zVvYS z1D3_!*WS(852IYo+tVWt(9p*`QP0GAW8*9eE)=+jw++3e|KHo&cJ3V8vuA40ovsmlzT*?O-(w8sZmEK*dz2XY}t@PNA z%NKLp{n&&9XfM+GaRg^Q3?_ekn0)AqsUnZQ;9<_u^k2OG(seSM2JMBw%YAA6&Sj}R z%A*VL?fsnbHKY2_@pu*^EGj3>9>FNhf-eO21&*2q?D-X$fi7XMY|wdU=`9yeAYw_%kg2g?AclRZJ$1ESLt3K66%BYR>un}3hT6MuYjxi z-5rxmHqc&Ypljj#f~9Y|9TMx~*l{@o+XB1+6F0~>z3tHB_Whhiq>vYPgxUzT=^ph% zIEd=>lEX6&9n6_?IBH7wxzo`59FttVVlRQWJL6CiX7^PTAq9WmN0;ZQstnL{Stye7 z&y*|wvXLf=7^HpK$Q6b}IUSLeA$sND!l7!ov)zb@n)sg~G=q)on951Em?RBxMy63G z$-aLC$d=tR8@a>jL40LX>0)GdOT!~5UbB`D^!07%=xFu!d$PIQ%Jz>G@)ZfwG zu`-v-dip)NO0irL`aB4;Yjwyxd4KYW9mCax3|d7#mwxThrGC*5g?whwS+H^5tKkbjB#0 z>^Mnv_v@OP^o;c+cQ!u7Z+ULz{Z z+uSk=wg>+K5IygBbe)Zk?id~IoY(DJ>f>a0yq!bdVjF8LSc;(|!hdVOVL#@b?-d<@ zv@-}mu;B4Dc0+HF_Lq};NkYkh;K2{xGR7Z%&ZqOFwP$7jZy278y{aGOsE&)s?QndN zbmnaM8NQ4~j_=IRv7}SIjyszlf{PdDeTHdH>E)F$}Cy??q067z!5^`K}sTQ}iobsbv z!^y@N!DwxMLgE<&#(XkJ4H|kFoU#VSAtqsmT*PfMhqsZTh?`;#^+d=>=5wfJ5MQzL zbbfHMarKlTZ5DH5A+r#4z!`Tp_ z?i{?DIGsi!kWPD`0bn-)T`7*`c--bhC}2#}a%fDH2~A5TP172AN^_!F0^_vXSuf)# z?GQ+Zq$SD0xMv!z<46z#AT%%YlL(!FCPInRgoP_p0zwpnLTFCN+`cp1Pd^gs8^l9n zaY&4Prf0cNNUF}E+9V{}vIJgwV%br;q|on%fp;C}5cmdgN0YBZmt~z{z=YL7+=n4d z;sOqiAYh`Rk8@9X0IpTDC;^7~Pbh|bmR*2xf9A@D<1PS>I}w(Hj2N1s(_*FRLI$R} zI*r-b-NxJ^%@|d}W(G!Go^%Aa@zV=TgQqJ=aJmdX6P43(_!LOvMGQ`;oC7H$cFP?K zCC0{4Chs{vrdJmjN2iAp%Jg5i!x~5I(p%}gSiryP-pwv4D=@4hkLG8Bk`) z;L@GwE&PlqN2h^GqoMV4SKVoav=UmUY0yrk4mk=_&9IA$)P$)>cN8e+&B-%QO4G3m zqWmaF2V+pwuR?GTNy`wn;6;NDg2oumuUP=HAFJ&fnoL*`&Bp z3DjW^zJz!uepXJn`8eJ&iLwvDQGV`HmoU&?Wu+?~dz&9GA-~^E>mYj;e z$+CnsPUO<9Imv;+hwM=CCL8jAt|eq9*+iHW9570_T;wK39&9!V2jPtz^9l8hl7auC zG7?F!X~50}3wBC?NeYxMZ#bFBl8CPq#Q6zUBtZmDlLApWVl#U)(537%`*r~&iNk*q41__)xVtq` z57kmoY(u4$+vZ4_97&N;{K7;`%cD;Qp~XsA4`eAyjY#-8rEF@&sUqBH{@lwYP7%!{ zlvSrsqYJD+FN6`93(ii_7(8Xa4`ng!^NPON*+0|#%~v`YsFgw#v|{a*xWOr70)V^g?!^Gr+O84|XpYgoSZj znjpx5l9L~h6pMKh4OwvVA+ceHG-R;iu7OEI9K|9&l1FpQB1lNnG-2>GSs<;7oH=q3 zcyV$P#9~U##3x>2F;4-)ZmQ6+&wZLW<~n@P4&u@wXaC#;+`_`ypeoL#Td6($nbiI4~-4+YYs==52uDCz`Zq+vib zDcLoCKeMZpifKHiUD%vs*j61TGU8^J{741p3WK!-;E+&m^IL{Fk;u$mR^)TnLuVUS zTMX^rtf3HoEVn{s7a-Sr%nHug_n4g}OE#RGpR95n&QMl(MSl*xYq}_xfn*QWK9PQ9bGVb|tqfLSB?AH!SbzJf>x4M^i8IjKiU&z4Or| zddcE=O%Nu@Y&7lXR`O#&qdz&>(@?*(qibao=RZE~uwTs?N(cPaa=R9`i$2OB*OyDO zeDAYaLiRPYwe7Quut)TX^k5NVndyUH9#GqW%1U1t0tqFcQ2YVq0LBGP8c;sIl;Scf)l=o7Xce^wfc0p^n_*IV@14Y zuD9*&cGUM9^!}|M#7))>8|g9kl>Tg4C$iZ1Vz}CStYxjkdJhBciTdSGeW=tMis>O= z(x*@H2ZR37v*+NbU!Zh?rQWBIolyFr$A62r!uKRfM7_7H_e8zATkc&D6xAdg<$@(++4hX$P+~tW> z>3vW>pFDW)p}W0Ly6(o)0E{^?%{suYM|W0gNZc`xV=UJ$-*e4x`PQ!6k-XdI_=~#- z^9_tW$6dnTfN65wB%SZO_EX%qxf1Hy%jrq{WxILXFPZOV+>dqru5#bc@O~1r9P=rQ zdmFC1yd{km_uIDbHGYEYS+1MFcqxM;cuRiD`P=I$8rY?{4PXTB&+dDJ`>t`{<@BU% z1-Fl31~Gk@B_4I?5brZFWjJ|T_V3^KJ?m$G@$b5yKp4{sJ`j(DTPX3(ao>u4z56Zs z5qyG}qvyCuIH?QreyGIzWYDW?eYXQE%#nOoS^KEBYjH;!y@BBr)4mS3 zD-3Q)`#$`J`F;og9Of?kKf`;q3s1cEyA(R{S+9}6wI*#Tv%~mJ;rBZ6{E|2GjprkT zx%%{;BF9o8nb^XY;Blv%!^!Np|%4{=oX_i-E%?vR;8j~RPm-# z@g~d$%#BJV2$xvLtWR!J2a$q75}7-cs(w_dnqCYwTz92X4Vy4GE0ra#M!uV#VAvrC zIcl&K(*|5er&9BtQmShT^D;vDcPQ0UkJ-nC_}3UNh_inK!wu;V;x;^pd5G@Bd%<3% z#&(1L0i`AgKXEHvXq{41;5bFNBiiUn;Byq|F9UW3Fe^vtzQ7+#+N)kw>bNaRt^S%) zCsbodZ!KX@UZT{-&oWGc+bO_(bPopHPrU=PU#ZjHRO-BEm70z#bv|)#Bc1IpD76Ee zKDkM$3yAB2bxQ5LU#W|DUj+Q_6-r&)gxN>ad{L=OuTyHz4$MPJT?YJRxL-~hmmg5- zO5(k835GPTI$x=)iT`TCTtnKQ#_c-7UVjZ!NT*UalCPU?RO&Mul=>&)`W!Gf6Xsv) zF^?+s`G8Wlj4Sn}*Oj{UT%~Tq+`qn#2 zJ%V`@TplI-xAA}MR;9i}y!)fed`Nz#4lE~TD+Q>kZGDfNR3m3r=erCx|A^)tf# z{5qxf6Xq4%e~tfZdzE?}x8J8Qr2hwC{`jg=Z*5TO?KhQrXI!bjkp8>i_3mp*9SC56 zdyn^f#PQw>7~l?$DmBAEp-*6%J{5C7>A;nWJBXOOlnx_FA^bI1_@^-VN1uh+9@vT< zN+)=y?`7F_jndUCl&)){i!8wquDA~Kg3@g_D&0A#bl3Sx_u$v_rqaD{C@9ig=%Pyv z)hj(BX1CJwUsHPOR;7<_ReB}ikJ*9&e$`$KVUEM^cwpCDiP^98TKrDLoJ?FBMlrb2 z*7RoVo9|cpOyJIaL225To<6AbR@~0tp!COydt0Z{JHVHs(7VQ!-c6WGu+x_Go>!H= ze1+0iyr}f2QkWe|Uv(pfxUPAKK^4Cni0|t!D}5jF-M3rmZ|uQ5rSv!Pd+31DkDRac zqo2jRsq|ySyN@`Z*{}4Ag!xIe(m(CQ+>0Tem+x2lXKk1ZmHq|pzu>)}@BOQkeuem6 zc~s|o+sQSMSvGCREIVFG0G9UVj!HrX1D|+#D?2M`E7}sud*QeLDd$6D3+T{jNg=_cWzR