dev
dart 301 lines 10.3 KB
Raw
1 import 'dart:convert';
2 import 'dart:developer';
3
4 import 'package:cake_wallet/.secrets.g.dart' as secrets;
5 import 'package:cake_wallet/buy/buy_provider.dart';
6 import 'package:cake_wallet/buy/buy_quote.dart';
7 import 'package:cake_wallet/buy/pairs_utils.dart';
8 import 'package:cake_wallet/buy/payment_method.dart';
9 import 'package:cake_wallet/entities/fiat_currency.dart';
10 import 'package:cake_wallet/generated/i18n.dart';
11 import 'package:cake_wallet/routes.dart';
12 import 'package:cake_wallet/src/screens/connect_device/connect_device_page.dart';
13 import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
14 import 'package:cake_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart';
15 import 'package:cw_core/utils/proxy_wrapper.dart';
16 import 'package:cake_wallet/utils/show_pop_up.dart';
17 import 'package:cw_core/crypto_currency.dart';
18 import 'package:cw_core/utils/print_verbose.dart';
19 import 'package:cw_core/wallet_base.dart';
20 import 'package:cw_core/wallet_type.dart';
21 import 'package:flutter/material.dart';
22 import 'package:url_launcher/url_launcher.dart';
23
24 class RobinhoodBuyProvider extends BuyProvider {
25 RobinhoodBuyProvider({
26 required WalletBase wallet,
27 bool isTestEnvironment = false,
28 HardwareWalletViewModel? hardwareWalletVM,
29 }) : super(
30 wallet: wallet,
31 isTestEnvironment: isTestEnvironment,
32 hardwareWalletVM: hardwareWalletVM,
33 supportedCryptoList: supportedCryptoToFiatPairs(
34 notSupportedCrypto: _notSupportedCrypto, notSupportedFiat: _notSupportedFiat),
35 supportedFiatList: supportedFiatToCryptoPairs(
36 notSupportedFiat: _notSupportedFiat, notSupportedCrypto: _notSupportedCrypto),
37 );
38
39 static const _baseUrl = 'applink.robinhood.com';
40 static const _cIdBaseUrl = 'exchange-helper.cakewallet.com';
41 static const _apiBaseUrl = 'api.robinhood.com';
42 static const _assetsPath = '/catpay/v1/supported_currencies/';
43
44 static List<CryptoCurrency> _supportedCrypto = [];
45 static final List<FiatCurrency> _supportedFiat = [FiatCurrency.usd];
46
47 static final List<CryptoCurrency> _notSupportedCrypto = [];
48
49 static final List<FiatCurrency> _notSupportedFiat =
50 FiatCurrency.all.where((fiat) => !_supportedFiat.contains(fiat)).toList();
51
52 @override
53 String get title => 'Robinhood Connect';
54
55 @override
56 String get providerDescription => S.current.robinhood_option_description;
57
58 @override
59 String get lightIcon => 'assets/images/robinhood_light.png';
60
61 @override
62 String get darkIcon => 'assets/images/robinhood_dark.png';
63
64 @override
65 bool get isAggregator => false;
66
67 String get _applicationId => secrets.robinhoodApplicationId;
68
69 String get _apiSecret => secrets.exchangeHelperApiKey;
70
71 Future<List<CryptoCurrency>> getSupportedAssets() async {
72 final uri = Uri.https(_apiBaseUrl, '$_assetsPath', {'applicationId': _applicationId});
73
74 try {
75 final response =
76 await ProxyWrapper().get(clearnetUri: uri, headers: {'accept': 'application/json'});
77
78 if (response.statusCode == 200) {
79 final responseData = jsonDecode(response.body) as Map<String, dynamic>;
80 final pairs = responseData['cryptoCurrencyPairs'] as List<dynamic>;
81
82 final supportedAssets = <CryptoCurrency>[];
83
84 for (final item in pairs) {
85 String code = item['assetCurrency']['code'] as String;
86 if (code == 'AVAX') code = 'AVAXC';
87 try {
88 final currency = CryptoCurrency.fromString(code);
89 supportedAssets.add(currency);
90 } catch (e) {
91 log('Robinhood: Unknown asset code "$code" - skipped');
92 }
93 }
94 return supportedAssets;
95 } else {
96 return [];
97 }
98 } catch (e) {
99 log('Robinhood: Failed to fetch supported assets: $e');
100 return [];
101 }
102 }
103
104 Future<String> getSignature(String message) async {
105 switch (wallet.type) {
106 case WalletType.ethereum:
107 case WalletType.polygon:
108 case WalletType.base:
109 case WalletType.arbitrum:
110 case WalletType.bsc:
111 case WalletType.solana:
112 case WalletType.tron:
113 case WalletType.dogecoin:
114 return wallet.signMessage(message);
115 case WalletType.litecoin:
116 case WalletType.bitcoin:
117 case WalletType.bitcoinCash:
118 return wallet.signMessage(message, address: wallet.walletAddresses.address);
119 case WalletType.monero:
120 case WalletType.none:
121 case WalletType.haven:
122 case WalletType.nano:
123 case WalletType.banano:
124 case WalletType.wownero:
125 case WalletType.zano:
126 case WalletType.zcash:
127 case WalletType.decred:
128 throw Exception("Wallet Type ${wallet.type.name} is not available for Robinhood");
129 }
130 }
131
132 Future<String> getConnectId() async {
133 final walletAddress = wallet.walletAddresses.address;
134 final valid_until = (DateTime.now().millisecondsSinceEpoch / 1000).round() + 10;
135 final message = "$_apiSecret:${valid_until}";
136
137 final signature = await getSignature(message);
138
139 final uri = Uri.https(_cIdBaseUrl, "/api/robinhood");
140
141 final response = await ProxyWrapper().post(
142 clearnetUri: uri,
143 headers: {'Content-Type': 'application/json'},
144 body: json
145 .encode({'valid_until': valid_until, 'wallet': walletAddress, 'signature': signature}),
146 );
147
148 if (response.statusCode == 200) {
149 return (jsonDecode(response.body) as Map<String, dynamic>)['connectId'] as String;
150 } else {
151 throw Exception('Provider currently unavailable. Status: ${response.statusCode}');
152 }
153 }
154
155 Future<Uri> requestProviderUrl() async {
156 final connectId = await getConnectId();
157 final networkName = wallet.currency.fullName?.toUpperCase().replaceAll(" ", "_");
158
159 return Uri.https(_baseUrl, '/u/connect', <String, dynamic>{
160 'applicationId': _applicationId,
161 'connectId': connectId,
162 'walletAddress': wallet.walletAddresses.address,
163 'userIdentifier': wallet.walletAddresses.address,
164 'supportedNetworks': networkName
165 });
166 }
167
168 Future<void>? launchProvider(
169 {required BuildContext context,
170 required Quote quote,
171 required double amount,
172 required bool isBuyAction,
173 required String cryptoCurrencyAddress,
174 String? countryCode}) async {
175 if (wallet.isHardwareWallet) {
176 if (!hardwareWalletVM!.isConnected(wallet.walletInfo.type)) {
177 await Navigator.of(context).pushNamed(
178 Routes.connectDevices,
179 arguments: ConnectDevicePageParams(
180 walletType: wallet.walletInfo.type,
181 hardwareWalletType: wallet.walletInfo.hardwareWalletType!,
182 onConnectDevice: (context, hwwVM) {
183 hwwVM.initWallet(wallet);
184 Navigator.of(context).pop();
185 },
186 isReconnect: false,
187 ),
188 );
189 if (!hardwareWalletVM!.isConnected(wallet.walletInfo.type)) {
190 return;
191 }
192 } else {
193 hardwareWalletVM!.initWallet(wallet);
194 }
195 }
196
197 try {
198 final uri = await requestProviderUrl();
199 await launchUrl(uri, mode: LaunchMode.externalApplication);
200 } catch (e) {
201 await showPopUp<void>(
202 context: context,
203 builder: (context) => AlertWithOneAction(
204 alertTitle: "Robinhood Connect",
205 alertContent: '${S.of(context).buy_provider_unavailable}: $e',
206 buttonText: S.of(context).ok,
207 buttonAction: () => Navigator.of(context).pop()),
208 );
209 }
210 }
211
212 @override
213 Future<List<Quote>?> fetchQuote(
214 {required CryptoCurrency cryptoCurrency,
215 required FiatCurrency fiatCurrency,
216 required double amount,
217 required bool isBuyAction,
218 required String walletAddress,
219 PaymentType? paymentType,
220 String? customPaymentMethodType,
221 String? countryCode}) async {
222 String? paymentMethod;
223
224 if (_supportedCrypto.isEmpty) _supportedCrypto = await getSupportedAssets();
225 if (_supportedCrypto.isNotEmpty && !(_supportedCrypto.contains(cryptoCurrency))) return null;
226
227 if (paymentType != null && paymentType != PaymentType.all) {
228 paymentMethod = normalizePaymentMethod(paymentType);
229 if (paymentMethod == null) return null;
230 }
231
232 final action = isBuyAction ? 'buy' : 'sell';
233 log('Robinhood: Fetching $action quote: ${isBuyAction ? cryptoCurrency.title : fiatCurrency.name.toUpperCase()} -> ${isBuyAction ? fiatCurrency.name.toUpperCase() : cryptoCurrency.title}, amount: $amount paymentMethod: $paymentMethod');
234
235 final queryParams = {
236 'applicationId': _applicationId,
237 'fiatCode': fiatCurrency.name,
238 'assetCode': cryptoCurrency.title,
239 'fiatAmount': amount.toString(),
240 if (paymentMethod != null) 'paymentMethod': paymentMethod,
241 };
242
243 final uri =
244 Uri.https('api.robinhood.com', '/catpay/v1/${cryptoCurrency.title}/quote/', queryParams);
245
246 try {
247 final response =
248 await ProxyWrapper().get(clearnetUri: uri, headers: {'accept': 'application/json'});
249
250 final responseData = jsonDecode(response.body) as Map<String, dynamic>;
251
252 if (response.statusCode == 200) {
253 final paymentType = _getPaymentTypeByString(responseData['paymentMethod'] as String?);
254 final quote = Quote.fromRobinhoodJson(responseData, isBuyAction, paymentType);
255 quote.setFiatCurrency = fiatCurrency;
256 quote.setCryptoCurrency = cryptoCurrency;
257 return [quote];
258 } else {
259 if (responseData.containsKey('message')) {
260 log('Robinhood Error: ${responseData['message']}');
261 } else {
262 printV('Robinhood Failed to fetch $action quote: ${response.statusCode}');
263 }
264 return null;
265 }
266 } catch (e) {
267 log('Robinhood: Failed to fetch $action quote: $e');
268 return null;
269 }
270
271 // Supported payment methods:
272 // ● buying_power
273 // ● crypto_balance
274 // ● debit_card
275 // ● bank_transfer
276 }
277
278 String? normalizePaymentMethod(PaymentType paymentMethod) {
279 switch (paymentMethod) {
280 case PaymentType.creditCard:
281 return 'debit_card';
282 case PaymentType.debitCard:
283 return 'debit_card';
284 case PaymentType.bankTransfer:
285 return 'bank_transfer';
286 default:
287 return null;
288 }
289 }
290
291 PaymentType _getPaymentTypeByString(String? paymentMethod) {
292 switch (paymentMethod) {
293 case 'debit_card':
294 return PaymentType.debitCard;
295 case 'bank_transfer':
296 return PaymentType.bankTransfer;
297 default:
298 return PaymentType.unknown;
299 }
300 }
301 }