refactor: delete dead Cake Pay/buy/exchange code; split di setup()

- Cake Pay: 36 files (~5.3k LOC) plus DI registrations, routes, and UI
  branches (feature was hard-disabled and unreachable)
- unreachable buy providers: robinhood, dfx, onramper, meld, kryptonim
  (~1.7k LOC); MoonPay untouched
- exchange providers that only existed as commented-out wiring:
  changenow, sideshift, chainflip, exolix, swaptrade, letsexchange,
  stealthex, xoswap, swapsxyz, thorchain, simpleswap (~5.2k LOC);
  Trocador untouched; ExchangeProviderDescription values and Hive
  adapters kept so stored trades and orders still deserialize
- di.dart setup() split from one 1,452-line function into 10 sequential
  chunk functions; registration execution order verified identical
  before and after (251 register calls)
This commit is contained in:
jwinterm
2026-08-15 16:29:03 -04:00
parent 71706314fb
commit adece963c3
75 changed files with 74 additions and 13200 deletions
-162
View File
@@ -22,19 +22,6 @@ extension RecommendationTitle on ProviderRecommendation {
}
}
ProviderRecommendation? getRecommendationFromString(String title) {
switch (title) {
case 'BEST RATE':
return ProviderRecommendation.bestRate;
case 'LowKyc':
return ProviderRecommendation.lowKyc;
case 'SuccessRate':
return ProviderRecommendation.successRate;
default:
return null;
}
}
class Quote extends SelectableOption {
Quote({
required this.rate,
@@ -135,69 +122,6 @@ class Quote extends SelectableOption {
set setLimits(Limits limits) => this.limits = limits;
factory Quote.fromOnramperJson(Map<String, dynamic> json, bool isBuyAction,
Map<String, dynamic> metaData, PaymentType paymentType, String? customPaymentMethodType) {
final rate = _toDouble(json['rate']) ?? 0.0;
final networkFee = _toDouble(json['networkFee']) ?? 0.0;
final transactionFee = _toDouble(json['transactionFee']) ?? 0.0;
final feeAmount = double.parse((networkFee + transactionFee).toStringAsFixed(2));
final rampId = json['ramp'] as String? ?? '';
final rampData = metaData[rampId] ?? {};
final rampName = rampData['displayName'] as String? ?? '';
final rampIconPath = rampData['svg'] as String? ?? '';
final recommendations = json['recommendations'] != null
? List<String>.from(json['recommendations'] as List<dynamic>)
: <String>[];
final enumRecommendations = recommendations
.map((e) => getRecommendationFromString(e))
.whereType<ProviderRecommendation>()
.toList();
final availablePaymentMethods = json['availablePaymentMethods'] as List<dynamic>? ?? [];
double minLimit = 0.0;
double maxLimit = double.infinity;
for (var paymentMethod in availablePaymentMethods) {
if (paymentMethod is Map<String, dynamic>) {
final details = paymentMethod['details'] as Map<String, dynamic>?;
if (details != null) {
final limits = details['limits'] as Map<String, dynamic>?;
if (limits != null && limits.isNotEmpty) {
final firstLimitEntry = limits.values.first as Map<String, dynamic>?;
if (firstLimitEntry != null) {
minLimit = _toDouble(firstLimitEntry['min'])?.roundToDouble() ?? 0.0;
maxLimit = _toDouble(firstLimitEntry['max'])?.roundToDouble() ?? double.infinity;
break;
}
}
}
}
}
return Quote(
rate: rate,
feeAmount: feeAmount,
networkFee: networkFee,
transactionFee: transactionFee,
payout: json['payout'] as double? ?? 0.0,
rampId: rampId,
rampName: rampName,
rampIconPath: rampIconPath,
paymentType: paymentType,
customPaymentMethodType: customPaymentMethodType,
quoteId: json['quoteId'] as String? ?? '',
recommendations: enumRecommendations,
provider: ProvidersHelper.getProviderByType(ProviderType.onramper),
isBuyAction: isBuyAction,
limits: Limits(min: minLimit, max: maxLimit),
);
}
factory Quote.fromMoonPayJson(
Map<String, dynamic> json, bool isBuyAction, PaymentType paymentType) {
final rate = isBuyAction
@@ -233,92 +157,6 @@ class Quote extends SelectableOption {
);
}
factory Quote.fromDFXJson(
Map<String, dynamic> json,
bool isBuyAction,
PaymentType paymentType,
) {
final rate = _toDouble(json['exchangeRate']) ?? 0.0;
final fees = json['fees'] as Map<String, dynamic>;
final minVolume = _toDouble(json['minVolume']) ?? 0.0;
final maxVolume = _toDouble(json['maxVolume']) ?? double.infinity;
return Quote(
rate: isBuyAction ? rate : 1 / rate,
feeAmount: _toDouble(json['feeAmount']) ?? 0.0,
networkFee: _toDouble(fees['network']) ?? 0.0,
transactionFee: _toDouble(fees['rate']) ?? 0.0,
payout: _toDouble(json['payout']) ?? 0.0,
paymentType: paymentType,
recommendations: [ProviderRecommendation.lowKyc],
provider: ProvidersHelper.getProviderByType(ProviderType.dfx),
isBuyAction: isBuyAction,
limits: Limits(min: minVolume, max: maxVolume),
);
}
factory Quote.fromRobinhoodJson(
Map<String, dynamic> json, bool isBuyAction, PaymentType paymentType) {
final networkFee = json['networkFee'] as Map<String, dynamic>;
final processingFee = json['processingFee'] as Map<String, dynamic>;
final networkFeeAmount = _toDouble(networkFee['fiatAmount']) ?? 0.0;
final transactionFeeAmount = _toDouble(processingFee['fiatAmount']) ?? 0.0;
final feeAmount = double.parse((networkFeeAmount + transactionFeeAmount).toStringAsFixed(2));
return Quote(
rate: _toDouble(json['price']) ?? 0.0,
feeAmount: feeAmount,
networkFee: _toDouble(networkFee['fiatAmount']) ?? 0.0,
transactionFee: _toDouble(processingFee['fiatAmount']) ?? 0.0,
payout: _toDouble(json['cryptoAmount']) ?? 0.0,
paymentType: paymentType,
recommendations: [],
provider: ProvidersHelper.getProviderByType(ProviderType.robinhood),
isBuyAction: isBuyAction,
limits: Limits(min: 0.0, max: double.infinity),
);
}
factory Quote.fromMeldJson(Map<String, dynamic> json, bool isBuyAction, PaymentType paymentType) {
final quotes = json['quotes'][0] as Map<String, dynamic>;
return Quote(
rate: quotes['exchangeRate'] as double? ?? 0.0,
feeAmount: quotes['totalFee'] as double? ?? 0.0,
networkFee: quotes['networkFee'] as double? ?? 0.0,
transactionFee: quotes['transactionFee'] as double? ?? 0.0,
payout: quotes['payout'] as double? ?? 0.0,
paymentType: paymentType,
recommendations: [],
provider: ProvidersHelper.getProviderByType(ProviderType.meld),
isBuyAction: isBuyAction,
limits: Limits(min: 0.0, max: double.infinity),
);
}
factory Quote.fromKryptonimJson(
Map<String, dynamic> json, bool isBuyAction, PaymentType paymentType) {
final fees = json['fees'] as Map<String, dynamic>;
// final rate = _toDouble(json['rate']) ?? 0.0;
final limits = json['limits'] as Map<String, dynamic>;
final minLimit = _toDouble(limits['min_amount']) ?? 0.0;
final maxLimit = _toDouble(limits['max_amount']) ?? double.infinity;
final convertedAmount = _toDouble(json['converted_amount']) ?? 0.0;
final amount = _toDouble(json['amount']) ?? 0.0;
final calculatedRate = amount / convertedAmount;
return Quote(
rate: calculatedRate,
feeAmount: _toDouble(fees['totalFee']) ?? 0.0,
networkFee: _toDouble(fees['network_fee']) ?? 0.0,
transactionFee: _toDouble(fees['operation_fee']) ?? 0.0,
payout: _toDouble(json['amount']) ?? 0.0,
paymentType: paymentType,
recommendations: [],
provider: ProvidersHelper.getProviderByType(ProviderType.kriptonim),
isBuyAction: isBuyAction,
limits: Limits(min: minLimit, max: maxLimit));
}
static double? _toDouble(dynamic value) {
if (value is int) {
return value.toDouble();
-421
View File
@@ -1,421 +0,0 @@
import 'dart:convert';
import 'dart:developer';
import 'package:hash_wallet/buy/buy_provider.dart';
import 'package:hash_wallet/buy/buy_quote.dart';
import 'package:hash_wallet/buy/pairs_utils.dart';
import 'package:hash_wallet/buy/payment_method.dart';
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/connect_device/connect_device_page.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:hash_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
class DFXBuyProvider extends BuyProvider {
DFXBuyProvider({
required WalletBase wallet,
bool isTestEnvironment = false,
HardwareWalletViewModel? hardwareWalletVM,
}) : super(
wallet: wallet,
isTestEnvironment: isTestEnvironment,
hardwareWalletVM: hardwareWalletVM,
supportedCryptoList: supportedCryptoToFiatPairs(
notSupportedCrypto: _notSupportedCrypto, notSupportedFiat: _notSupportedFiat),
supportedFiatList: supportedFiatToCryptoPairs(
notSupportedFiat: _notSupportedFiat, notSupportedCrypto: _notSupportedCrypto),
);
static const _baseUrl = 'api.dfx.swiss';
// static const _signMessagePath = '/v1/auth/signMessage';
static const _authPath = '/v1/auth';
static const walletName = 'CakeWallet';
static final List<CryptoCurrency> _supportedCrypto = [
CryptoCurrency.xmr,
CryptoCurrency.btc,
CryptoCurrency.eth,
CryptoCurrency.maticpoly,
];
static final List<CryptoCurrency> _notSupportedCrypto = CryptoCurrency.all
.where((crypto) => !_supportedCrypto.contains(crypto) || ["ETH", "POL"].contains(crypto.tag))
.toList();
static final List<FiatCurrency> _supportedFiat = [FiatCurrency.chf, FiatCurrency.eur];
static final List<FiatCurrency> _notSupportedFiat =
FiatCurrency.all.where((fiat) => !_supportedFiat.contains(fiat)).toList();
@override
String get title => 'DFX.swiss';
@override
String get providerDescription => S.current.dfx_option_description;
@override
String get lightIcon => 'assets/images/dfx_light.png';
@override
String get darkIcon => 'assets/images/dfx_dark.png';
@override
bool get isAggregator => false;
String get blockchain {
switch (wallet.type) {
case WalletType.bitcoin:
return 'Bitcoin';
default:
return walletTypeToString(wallet.type);
}
}
Future<String> getSignMessage(String walletAddress) async =>
"By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_of_the_provided_Blockchain_address._Your_ID:_$walletAddress";
// Lets keep this just in case, but we can avoid this API Call
// Future<String> getSignMessage() async {
// final uri = Uri.https(_baseUrl, _signMessagePath, {'address': walletAddress});
//
// final response = await http.get(uri, headers: {'accept': 'application/json'});
//
// if (response.statusCode == 200) {
// final responseBody = jsonDecode(response.body);
// return responseBody['message'] as String;
// } else {
// throw Exception(
// 'Failed to get sign message. Status: ${response.statusCode} ${response.body}');
// }
// }
Future<String> auth(String walletAddress) async {
final signMessage = await getSignature(await getSignMessage(walletAddress), walletAddress);
final requestBody = jsonEncode({
'wallet': walletName,
'address': walletAddress,
'signature': signMessage,
});
final uri = Uri.https(_baseUrl, _authPath);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: {'Content-Type': 'application/json'},
body: requestBody,
);
if (response.statusCode == 201) {
final responseBody = jsonDecode(response.body);
return responseBody['accessToken'] as String;
} else if (response.statusCode == 403) {
final responseBody = jsonDecode(response.body);
final message = responseBody['message'] ?? 'Service unavailable in your country';
throw Exception(message);
} else {
throw Exception('Failed to sign up. ${_getErrorMessage(response.statusCode, response.body)}');
}
}
Future<String> getSignature(String message, String walletAddress) async {
switch (wallet.type) {
case WalletType.ethereum:
case WalletType.polygon:
case WalletType.base:
case WalletType.arbitrum:
case WalletType.bsc:
return wallet.signMessage(message);
case WalletType.monero:
case WalletType.litecoin:
case WalletType.bitcoin:
case WalletType.bitcoinCash:
return wallet.signMessage(message, address: walletAddress);
default:
throw Exception("WalletType is not available for DFX ${wallet.type}");
}
}
Future<Map<String, dynamic>> fetchFiatCredentials(String fiatCurrency) async {
final url = Uri.https(_baseUrl, '/v1/fiat');
try {
final response = await ProxyWrapper().get(
clearnetUri: url,
headers: {'accept': 'application/json'});
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as List<dynamic>;
for (final item in data) {
if (item['name'] == fiatCurrency) return item as Map<String, dynamic>;
}
log('DFX does not support fiat: $fiatCurrency');
return {};
} else {
log('DFX Failed to fetch fiat currencies: ${response.statusCode}');
return {};
}
} catch (e) {
printV('DFX Error fetching fiat currencies: $e');
return {};
}
}
Future<Map<String, dynamic>> fetchAssetCredential(String assetsName) async {
final url = Uri.https(_baseUrl, '/v1/asset', {'blockchains': blockchain});
try {
final response = await ProxyWrapper().get(clearnetUri: url, headers: {'accept': 'application/json'});
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
if (responseData is List && responseData.isNotEmpty) {
for (final i in responseData) {
if (assetsName.toLowerCase() == i["dexName"].toString().toLowerCase()) {
return i as Map<String, dynamic>;
}
}
return responseData.first as Map<String, dynamic>;
} else if (responseData is Map<String, dynamic>) {
return responseData;
} else {
log('DFX: Does not support this asset name : ${blockchain}');
}
} else {
log('DFX: Failed to fetch assets: ${response.statusCode}');
}
} catch (e) {
log('DFX: Error fetching assets: $e');
}
return {};
}
Future<List<PaymentMethod>> getAvailablePaymentTypes(
String fiatCurrency, CryptoCurrency cryptoCurrency, bool isBuyAction) async {
final List<PaymentMethod> paymentMethods = [];
if (isBuyAction) {
final fiatBuyCredentials = await fetchFiatCredentials(fiatCurrency);
if (fiatBuyCredentials.isNotEmpty) {
fiatBuyCredentials.forEach((key, value) {
if (key == 'limits') {
final limits = value as Map<String, dynamic>;
limits.forEach((paymentMethodKey, paymentMethodValue) {
final min = _toDouble(paymentMethodValue['minVolume']);
final max = _toDouble(paymentMethodValue['maxVolume']);
if (min != null && max != null && min > 0 && max > 0) {
final paymentMethod = PaymentMethod.fromDFX(
paymentMethodKey, _getPaymentTypeByString(paymentMethodKey));
paymentMethods.add(paymentMethod);
}
});
}
});
}
} else {
final assetCredentials = await fetchAssetCredential(cryptoCurrency.title);
if (assetCredentials.isNotEmpty) {
if (assetCredentials['sellable'] == true) {
final availablePaymentTypes = [
PaymentType.bankTransfer,
PaymentType.creditCard,
PaymentType.sepa
];
availablePaymentTypes.forEach((element) {
final paymentMethod = PaymentMethod.fromDFX(normalizePaymentMethod(element)!, element);
paymentMethods.add(paymentMethod);
});
}
}
}
return paymentMethods;
}
@override
Future<List<Quote>?> fetchQuote(
{required CryptoCurrency cryptoCurrency,
required FiatCurrency fiatCurrency,
required double amount,
required bool isBuyAction,
required String walletAddress,
PaymentType? paymentType,
String? customPaymentMethodType,
String? countryCode}) async {
/// if buying with any currency other than eur or chf then DFX is not supported
if (isBuyAction && (fiatCurrency != FiatCurrency.eur && fiatCurrency != FiatCurrency.chf)) {
return null;
}
String? paymentMethod;
if (paymentType != null && paymentType != PaymentType.all) {
paymentMethod = normalizePaymentMethod(paymentType);
if (paymentMethod == null) paymentMethod = paymentType.name;
} else {
paymentMethod = 'Bank';
}
final action = isBuyAction ? 'buy' : 'sell';
final fiatCredentials = await fetchFiatCredentials(fiatCurrency.name.toString());
if (fiatCredentials['id'] == null) return null;
final assetCredentials = await fetchAssetCredential(cryptoCurrency.title.toString());
if (assetCredentials['id'] == null) return null;
log('DFX: Fetching $action quote: ${isBuyAction ? cryptoCurrency : fiatCurrency} -> ${isBuyAction ? fiatCurrency : cryptoCurrency}, amount: $amount, paymentMethod: $paymentMethod');
final url = Uri.https(_baseUrl, '/v1/$action/quote');
final headers = {'accept': 'application/json', 'Content-Type': 'application/json'};
final body = jsonEncode({
'currency': {'id': fiatCredentials['id'] as int},
'asset': {'id': assetCredentials['id']},
'amount': amount,
'targetAmount': 0,
'paymentMethod': paymentMethod,
'discountCode': ''
});
try {
final response = await ProxyWrapper().put(
clearnetUri: url,
headers: headers,
body: body,
);
final responseData = jsonDecode(response.body);
if (response.statusCode == 200) {
if (responseData is Map<String, dynamic>) {
final paymentType = _getPaymentTypeByString(responseData['paymentMethod'] as String?);
final quote = Quote.fromDFXJson(responseData, isBuyAction, paymentType);
quote.setFiatCurrency = fiatCurrency;
quote.setCryptoCurrency = cryptoCurrency;
return [quote];
} else {
printV('DFX: Unexpected data type: ${responseData.runtimeType}');
return null;
}
} else {
if (responseData is Map<String, dynamic> && responseData.containsKey('message')) {
printV('DFX Error: ${responseData['message']}');
} else {
printV('DFX Failed to fetch buy quote: ${response.statusCode}');
}
return null;
}
} catch (e) {
printV('DFX Error fetching buy quote: $e');
return null;
}
}
Future<void>? launchProvider(
{required BuildContext context,
required Quote quote,
required double amount,
required bool isBuyAction,
required String cryptoCurrencyAddress,
String? countryCode}) async {
if (wallet.isHardwareWallet) {
if (!hardwareWalletVM!.isConnected) {
await Navigator.of(context).pushNamed(Routes.connectDevices,
arguments: ConnectDevicePageParams(
walletType: wallet.walletInfo.type,
hardwareWalletType: wallet.walletInfo.hardwareWalletType!,
onConnectDevice: (context, hwwVM) {
hwwVM.initWallet(wallet);
Navigator.of(context).pop();
}));
} else {
hardwareWalletVM!.initWallet(wallet);
}
}
try {
final actionType = isBuyAction ? '/buy' : '/sell';
final accessToken = await auth(cryptoCurrencyAddress);
final uri = Uri.https('app.dfx.swiss', actionType, {
'session': accessToken,
'lang': 'en',
'asset-out': isBuyAction ? quote.cryptoCurrency.toString() : quote.fiatCurrency.toString(),
'blockchain': blockchain,
'asset-in': isBuyAction ? quote.fiatCurrency.toString() : quote.cryptoCurrency.toString(),
'amount-in': amount.toString()
});
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
throw Exception('Could not launch URL');
}
} catch (e) {
await showPopUp<void>(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: "DFX.swiss",
alertContent: '${S.of(context).buy_provider_unavailable}: $e',
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop()),
);
}
}
String? normalizePaymentMethod(PaymentType paymentMethod) {
switch (paymentMethod) {
case PaymentType.bankTransfer:
return 'Bank';
case PaymentType.creditCard:
return 'Card';
case PaymentType.sepa:
return 'Instant';
default:
return null;
}
}
PaymentType _getPaymentTypeByString(String? paymentMethod) {
switch (paymentMethod) {
case 'Bank':
return PaymentType.bankTransfer;
case 'Card':
return PaymentType.creditCard;
case 'Instant':
return PaymentType.sepa;
default:
return PaymentType.unknown;
}
}
double? _toDouble(dynamic value) {
if (value is int) {
return value.toDouble();
} else if (value is double) {
return value;
}
return null;
}
String _getErrorMessage(int statusCode, String body) {
final responseBody = jsonDecode(body) as Map<String, dynamic>;
final message = responseBody['message']?.toString() ?? '';
if (message.contains("address must match") || message.contains("signature must match")) {
return "The wallet type must match the selected currency";
}
return message.isNotEmpty ? message : "Unknown error: ${statusCode}";
}
}
-243
View File
@@ -1,243 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/buy/buy_provider.dart';
import 'package:hash_wallet/buy/buy_quote.dart';
import 'package:hash_wallet/buy/pairs_utils.dart';
import 'package:hash_wallet/buy/payment_method.dart';
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:flutter/material.dart';
import 'dart:developer';
import 'package:url_launcher/url_launcher.dart';
class KryptonimBuyProvider extends BuyProvider {
KryptonimBuyProvider({required WalletBase wallet, bool isTestEnvironment = false})
: super(
wallet: wallet,
isTestEnvironment: isTestEnvironment,
hardwareWalletVM: null,
supportedCryptoList: supportedCryptoToFiatPairs(
notSupportedCrypto: _notSupportedCrypto, notSupportedFiat: _notSupportedFiat),
supportedFiatList: supportedFiatToCryptoPairs(
notSupportedFiat: _notSupportedFiat, notSupportedCrypto: _notSupportedCrypto));
static const _isProduction = true;
static const _baseUrl = _isProduction ? 'app.kryptonim.com' : 'intg-api.kryptonim.com';
static const _baseWidgetUrl = _isProduction ? 'buy.kryptonim.com' : 'intg.kryptonim.com';
static const _quotePath = '/v2/ramp/buy/quotes';
static const _merchantId = 'a70fe053';
static String get _kryptonimApiKey => secrets.kryptonimApiKey;
static const List<CryptoCurrency> _notSupportedCrypto = [];
static const List<FiatCurrency> _notSupportedFiat = [];
@override
String get title => 'Kryptonim';
@override
String get providerDescription => 'Kryptonim Buy Provider';
@override
String get lightIcon => 'assets/images/kryptonim_light.png';
@override
String get darkIcon => 'assets/images/kryptonim_dark.png';
@override
bool get isAggregator => false;
Future<Map<String, dynamic>> getExchangeRates(
{required CryptoCurrency cryptoCurrency,
required String fiatCurrency,
required double amount}) async {
final url = Uri.https(_baseUrl, _quotePath, {'m': _merchantId});
final headers = {
'accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': _kryptonimApiKey,
};
try {
final body = jsonEncode({
'amount': amount,
'currency': fiatCurrency,
'converted_currency': cryptoCurrency.title,
'blockchain': _normalizeBlockChain(cryptoCurrency),
'quote_currency': fiatCurrency,
});
final response = await ProxyWrapper().post(
clearnetUri: url,
headers: headers,
body: body,
);
if (response.statusCode == 200 || response.statusCode == 201 || response.statusCode == 401) {
return jsonDecode(response.body) as Map<String, dynamic>;
} else {
return {};
}
} catch (e) {
return {};
}
}
@override
Future<List<PaymentMethod>> getAvailablePaymentTypes(
String fiatCurrency, CryptoCurrency cryptoCurrency, bool isBuyAction) async {
final data = await getExchangeRates(
cryptoCurrency: cryptoCurrency,
fiatCurrency: fiatCurrency,
amount: 100.0,
);
if (data.isEmpty || !data.containsKey('data')) return [];
final paymentMethods = (data['data'] as List<dynamic>)
.map((e) => PaymentMethod.fromKryptonimJson(e as Map<String, dynamic>))
.toList();
return paymentMethods;
}
@override
Future<List<Quote>?> fetchQuote({
required CryptoCurrency cryptoCurrency,
required FiatCurrency fiatCurrency,
required double amount,
required bool isBuyAction,
required String walletAddress,
PaymentType? paymentType,
String? customPaymentMethodType,
String? countryCode,
}) async {
log('Kryptonim: Fetching quote: ${isBuyAction ? cryptoCurrency : fiatCurrency} -> ${isBuyAction ? fiatCurrency : cryptoCurrency}, amount: $amount');
final data = await getExchangeRates(
cryptoCurrency: cryptoCurrency,
fiatCurrency: fiatCurrency.toString(),
amount: amount,
);
if (!data.containsKey('data') || (data['data'] as List).isEmpty) {
return null;
}
final quotesList = data['data'] as List<dynamic>;
Map<String, dynamic>? selectedPaymentMethod;
if (paymentType == PaymentType.all || paymentType == null) {
selectedPaymentMethod = quotesList.first as Map<String, dynamic>;
} else {
for (var quote in quotesList) {
final quotePaymentType = PaymentMethod.getPaymentTypeId(quote['payment_method'] as String?);
if (quotePaymentType == paymentType) {
selectedPaymentMethod = quote as Map<String, dynamic>;
break;
}
}
}
if (selectedPaymentMethod == null) {
return null;
}
final selectedPaymentType =
PaymentMethod.getPaymentTypeId(selectedPaymentMethod['payment_method'] as String?);
final quote = Quote.fromKryptonimJson(selectedPaymentMethod, isBuyAction, selectedPaymentType ?? PaymentType.unknown);
quote.setFiatCurrency = fiatCurrency;
quote.setCryptoCurrency = cryptoCurrency;
return [quote];
}
@override
Future<void>? launchProvider(
{required BuildContext context,
required Quote quote,
required double amount,
required bool isBuyAction,
required String cryptoCurrencyAddress,
String? countryCode}) async {
final params = {
'amount': amount.toInt().toString(),
'currency': quote.fiatCurrency.name,
'convertedCurrency': quote.cryptoCurrency.title,
'blockchain': _normalizeBlockChain(quote.cryptoCurrency),
'address': cryptoCurrencyAddress,
'paymentMethod': normalizePaymentMethod(quote.paymentType),
};
final uri = Uri.https(_baseWidgetUrl, '/redirect-form', params);
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
throw Exception('Could not launch URL');
}
} catch (e) {
await showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: "Kryptonim",
alertContent: "Payment provider is unavailable: $e",
buttonText: "OK",
buttonAction: () => Navigator.of(context).pop(),
);
},
);
}
}
String normalizePaymentMethod(PaymentType paymentType) {
switch (paymentType) {
case PaymentType.bankTransfer:
return 'bank';
case PaymentType.creditCard:
case PaymentType.debitCard:
return 'card';
default:
return paymentType.name.toLowerCase();
}
}
String _normalizeBlockChain(CryptoCurrency cur) {
String? blockchain = switch (cur.tag) {
'ETH' => 'Ethereum',
'BASE' => 'Base',
'ARB' => 'Arbitrum',
'POL' => 'Polygon',
'AVAXC' => 'Avalanche',
'SOL' => 'Solana',
_ => null,
};
if (blockchain == null) {
blockchain = switch (cur) {
CryptoCurrency.btc => 'Bitcoin',
CryptoCurrency.ltc => 'Litecoin',
CryptoCurrency.eth => 'Ethereum',
CryptoCurrency.baseEth => 'Base',
CryptoCurrency.arbEth => 'Arbitrum',
CryptoCurrency.maticpoly => 'Matic',
_ => null,
};
}
return blockchain ?? cur.fullName ?? '';
}
}
-281
View File
@@ -1,281 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/buy/buy_provider.dart';
import 'package:hash_wallet/buy/buy_quote.dart';
import 'package:hash_wallet/buy/pairs_utils.dart';
import 'package:hash_wallet/buy/payment_method.dart';
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:flutter/material.dart';
import 'dart:developer';
import 'package:url_launcher/url_launcher.dart';
class MeldBuyProvider extends BuyProvider {
MeldBuyProvider({required WalletBase wallet, bool isTestEnvironment = false})
: super(
wallet: wallet,
isTestEnvironment: isTestEnvironment,
hardwareWalletVM: null,
supportedCryptoList: supportedCryptoToFiatPairs(
notSupportedCrypto: _notSupportedCrypto, notSupportedFiat: _notSupportedFiat),
supportedFiatList: supportedFiatToCryptoPairs(
notSupportedFiat: _notSupportedFiat, notSupportedCrypto: _notSupportedCrypto));
static const _isProduction = false;
static const _baseUrl = _isProduction ? 'api.meld.io' : 'api-sb.meld.io';
static const _providersProperties = '/service-providers/properties';
static const _paymentMethodsPath = '/payment-methods';
static const _quotePath = '/payments/crypto/quote';
static const String sandboxUrl = 'sb.fluidmoney.xyz';
static const String productionUrl = 'fluidmoney.xyz';
static const String _baseWidgetUrl = _isProduction ? productionUrl : sandboxUrl;
static String get _testApiKey => secrets.meldTestApiKey;
static const List<CryptoCurrency> _notSupportedCrypto = [];
static const List<FiatCurrency> _notSupportedFiat = [];
static String get _testPublicKey => '' ; //secrets.meldTestPublicKey;
@override
String get title => 'Meld';
@override
String get providerDescription => 'Meld Buy Provider';
@override
String get lightIcon => 'assets/images/meld_logo.svg';
@override
String get darkIcon => 'assets/images/meld_logo.svg';
@override
bool get isAggregator => true;
@override
Future<List<PaymentMethod>> getAvailablePaymentTypes(
String fiatCurrency, CryptoCurrency cryptoCurrency, bool isBuyAction) async {
final params = {'fiatCurrencies': fiatCurrency, 'statuses': 'LIVE,RECENTLY_ADDED,BUILDING'};
final path = '$_providersProperties$_paymentMethodsPath';
final url = Uri.https(_baseUrl, path, params);
try {
final response = await ProxyWrapper().get(
clearnetUri: url,
headers: {
'Authorization': _isProduction ? '' : _testApiKey,
'Meld-Version': '2023-12-19',
'accept': 'application/json',
'content-type': 'application/json',
},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as List<dynamic>;
final paymentMethods =
data.map((e) => PaymentMethod.fromMeldJson(e as Map<String, dynamic>)).toList();
return paymentMethods;
} else {
printV('Meld: Failed to fetch payment types');
return List<PaymentMethod>.empty();
}
} catch (e) {
printV('Meld: Failed to fetch payment types: $e');
return List<PaymentMethod>.empty();
}
}
@override
Future<List<Quote>?> fetchQuote(
{required CryptoCurrency cryptoCurrency,
required FiatCurrency fiatCurrency,
required double amount,
required bool isBuyAction,
required String walletAddress,
PaymentType? paymentType,
String? customPaymentMethodType,
String? countryCode}) async {
String? paymentMethod;
if (paymentType != null && paymentType != PaymentType.all) {
paymentMethod = normalizePaymentMethod(paymentType);
if (paymentMethod == null) paymentMethod = paymentType.name;
}
log('Meld: Fetching buy quote: ${isBuyAction ? cryptoCurrency : fiatCurrency} -> ${isBuyAction ? fiatCurrency : cryptoCurrency}, amount: $amount');
final url = Uri.https(_baseUrl, _quotePath);
final headers = {
'Authorization': _testApiKey,
'Meld-Version': '2023-12-19',
'accept': 'application/json',
'content-type': 'application/json',
};
final body = jsonEncode({
'countryCode': countryCode,
'destinationCurrencyCode': isBuyAction ? fiatCurrency.name : cryptoCurrency.title,
'sourceAmount': amount,
'sourceCurrencyCode': isBuyAction ? cryptoCurrency.title : fiatCurrency.name,
if (paymentMethod != null) 'paymentMethod': paymentMethod,
});
try {
final response = await ProxyWrapper().post(
clearnetUri: url,
headers: headers,
body: body,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final paymentType = _getPaymentTypeByString(data['paymentMethodType'] as String?);
final quote = Quote.fromMeldJson(data, isBuyAction, paymentType);
quote.setFiatCurrency = fiatCurrency;
quote.setCryptoCurrency = cryptoCurrency;
return [quote];
} else {
return null;
}
} catch (e) {
printV('Error fetching buy quote: $e');
return null;
}
}
Future<void>? launchProvider(
{required BuildContext context,
required Quote quote,
required double amount,
required bool isBuyAction,
required String cryptoCurrencyAddress,
String? countryCode}) async {
final actionType = isBuyAction ? 'BUY' : 'SELL';
final params = {
'publicKey': _isProduction ? '' : _testPublicKey,
'countryCode': countryCode,
//'paymentMethodType': normalizePaymentMethod(paymentMethod.paymentMethodType),
'sourceAmount': amount.toString(),
'sourceCurrencyCode': quote.fiatCurrency,
'destinationCurrencyCode': quote.cryptoCurrency,
'walletAddress': cryptoCurrencyAddress,
'transactionType': actionType
};
final uri = Uri.https(_baseWidgetUrl, '', params);
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
throw Exception('Could not launch URL');
}
} catch (e) {
await showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: "Meld",
alertContent: S.of(context).buy_provider_unavailable + ': $e',
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop());
});
}
}
String? normalizePaymentMethod(PaymentType paymentType) {
switch (paymentType) {
case PaymentType.creditCard:
return 'CREDIT_DEBIT_CARD';
case PaymentType.applePay:
return 'APPLE_PAY';
case PaymentType.googlePay:
return 'GOOGLE_PAY';
case PaymentType.neteller:
return 'NETELLER';
case PaymentType.skrill:
return 'SKRILL';
case PaymentType.sepa:
return 'SEPA';
case PaymentType.sepaInstant:
return 'SEPA_INSTANT';
case PaymentType.ach:
return 'ACH';
case PaymentType.achInstant:
return 'INSTANT_ACH';
case PaymentType.Khipu:
return 'KHIPU';
case PaymentType.ovo:
return 'OVO';
case PaymentType.zaloPay:
return 'ZALOPAY';
case PaymentType.zaloBankTransfer:
return 'ZA_BANK_TRANSFER';
case PaymentType.gcash:
return 'GCASH';
case PaymentType.imps:
return 'IMPS';
case PaymentType.dana:
return 'DANA';
case PaymentType.ideal:
return 'IDEAL';
default:
return null;
}
}
PaymentType _getPaymentTypeByString(String? paymentMethod) {
switch (paymentMethod?.toUpperCase()) {
case 'CREDIT_DEBIT_CARD':
return PaymentType.creditCard;
case 'APPLE_PAY':
return PaymentType.applePay;
case 'GOOGLE_PAY':
return PaymentType.googlePay;
case 'NETELLER':
return PaymentType.neteller;
case 'SKRILL':
return PaymentType.skrill;
case 'SEPA':
return PaymentType.sepa;
case 'SEPA_INSTANT':
return PaymentType.sepaInstant;
case 'ACH':
return PaymentType.ach;
case 'INSTANT_ACH':
return PaymentType.achInstant;
case 'KHIPU':
return PaymentType.Khipu;
case 'OVO':
return PaymentType.ovo;
case 'ZALOPAY':
return PaymentType.zaloPay;
case 'ZA_BANK_TRANSFER':
return PaymentType.zaloBankTransfer;
case 'GCASH':
return PaymentType.gcash;
case 'IMPS':
return PaymentType.imps;
case 'DANA':
return PaymentType.dana;
case 'IDEAL':
return PaymentType.ideal;
default:
return PaymentType.all;
}
}
}
-453
View File
@@ -1,453 +0,0 @@
import 'dart:convert';
import 'dart:developer';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/buy/buy_provider.dart';
import 'package:hash_wallet/buy/buy_quote.dart';
import 'package:hash_wallet/buy/pairs_utils.dart';
import 'package:hash_wallet/buy/payment_method.dart';
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
class OnRamperBuyProvider extends BuyProvider {
OnRamperBuyProvider(
{required WalletBase wallet, bool isTestEnvironment = false})
: super(wallet: wallet,
isTestEnvironment: isTestEnvironment,
hardwareWalletVM: null,
supportedCryptoList: supportedCryptoToFiatPairs(
notSupportedCrypto: _notSupportedCrypto, notSupportedFiat: _notSupportedFiat),
supportedFiatList: supportedFiatToCryptoPairs(
notSupportedFiat: _notSupportedFiat, notSupportedCrypto: _notSupportedCrypto));
static const _baseUrl = 'buy.onramper.com';
static const _baseApiUrl = 'api.onramper.com';
static const _cIdBaseUrl = 'exchange-helper.such.software';
static const quotes = '/quotes';
static const paymentTypes = '/payment-types';
static const supported = '/supported';
static const defaultsAll = '/defaults/all';
static const List<CryptoCurrency> _notSupportedCrypto = [];
static const List<FiatCurrency> _notSupportedFiat = [];
static Map<String, dynamic> _onrampMetadata = {};
String? recommendedPaymentType;
String get _apiKey => secrets.onramperApiKey;
String get _exchangeHelperApiKey => secrets.exchangeHelperApiKey;
@override
String get title => 'Onramper';
@override
String get providerDescription => S.current.onramper_option_description;
@override
String get lightIcon => 'assets/images/onramper_light.png';
@override
String get darkIcon => 'assets/images/onramper_dark.png';
@override
bool get isAggregator => true;
Future<String> getOnramperSignature(String query) async {
final uri = Uri.https(_cIdBaseUrl, "/api/onramper");
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: {'Content-Type': 'application/json', 'x-api-key': _exchangeHelperApiKey},
body: json.encode({'query': query}),
);
if (response.statusCode == 200) {
return (jsonDecode(response.body) as Map<String, dynamic>)['signature'] as String;
} else {
throw Exception(
'Provider currently unavailable. Status: ${response.statusCode} ${response.body}');
}
}
Future<String?> getRecommendedPaymentType(bool isBuyAction) async {
final params = {'type': isBuyAction ? 'buy' : 'sell'};
final url = Uri.https(_baseApiUrl, '$supported$defaultsAll', params);
try {
final response = await ProxyWrapper().get(
clearnetUri: url,
headers: {'Authorization': _apiKey, 'accept': 'application/json'},
);
if (response.statusCode == 200) {
final Map<String, dynamic> data = jsonDecode(response.body) as Map<String, dynamic>;
final recommended = data['message']['recommended'] as Map<String, dynamic>;
final recommendedPaymentType = recommended['paymentMethod'] as String?;
return recommendedPaymentType ;
} else {
final responseBody =
jsonDecode(response.body) as Map<String, dynamic>;
printV('Failed to fetch available payment types: ${responseBody['message']}');
}
} catch (e) {
printV('Failed to fetch available payment types: $e');
}
return null;
}
Future<List<PaymentMethod>> getAvailablePaymentTypes(
String fiatCurrency, CryptoCurrency cryptoCurrency, bool isBuyAction) async {
final normalizedCryptoCurrency =
cryptoCurrency.title + _getNormalizeNetwork(cryptoCurrency);
final sourceCurrency = (isBuyAction ? fiatCurrency : normalizedCryptoCurrency).toLowerCase();
final destinationCurrency = (isBuyAction ? normalizedCryptoCurrency : fiatCurrency).toLowerCase();
final params = {'type': isBuyAction ? 'buy' : 'sell', 'destination' : destinationCurrency};
final url = Uri.https(_baseApiUrl, '$supported$paymentTypes/$sourceCurrency', params);
try {
final response =
await ProxyWrapper().get(clearnetUri: url, headers: {'Authorization': _apiKey, 'accept': 'application/json'});
if (response.statusCode == 200) {
final Map<String, dynamic> data = jsonDecode(response.body) as Map<String, dynamic>;
final List<dynamic> message = data['message'] as List<dynamic>;
final allAvailablePaymentMethods = message
.map((item) => PaymentMethod.fromOnramperJson(item as Map<String, dynamic>))
.toList();
recommendedPaymentType = await getRecommendedPaymentType(isBuyAction);
return allAvailablePaymentMethods;
} else {
final responseBody =
jsonDecode(response.body) as Map<String, dynamic>;
printV('Failed to fetch available payment types: ${responseBody['message']}');
return [];
}
} catch (e) {
printV('Failed to fetch available payment types: $e');
return [];
}
}
Future<Map<String, dynamic>> getOnrampMetadata() async {
final url = Uri.https(_baseApiUrl, '$supported/onramps/all');
try {
final response =
await ProxyWrapper().get(clearnetUri: url, headers: {'Authorization': _apiKey, 'accept': 'application/json'});
if (response.statusCode == 200) {
final Map<String, dynamic> data = jsonDecode(response.body) as Map<String, dynamic>;
final List<dynamic> onramps = data['message'] as List<dynamic>;
final Map<String, dynamic> result = {
for (var onramp in onramps)
(onramp['id'] as String): {
'displayName': onramp['displayName'] as String,
'svg': onramp['icons']['svg'] as String
}
};
return result;
} else {
printV('Failed to fetch onramp metadata');
return {};
}
} catch (e) {
printV('Error occurred: $e');
return {};
}
}
@override
Future<List<Quote>?> fetchQuote(
{required CryptoCurrency cryptoCurrency,
required FiatCurrency fiatCurrency,
required double amount,
required bool isBuyAction,
required String walletAddress,
PaymentType? paymentType,
String? customPaymentMethodType,
String? countryCode}) async {
String? paymentMethod;
if (paymentType == PaymentType.all ) paymentMethod = null;
else if (paymentType == PaymentType.unknown) paymentMethod = customPaymentMethodType;
else if (paymentType != null) paymentMethod = normalizePaymentMethod(paymentType);
final actionType = isBuyAction ? 'buy' : 'sell';
final normalizedCryptoCurrency =
cryptoCurrency.title + _getNormalizeNetwork(cryptoCurrency).toUpperCase();
final params = {
'amount': amount.toString(),
if (paymentMethod != null) 'paymentMethod': paymentMethod,
'clientName': 'CakeWallet',
if (actionType == 'sell') 'type': actionType,
};
log('Onramper: Fetching $actionType quote: ${isBuyAction ? normalizedCryptoCurrency : fiatCurrency.name} -> ${isBuyAction ? fiatCurrency.name : normalizedCryptoCurrency}, amount: $amount, paymentMethod: $paymentMethod');
final sourceCurrency = isBuyAction ? fiatCurrency.name : normalizedCryptoCurrency;
final destinationCurrency = isBuyAction ? normalizedCryptoCurrency : fiatCurrency.name;
final url = Uri.https(_baseApiUrl, '$quotes/${sourceCurrency}/${destinationCurrency}', params);
final headers = {'Authorization': _apiKey, 'accept': 'application/json'};
try {
final response = await ProxyWrapper().get(clearnetUri: url, headers: headers);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as List<dynamic>;
if (data.isEmpty) return null;
List<Quote> validQuotes = [];
if (_onrampMetadata.isEmpty) _onrampMetadata = await getOnrampMetadata();
for (var item in data) {
if (item['errors'] != null) continue;
final paymentMethod = (item as Map<String, dynamic>)['paymentMethod'] as String;
final rampId = item['ramp'] as String?;
final rampMetaData = _onrampMetadata[rampId] as Map<String, dynamic>?;
if (rampMetaData == null) continue;
final quote = Quote.fromOnramperJson(
item, isBuyAction, _onrampMetadata, _getPaymentTypeByString(paymentMethod), customPaymentMethodType);
quote.setFiatCurrency = fiatCurrency;
quote.setCryptoCurrency = cryptoCurrency;
validQuotes.add(quote);
}
if (validQuotes.isEmpty) return null;
return validQuotes;
} else {
printV('Onramper: Failed to fetch rate');
return null;
}
} catch (e) {
printV('Onramper: Failed to fetch rate $e');
return null;
}
}
Future<void>? launchProvider(
{required BuildContext context,
required Quote quote,
required double amount,
required bool isBuyAction,
required String cryptoCurrencyAddress,
String? countryCode}) async {
final actionType = isBuyAction ? 'buy' : 'sell';
final primaryColor = getColorStr(Theme.of(context).colorScheme.primary,);
final secondaryColor = getColorStr(Theme.of(context).colorScheme.surface);
final primaryTextColor = getColorStr(Theme.of(context).colorScheme.onSurface);
final secondaryTextColor =
getColorStr(Theme.of(context).colorScheme.onSurfaceVariant);
final containerColor = getColorStr(Theme.of(context).colorScheme.surface);
var cardColor = getColorStr(Theme.of(context).colorScheme.surfaceContainer);
final defaultCrypto =
quote.cryptoCurrency.title + _getNormalizeNetwork(quote.cryptoCurrency).toLowerCase();
final paymentMethod = quote.paymentType == PaymentType.unknown ? quote.customPaymentMethodType : normalizePaymentMethod(quote.paymentType);
final networkWallets =
'${_tagToNetwork(quote.cryptoCurrency.tag ?? quote.cryptoCurrency.title).toLowerCase()}:$cryptoCurrencyAddress';
final signature = await getOnramperSignature("networkWallets=$networkWallets");
final uri = Uri.https(_baseUrl, '', {
'apiKey': _apiKey,
'txnType': actionType,
'txnFiat': quote.fiatCurrency.name,
'txnCrypto': defaultCrypto,
'txnAmount': amount.toString(),
'skipTransactionScreen': "true",
if (paymentMethod != null) 'txnPaymentMethod': paymentMethod,
'txnOnramp': quote.rampId,
'networkWallets': networkWallets,
'supportSwap': "false",
'primaryColor': primaryColor,
'secondaryColor': secondaryColor,
'containerColor': containerColor,
'primaryTextColor': primaryTextColor,
'secondaryTextColor': secondaryTextColor,
'cardColor': cardColor,
'signature': signature,
});
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
throw Exception('Could not launch URL ${uri.toString()}');
}
}
List<CryptoCurrency> mainCurrency = [
CryptoCurrency.btc,
CryptoCurrency.eth,
CryptoCurrency.sol,
];
String _tagToNetwork(String tag) {
switch (tag) {
case 'POL':
return 'POLYGON';
case 'ARB':
return 'ARBITRUM';
case 'ETH':
return 'ETHEREUM';
case 'TRX':
return 'TRON';
case 'SOL':
return 'SOLANA';
case 'ZEC':
return 'ZCASH';
default:
return tag;
}
}
String _getNormalizeNetwork(CryptoCurrency currency) {
if (mainCurrency.contains(currency)) return '';
if (currency == CryptoCurrency.eos) return '_EOSIO';
if (currency.tag != null) return '_' + _tagToNetwork(currency.tag!);
return '_' + (currency.fullName?.replaceAll(' ', '') ?? currency.title);
}
String? normalizePaymentMethod(PaymentType paymentType) {
switch (paymentType) {
case PaymentType.bankTransfer:
return 'banktransfer';
case PaymentType.creditCard:
return 'creditcard';
case PaymentType.debitCard:
return 'debitcard';
case PaymentType.applePay:
return 'applepay';
case PaymentType.googlePay:
return 'googlepay';
case PaymentType.revolutPay:
return 'revolutpay';
case PaymentType.neteller:
return 'neteller';
case PaymentType.skrill:
return 'skrill';
case PaymentType.sepa:
return 'sepabanktransfer';
case PaymentType.sepaInstant:
return 'sepainstant';
case PaymentType.ach:
return 'ach';
case PaymentType.achInstant:
return 'iach';
case PaymentType.Khipu:
return 'khipu';
case PaymentType.palomaBanktTansfer:
return 'palomabanktransfer';
case PaymentType.ovo:
return 'ovo';
case PaymentType.zaloPay:
return 'zalopay';
case PaymentType.zaloBankTransfer:
return 'zalobanktransfer';
case PaymentType.gcash:
return 'gcash';
case PaymentType.imps:
return 'imps';
case PaymentType.dana:
return 'dana';
case PaymentType.ideal:
return 'ideal';
case PaymentType.pixPay:
return 'pix';
default:
return null;
}
}
PaymentType _getPaymentTypeByString(String paymentMethod) {
switch (paymentMethod.toLowerCase()) {
case 'banktransfer':
return PaymentType.bankTransfer;
case 'creditcard':
return PaymentType.creditCard;
case 'debitcard':
return PaymentType.debitCard;
case 'applepay':
return PaymentType.applePay;
case 'googlepay':
return PaymentType.googlePay;
case 'revolutpay':
return PaymentType.revolutPay;
case 'neteller':
return PaymentType.neteller;
case 'skrill':
return PaymentType.skrill;
case 'sepabanktransfer':
return PaymentType.sepa;
case 'sepainstant':
return PaymentType.sepaInstant;
case 'ach':
return PaymentType.ach;
case 'iach':
return PaymentType.achInstant;
case 'khipu':
return PaymentType.Khipu;
case 'palomabanktransfer':
return PaymentType.palomaBanktTansfer;
case 'ovo':
return PaymentType.ovo;
case 'zalopay':
return PaymentType.zaloPay;
case 'zalobanktransfer':
return PaymentType.zaloBankTransfer;
case 'gcash':
return PaymentType.gcash;
case 'imps':
return PaymentType.imps;
case 'dana':
return PaymentType.dana;
case 'ideal':
return PaymentType.ideal;
case 'pix':
return PaymentType.pixPay;
default:
return PaymentType.unknown;
}
}
String getColorStr(Color color) =>
color.toARGB32().toRadixString(16).replaceAll(RegExp(r'^ff'), "");
}
-36
View File
@@ -193,23 +193,6 @@ class PaymentMethod extends SelectableOption {
customIconPath: 'assets/images/dollar_coin.svg');
}
factory PaymentMethod.fromOnramperJson(Map<String, dynamic> json) {
final type = PaymentMethod.getPaymentTypeId(json['paymentTypeId'] as String?);
return PaymentMethod(
paymentMethodType: type ?? PaymentType.unknown,
customPaymentMethodType: json['paymentTypeId'] as String?,
customTitle: json['name'] as String? ?? 'Unknown',
customIconPath: json['icon'] as String? ?? 'assets/images/card.png',
customDescription: json['description'] as String?);
}
factory PaymentMethod.fromDFX(String paymentMethod, PaymentType paymentType) {
return PaymentMethod(
paymentMethodType: paymentType,
customTitle: paymentMethod,
customIconPath: 'assets/images/card.png');
}
factory PaymentMethod.fromMoonPayJson(Map<String, dynamic> json, PaymentType paymentType) {
return PaymentMethod(
paymentMethodType: paymentType,
@@ -217,25 +200,6 @@ class PaymentMethod extends SelectableOption {
customIconPath: 'assets/images/card.png');
}
factory PaymentMethod.fromMeldJson(Map<String, dynamic> json) {
final type = PaymentMethod.getPaymentTypeId(json['paymentMethod'] as String?);
final logos = json['logos'] as Map<String, dynamic>;
return PaymentMethod(
paymentMethodType: type ?? PaymentType.unknown,
customTitle: json['name'] as String? ?? 'Unknown',
customIconPath: logos['dark'] as String? ?? 'assets/images/card.png',
customDescription: json['description'] as String?);
}
factory PaymentMethod.fromKryptonimJson(Map<String, dynamic> json) {
final type = PaymentMethod.getPaymentTypeId(json['payment_method'] as String?);
return PaymentMethod(
paymentMethodType: type ?? PaymentType.unknown,
customTitle: json['payment_method'] as String? ?? 'Unknown',
customIconPath: 'assets/images/card.png',
);
}
static PaymentType? getPaymentTypeId(String? type) {
switch (type?.toLowerCase()) {
case 'banktransfer':
@@ -1,287 +0,0 @@
import 'dart:convert';
import 'dart:developer';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/buy/buy_provider.dart';
import 'package:hash_wallet/buy/buy_quote.dart';
import 'package:hash_wallet/buy/pairs_utils.dart';
import 'package:hash_wallet/buy/payment_method.dart';
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/connect_device/connect_device_page.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:hash_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
class RobinhoodBuyProvider extends BuyProvider {
RobinhoodBuyProvider({
required WalletBase wallet,
bool isTestEnvironment = false,
HardwareWalletViewModel? hardwareWalletVM,
}) : super(
wallet: wallet,
isTestEnvironment: isTestEnvironment,
hardwareWalletVM: hardwareWalletVM,
supportedCryptoList: supportedCryptoToFiatPairs(
notSupportedCrypto: _notSupportedCrypto, notSupportedFiat: _notSupportedFiat),
supportedFiatList: supportedFiatToCryptoPairs(
notSupportedFiat: _notSupportedFiat, notSupportedCrypto: _notSupportedCrypto),
);
static const _baseUrl = 'applink.robinhood.com';
static const _cIdBaseUrl = 'exchange-helper.such.software';
static const _apiBaseUrl = 'api.robinhood.com';
static const _assetsPath = '/catpay/v1/supported_currencies/';
static List<CryptoCurrency> _supportedCrypto = [];
static final List<FiatCurrency> _supportedFiat = [FiatCurrency.usd];
static final List<CryptoCurrency> _notSupportedCrypto = [];
static final List<FiatCurrency> _notSupportedFiat =
FiatCurrency.all.where((fiat) => !_supportedFiat.contains(fiat)).toList();
@override
String get title => 'Robinhood Connect';
@override
String get providerDescription => S.current.robinhood_option_description;
@override
String get lightIcon => 'assets/images/robinhood_light.png';
@override
String get darkIcon => 'assets/images/robinhood_dark.png';
@override
bool get isAggregator => false;
String get _applicationId => secrets.robinhoodApplicationId;
String get _apiSecret => secrets.exchangeHelperApiKey;
Future<List<CryptoCurrency>> getSupportedAssets() async {
final uri = Uri.https(_apiBaseUrl, '$_assetsPath', {'applicationId': _applicationId});
try {
final response =
await ProxyWrapper().get(clearnetUri: uri, headers: {'accept': 'application/json'});
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body) as Map<String, dynamic>;
final pairs = responseData['cryptoCurrencyPairs'] as List<dynamic>;
final supportedAssets = <CryptoCurrency>[];
for (final item in pairs) {
String code = item['assetCurrency']['code'] as String;
if (code == 'AVAX') code = 'AVAXC';
try {
final currency = CryptoCurrency.fromString(code);
supportedAssets.add(currency);
} catch (e) {
log('Robinhood: Unknown asset code "$code" - skipped');
}
}
return supportedAssets;
} else {
return [];
}
} catch (e) {
log('Robinhood: Failed to fetch supported assets: $e');
return [];
}
}
Future<String> getSignature(String message) async {
switch (wallet.type) {
case WalletType.ethereum:
case WalletType.polygon:
case WalletType.base:
case WalletType.arbitrum:
case WalletType.bsc:
case WalletType.dogecoin:
return wallet.signMessage(message);
case WalletType.litecoin:
case WalletType.bitcoin:
case WalletType.bitcoinCash:
return wallet.signMessage(message, address: wallet.walletAddresses.address);
case WalletType.monero:
case WalletType.none:
case WalletType.nano:
case WalletType.banano:
case WalletType.wownero:
throw Exception("Wallet Type ${wallet.type.name} is not available for Robinhood");
}
}
Future<String> getConnectId() async {
final walletAddress = wallet.walletAddresses.address;
final valid_until = (DateTime.now().millisecondsSinceEpoch / 1000).round() + 10;
final message = "$_apiSecret:${valid_until}";
final signature = await getSignature(message);
final uri = Uri.https(_cIdBaseUrl, "/api/robinhood");
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: {'Content-Type': 'application/json'},
body: json
.encode({'valid_until': valid_until, 'wallet': walletAddress, 'signature': signature}),
);
if (response.statusCode == 200) {
return (jsonDecode(response.body) as Map<String, dynamic>)['connectId'] as String;
} else {
throw Exception('Provider currently unavailable. Status: ${response.statusCode}');
}
}
Future<Uri> requestProviderUrl() async {
final connectId = await getConnectId();
final networkName = wallet.currency.fullName?.toUpperCase().replaceAll(" ", "_");
return Uri.https(_baseUrl, '/u/connect', <String, dynamic>{
'applicationId': _applicationId,
'connectId': connectId,
'walletAddress': wallet.walletAddresses.address,
'userIdentifier': wallet.walletAddresses.address,
'supportedNetworks': networkName
});
}
Future<void>? launchProvider(
{required BuildContext context,
required Quote quote,
required double amount,
required bool isBuyAction,
required String cryptoCurrencyAddress,
String? countryCode}) async {
if (wallet.isHardwareWallet) {
if (!hardwareWalletVM!.isConnected) {
await Navigator.of(context).pushNamed(Routes.connectDevices,
arguments: ConnectDevicePageParams(
walletType: wallet.walletInfo.type,
hardwareWalletType: wallet.walletInfo.hardwareWalletType!,
onConnectDevice: (context, hwwVM) {
hwwVM.initWallet(wallet);
Navigator.of(context).pop();
}));
} else {
hardwareWalletVM!.initWallet(wallet);
}
}
try {
final uri = await requestProviderUrl();
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (e) {
await showPopUp<void>(
context: context,
builder: (context) => AlertWithOneAction(
alertTitle: "Robinhood Connect",
alertContent: '${S.of(context).buy_provider_unavailable}: $e',
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop()),
);
}
}
@override
Future<List<Quote>?> fetchQuote(
{required CryptoCurrency cryptoCurrency,
required FiatCurrency fiatCurrency,
required double amount,
required bool isBuyAction,
required String walletAddress,
PaymentType? paymentType,
String? customPaymentMethodType,
String? countryCode}) async {
String? paymentMethod;
if (_supportedCrypto.isEmpty) _supportedCrypto = await getSupportedAssets();
if (_supportedCrypto.isNotEmpty && !(_supportedCrypto.contains(cryptoCurrency))) return null;
if (paymentType != null && paymentType != PaymentType.all) {
paymentMethod = normalizePaymentMethod(paymentType);
if (paymentMethod == null) return null;
}
final action = isBuyAction ? 'buy' : 'sell';
log('Robinhood: Fetching $action quote: ${isBuyAction ? cryptoCurrency.title : fiatCurrency.name.toUpperCase()} -> ${isBuyAction ? fiatCurrency.name.toUpperCase() : cryptoCurrency.title}, amount: $amount paymentMethod: $paymentMethod');
final queryParams = {
'applicationId': _applicationId,
'fiatCode': fiatCurrency.name,
'assetCode': cryptoCurrency.title,
'fiatAmount': amount.toString(),
if (paymentMethod != null) 'paymentMethod': paymentMethod,
};
final uri =
Uri.https('api.robinhood.com', '/catpay/v1/${cryptoCurrency.title}/quote/', queryParams);
try {
final response = await ProxyWrapper().get(clearnetUri: uri, headers: {'accept': 'application/json'});
final responseData = jsonDecode(response.body) as Map<String, dynamic>;
if (response.statusCode == 200) {
final paymentType = _getPaymentTypeByString(responseData['paymentMethod'] as String?);
final quote = Quote.fromRobinhoodJson(responseData, isBuyAction, paymentType);
quote.setFiatCurrency = fiatCurrency;
quote.setCryptoCurrency = cryptoCurrency;
return [quote];
} else {
if (responseData.containsKey('message')) {
log('Robinhood Error: ${responseData['message']}');
} else {
printV('Robinhood Failed to fetch $action quote: ${response.statusCode}');
}
return null;
}
} catch (e) {
log('Robinhood: Failed to fetch $action quote: $e');
return null;
}
// Supported payment methods:
// ● buying_power
// ● crypto_balance
// ● debit_card
// ● bank_transfer
}
String? normalizePaymentMethod(PaymentType paymentMethod) {
switch (paymentMethod) {
case PaymentType.creditCard:
return 'debit_card';
case PaymentType.debitCard:
return 'debit_card';
case PaymentType.bankTransfer:
return 'bank_transfer';
default:
return null;
}
}
PaymentType _getPaymentTypeByString(String? paymentMethod) {
switch (paymentMethod) {
case 'debit_card':
return PaymentType.debitCard;
case 'bank_transfer':
return PaymentType.bankTransfer;
default:
return PaymentType.unknown;
}
}
}
-5
View File
@@ -1,5 +0,0 @@
export 'src/auth/cake_pay_welcome_page.dart';
export 'src/auth/cake_pay_verify_otp_page.dart';
export 'src/auth/cake_pay_account_page.dart';
export 'src/cards/cake_pay_cards_page.dart';
export 'src/cards/cake_pay_buy_card_page.dart';
@@ -1,92 +0,0 @@
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/base_page.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/cake_pay/src/widgets/cake_pay_tile.dart';
import 'package:hash_wallet/src/widgets/primary_button.dart';
import 'package:hash_wallet/src/widgets/scrollable_with_bottom_section.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_account_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class CakePayAccountPage extends BasePage {
CakePayAccountPage(this.cakePayAccountViewModel);
final CakePayAccountViewModel cakePayAccountViewModel;
@override
Widget leading(BuildContext context) {
return MergeSemantics(
child: SizedBox(
height: 37,
width: 37,
child: ButtonTheme(
minWidth: double.minPositive,
child: Semantics(
label: S.of(context).seed_alert_back,
child: TextButton(
style: ButtonStyle(
overlayColor: WidgetStateColor.resolveWith(
(states) => Colors.transparent),
),
onPressed: () => Navigator.pop(context),
child: backButton(context),
),
),
),
),
);
}
@override
Widget middle(BuildContext context) {
return Text(
S.current.account,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
);
}
@override
Widget body(BuildContext context) {
return ScrollableWithBottomSection(
contentPadding: EdgeInsets.all(24),
content: Column(
children: [
SizedBox(height: 20),
Observer(
builder: (_) => Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 1.0,
color: Theme.of(context).colorScheme.outlineVariant,
),
),
),
child: CakePayTile(
title: S.of(context).email_address,
subTitle: cakePayAccountViewModel.email
),
),
),
],
),
bottomSectionPadding: EdgeInsets.all(30),
bottomSection: Column(
children: [
PrimaryButton(
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
text: S.of(context).logout,
onPressed: () {
cakePayAccountViewModel.logout();
Navigator.pushNamedAndRemoveUntil(context, Routes.dashboard, (route) => false);
},
),
],
),
);
}
}
@@ -1,154 +0,0 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/cake_pay/src/cake_pay_states.dart';
import 'package:hash_wallet/src/screens/base_page.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:hash_wallet/src/widgets/base_text_form_field.dart';
import 'package:hash_wallet/src/widgets/keyboard_done_button.dart';
import 'package:hash_wallet/src/widgets/primary_button.dart';
import 'package:hash_wallet/src/widgets/scrollable_with_bottom_section.dart';
import 'package:hash_wallet/typography.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_auth_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:keyboard_actions/keyboard_actions.dart';
import 'package:mobx/mobx.dart';
class CakePayVerifyOtpPage extends BasePage {
CakePayVerifyOtpPage(this._authViewModel, this._email, this.isSignIn)
: _codeController = TextEditingController(),
_codeFocus = FocusNode() {
_codeController.addListener(() {
final otp = _codeController.text;
_authViewModel.otp = otp;
if (otp.length > 5) {
_authViewModel.otpState = CakePayOtpSendEnabled();
} else {
_authViewModel.otpState = CakePayOtpSendDisabled();
}
});
}
final CakePayAuthViewModel _authViewModel;
final bool isSignIn;
final String _email;
@override
Widget middle(BuildContext context) {
return Text(
S.current.verification,
style: textMediumSemiBold(
color: Theme.of(context).colorScheme.onSurface,
),
);
}
final TextEditingController _codeController;
final FocusNode _codeFocus;
@override
Widget body(BuildContext context) {
reaction((_) => _authViewModel.otpState, (CakePayOtpState state) {
if (state is CakePayOtpFailure) {
_onOtpFailure(context, state.error);
}
if (state is CakePayOtpSuccess) {
_onOtpSuccessful(context);
}
});
return KeyboardActions(
config: KeyboardActionsConfig(
keyboardActionsPlatform: KeyboardActionsPlatform.IOS,
keyboardBarColor: Theme.of(context).colorScheme.surface,
nextFocus: false,
actions: [
KeyboardActionsItem(
focusNode: _codeFocus,
toolbarButtons: [(_) => KeyboardDoneButton()],
),
]),
child: Container(
height: 0,
color: Theme.of(context).colorScheme.surface,
child: ScrollableWithBottomSection(
contentPadding: EdgeInsets.all(24),
content: Column(
children: [
BaseTextFormField(
hintText: S.of(context).enter_code,
keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true),
focusNode: _codeFocus,
controller: _codeController,
onSubmit: (_) => _verify(),
),
SizedBox(height: 14),
Text(
S.of(context).fill_code,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
SizedBox(height: 34),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(S.of(context).didnt_get_code),
SizedBox(width: 20),
InkWell(
onTap: () => _authViewModel.logIn(_email),
child: Text(
S.of(context).resend_code,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
],
),
bottomSectionPadding: EdgeInsets.symmetric(vertical: 36, horizontal: 24),
bottomSection: Column(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Observer(
builder: (_) => LoadingPrimaryButton(
text: S.of(context).continue_text,
onPressed: _verify,
isDisabled: _authViewModel.otpState is CakePayOtpSendDisabled,
isLoading: _authViewModel.otpState is CakePayOtpValidating,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
SizedBox(height: 20),
],
),
],
),
),
),
);
}
void _onOtpFailure(BuildContext context, String error) {
showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: S.current.verification,
alertContent: error,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop());
});
}
void _onOtpSuccessful(BuildContext context) =>
Navigator.pop(context, true);
void _verify() async => await _authViewModel.verifyEmail(_codeController.text);
}
@@ -1,131 +0,0 @@
import 'package:hash_wallet/core/email_validator.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/cake_pay/src/cake_pay_states.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/base_page.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:hash_wallet/src/widgets/base_text_form_field.dart';
import 'package:hash_wallet/src/widgets/primary_button.dart';
import 'package:hash_wallet/src/widgets/scrollable_with_bottom_section.dart';
import 'package:hash_wallet/typography.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_auth_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
class CakePayWelcomePage extends BasePage {
CakePayWelcomePage(this._authViewModel)
: _formKey = GlobalKey<FormState>(),
_emailController = TextEditingController() {
_emailController.text = _authViewModel.email;
_emailController.addListener(() => _authViewModel.email = _emailController.text);
}
final GlobalKey<FormState> _formKey;
final CakePayAuthViewModel _authViewModel;
final TextEditingController _emailController;
@override
Widget middle(BuildContext context) {
return Text(
S.current.welcome_to_cakepay,
style: textMediumSemiBold(
color: Theme.of(context).colorScheme.onSurface,
),
);
}
@override
Widget body(BuildContext context) {
reaction((_) => _authViewModel.userVerificationState, (CakePayUserVerificationState state) {
if (state is CakePayUserVerificationStateFailure) {
_onLoginUserFailure(context, state.error);
}
if (state is CakePayUserVerificationStateSuccess) {
_onLoginSuccessful(context, _authViewModel);
}
});
return ScrollableWithBottomSection(
contentPadding: EdgeInsets.all(24),
content: Column(
children: [
SizedBox(height: 90),
Form(
key: _formKey,
child: BaseTextFormField(
hintText: S.of(context).email_address,
keyboardType: TextInputType.emailAddress,
validator: EmailValidator(),
controller: _emailController,
onSubmit: (text) => _login(),
),
),
SizedBox(height: 20),
Text(
S.of(context).about_cake_pay,
style: textLarge(
color: Theme.of(context).colorScheme.onSurface,
),
),
SizedBox(height: 20),
Text(S.of(context).cake_pay_account_note,
style: textLarge(color: Theme.of(context).colorScheme.onSurface)),
],
),
bottomSectionPadding: EdgeInsets.symmetric(vertical: 36, horizontal: 24),
bottomSection: Column(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
Observer(
builder: (_) => LoadingPrimaryButton(
text: S.of(context).login,
onPressed: _login,
isLoading:
_authViewModel.userVerificationState is CakePayUserVerificationStateLoading,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
SizedBox(
height: 20,
),
],
),
],
),
);
}
void _onLoginUserFailure(BuildContext context, String error) {
showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: S.current.login,
alertContent: error,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop());
});
}
Future<void> _onLoginSuccessful(BuildContext context, CakePayAuthViewModel authViewModel) async {
final verified = await Navigator.pushNamed<bool>(context, Routes.cakePayVerifyOtpPage,
arguments: [authViewModel.email, true]);
if (verified == true && Navigator.of(context).canPop()) {
Navigator.pop(context, true);
}
}
void _login() async {
if (_formKey.currentState != null && !_formKey.currentState!.validate()) {
return;
}
await _authViewModel.logIn(_emailController.text);
}
}
-68
View File
@@ -1,68 +0,0 @@
abstract class CakePayUserVerificationState {}
class CakePayUserVerificationStateInitial extends CakePayUserVerificationState {}
class CakePayUserVerificationStateSuccess extends CakePayUserVerificationState {}
class CakePayUserVerificationStatePending extends CakePayUserVerificationState {}
class CakePayUserVerificationStateLoading extends CakePayUserVerificationState {}
class CakePayUserVerificationStateFailure extends CakePayUserVerificationState {
CakePayUserVerificationStateFailure({required this.error});
final String error;
}
abstract class CakePayOtpState {}
class CakePayOtpValidating extends CakePayOtpState {}
class CakePayOtpSuccess extends CakePayOtpState {}
class CakePayOtpSendDisabled extends CakePayOtpState {}
class CakePayOtpSendEnabled extends CakePayOtpState {}
class CakePayOtpFailure extends CakePayOtpState {
CakePayOtpFailure({required this.error});
final String error;
}
class CakePayCreateCardState {}
class CakePayCreateCardStateSuccess extends CakePayCreateCardState {}
class CakePayCreateCardStateLoading extends CakePayCreateCardState {}
class CakePayCreateCardStateFailure extends CakePayCreateCardState {
CakePayCreateCardStateFailure({required this.error});
final String error;
}
class UserCakePayCardsState {}
class UserCakePayCardsStateInitial extends UserCakePayCardsState {}
class UserCakePayCardsStateNoCards extends UserCakePayCardsState {}
class UserCakePayCardsStateFetching extends UserCakePayCardsState {}
class UserCakePayCardsStateFailure extends UserCakePayCardsState {
UserCakePayCardsStateFailure({required this.error});
final String error;
}
class UserCakePayCardsStateSuccess extends UserCakePayCardsState {}
abstract class CakePayVendorState {}
class InitialCakePayVendorLoadingState extends CakePayVendorState {}
class CakePayVendorLoadingState extends CakePayVendorState {}
class CakePayVendorLoadedState extends CakePayVendorState {}
@@ -1,800 +0,0 @@
import 'dart:io';
import 'package:hash_wallet/bitcoin/bitcoin.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_card.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:hash_wallet/cake_pay/src/widgets/cake_pay_alert_modal.dart';
import 'package:hash_wallet/cake_pay/src/widgets/denominations_amount_widget.dart';
import 'package:hash_wallet/cake_pay/src/widgets/enter_amount_widget.dart';
import 'package:hash_wallet/cake_pay/src/widgets/image_placeholder.dart';
import 'package:hash_wallet/cake_pay/src/widgets/link_extractor.dart';
import 'package:hash_wallet/cake_pay/src/widgets/rounded_overlay_cards_widget.dart';
import 'package:hash_wallet/cake_pay/src/widgets/text_icon_button.dart';
import 'package:hash_wallet/cake_pay/src/widgets/three_checkbox_alert_content_widget.dart';
import 'package:hash_wallet/core/execution_state.dart';
import 'package:hash_wallet/entities/parsed_address.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/base_page.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:hash_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:hash_wallet/src/widgets/bottom_sheet/cake_pay_transaction_sent_bottom_sheet.dart';
import 'package:hash_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart';
import 'package:hash_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart';
import 'package:hash_wallet/src/widgets/keyboard_done_button.dart';
import 'package:hash_wallet/src/widgets/primary_button.dart';
import 'package:hash_wallet/src/widgets/standard_checkbox.dart';
import 'package:hash_wallet/typography.dart';
import 'package:hash_wallet/utils/feature_flag.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart';
import 'package:hash_wallet/view_model/send/output.dart';
import 'package:hash_wallet/view_model/send/send_view_model.dart';
import 'package:hash_wallet/view_model/send/send_view_model_state.dart';
import 'package:cw_core/unspent_coin_type.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:keyboard_actions/keyboard_actions.dart';
import 'package:mobx/mobx.dart';
class CakePayBuyCardPage extends BasePage {
CakePayBuyCardPage(this.cakePayBuyCardViewModel)
: _sendViewModel = cakePayBuyCardViewModel.sendViewModel,
_amountFieldFocus = FocusNode(),
_amountController = TextEditingController(),
_quantityFieldFocus = FocusNode(),
_quantityController =
TextEditingController(text: cakePayBuyCardViewModel.quantity.toString()) {
_amountController.addListener(() {
cakePayBuyCardViewModel.onAmountChanged(_amountController.text);
});
}
final CakePayBuyCardViewModel cakePayBuyCardViewModel;
final SendViewModel _sendViewModel;
bool _effectsInstalled = false;
late final BuildContext _overlayCtx;
@override
String get title => cakePayBuyCardViewModel.card.name;
@override
bool get extendBodyBehindAppBar => true;
@override
bool get gradientAll => true;
@override
bool get resizeToAvoidBottomInset => false;
@override
AppBarStyle get appBarStyle => AppBarStyle.completelyTransparent;
@override
Widget? trailing(BuildContext context) {
return const SizedBox(
width: 54,
height: 0,
);
}
@override
Widget? middle(BuildContext context) {
return Text(
title,
textAlign: TextAlign.center,
maxLines: 2,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontFamily: 'Lato',
color: titleColor(context)),
);
}
final TextEditingController _amountController;
final FocusNode _amountFieldFocus;
final TextEditingController _quantityController;
final FocusNode _quantityFieldFocus;
@override
Widget body(BuildContext context) {
_setEffects(context);
final card = cakePayBuyCardViewModel.card;
final vendor = cakePayBuyCardViewModel.vendor;
return Container(
color: Colors.transparent,
child: KeyboardHideOverlay(
child: Column(
children: [
RoundedOverlayCards(
topCardChild: Column(
children: [
Expanded(flex: 4, child: const SizedBox()),
Expanded(
flex: 7,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(150),
blurRadius: 8,
offset: const Offset(0, 2))
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(
card.cardImageUrl ?? '',
fit: BoxFit.cover,
loadingBuilder: (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(child: CircularProgressIndicator());
},
errorBuilder: (context, error, stackTrace) =>
CakePayCardImagePlaceholder(),
),
),
),
),
Expanded(child: const SizedBox()),
],
),
bottomCardChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: card.denominationItems.isNotEmpty
? DenominationsAmountWidget(
fiatCurrency: card.fiatCurrency.title,
denominations: card.denominationItems,
amountFieldFocus: _amountFieldFocus,
amountController: _amountController,
quantityFieldFocus: _quantityFieldFocus,
quantityController: _quantityController,
onAmountChanged: cakePayBuyCardViewModel.onAmountChanged,
onQuantityChanged: cakePayBuyCardViewModel.onQuantityChanged,
cakePayBuyCardViewModel: cakePayBuyCardViewModel)
: EnterAmountWidget(
minValue: card.minValue ?? '-',
maxValue: card.maxValue ?? '-',
fiatCurrency: card.fiatCurrency.title,
amountFieldFocus: _amountFieldFocus,
amountController: _amountController,
onAmountChanged: cakePayBuyCardViewModel.onAmountChanged))),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (vendor.cakeWarnings != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Center(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withAlpha(50)),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
vendor.cakeWarnings!,
textAlign: TextAlign.center,
style: textSmallSemiBold(color: Colors.white),
),
),
),
),
),
Expanded(
child: SingleChildScrollView(
primary: false,
padding: EdgeInsets.zero,
child: ClickableLinksText(
text: card.description ?? '',
textStyle: TextStyle(
color: Theme.of(context).textTheme.titleLarge!.color!,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
),
],
),
),
),
Expanded(
child: SingleChildScrollView(
primary: false,
child: Padding(
padding: const EdgeInsets.only(left: 24, right: 24),
child: Column(
children: [
if (card.expiryAndValidity != null && card.expiryAndValidity!.isNotEmpty)
Row(
children: [
Text(S.of(context).expiry_and_validity + ':',
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge!.color!,
fontSize: 16,
fontWeight: FontWeight.w900)),
Expanded(
child: Text(card.expiryAndValidity!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium)),
],
),
SizedBox(height: 8),
TextIconButton(
label: S.of(context).how_to_use_card,
onTap: () => _showHowToUseCard(context, card)),
SizedBox(height: 8),
TextIconButton(
label: S.of(context).settings_terms_and_conditions,
onTap: () => _showTermsAndCondition(context, card.termsAndConditions)),
],
),
),
),
),
SizedBox(height: 8),
Observer(builder: (_) {
Widget _buildPaymentMethodWidget(
List<CakePayPaymentMethod> methods, CakePayPaymentMethod selected) {
return Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 24, right: 8),
child: Text(
'Payment Method',
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge!.color!,
fontSize: 16,
fontWeight: FontWeight.w900,
),
),
),
Expanded(child: const SizedBox()),
if (methods.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
child: ToggleButtons(
isSelected: methods.map((m) => m == selected).toList(),
borderRadius: BorderRadius.circular(8),
onPressed: (index) =>
cakePayBuyCardViewModel.chooseMethod(methods[index]),
children: methods
.map((m) => Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(m.label),
))
.toList(),
),
),
],
);
}
final methods = cakePayBuyCardViewModel.availableMethods;
final selected = cakePayBuyCardViewModel.selectedPaymentMethod ??
(methods.isNotEmpty ? methods.first : null);
return Column(
children: [
methods.length <= 1 || selected == null
? const SizedBox.shrink()
: _buildPaymentMethodWidget(methods, selected),
if (_sendViewModel.walletType == WalletType.litecoin &&
_sendViewModel.isMwebEnabled)
Observer(
builder: (_) => Padding(
padding: EdgeInsets.only(top: 10, bottom: 0, right: 20, left: 20),
child: GestureDetector(
key: ValueKey('cake_pay_buy_page_unspent_coin_button_key'),
onTap: () {
bool value = _sendViewModel.coinTypeToSpendFrom == UnspentCoinType.any;
_sendViewModel.setAllowMwebCoins(!value);
},
child: Container(
color: Colors.transparent,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
StandardCheckbox(
caption: S.of(context).litecoin_mweb_allow_coins,
captionColor: Theme.of(context).colorScheme.onSurfaceVariant,
borderColor: Theme.of(context).colorScheme.primary,
iconColor: Theme.of(context).colorScheme.primary,
value: _sendViewModel.coinTypeToSpendFrom == UnspentCoinType.any,
onChanged: (bool? value) {
_sendViewModel.setAllowMwebCoins(value ?? false);
},
),
],
),
),
),
),
),
if (FeatureFlag.hasDevOptions && FeatureFlag.isCakePayPurchaseSimulationEnabled)
Padding(
padding: EdgeInsets.only(top: 10, bottom: 0, right: 20, left: 20),
child: LoadingPrimaryButton(
onPressed: () {
//Request dummy node to get the focus out of the text fields
FocusScope.of(context).requestFocus(FocusNode());
cakePayBuyCardViewModel.isSimulatingFlow = true;
isIOSUnavailable(card)
? alertIOSAvailability(context, card)
: confirmPurchaseFirst(context);
},
text: '(Dev) Simulate Purchasing Gift Card',
isDisabled: !cakePayBuyCardViewModel.isAmountSufficient ||
cakePayBuyCardViewModel.isPurchasing,
isLoading: _sendViewModel.state is IsExecutingState ||
cakePayBuyCardViewModel.isPurchasing,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
Padding(
padding: EdgeInsets.only(top: 10, bottom: 34, right: 20, left: 20),
child: LoadingPrimaryButton(
onPressed: () {
//Request dummy node to get the focus out of the text fields
FocusScope.of(context).requestFocus(FocusNode());
isIOSUnavailable(card)
? alertIOSAvailability(context, card)
: confirmPurchaseFirst(context);
},
text: S.of(context).purchase_gift_card,
isDisabled: !cakePayBuyCardViewModel.isAmountSufficient ||
cakePayBuyCardViewModel.isPurchasing || _sendViewModel.state is ExecutedSuccessfullyState,
isLoading: _sendViewModel.state is IsExecutingState ||
cakePayBuyCardViewModel.isPurchasing,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
],
);
}),
],
),
),
);
}
bool isWordInCardsName(CakePayCard card, String word) {
return card.name.toLowerCase().contains(word.toLowerCase());
}
bool isIOSUnavailable(CakePayCard card) {
if (!Platform.isIOS && !Platform.isMacOS) {
return false;
}
final isDigitalGameStores = isWordInCardsName(card, 'playstation') ||
isWordInCardsName(card, 'xbox') ||
isWordInCardsName(card, 'steam') ||
isWordInCardsName(card, 'meta quest') ||
isWordInCardsName(card, 'kigso') ||
isWordInCardsName(card, 'game world') ||
isWordInCardsName(card, 'google') ||
isWordInCardsName(card, 'nintendo');
final isGCodes = isWordInCardsName(card, 'gcodes');
final isApple = isWordInCardsName(card, 'itunes') || isWordInCardsName(card, 'apple');
final isTidal = isWordInCardsName(card, 'tidal');
final isVPNServices = isWordInCardsName(card, 'nordvpn') ||
isWordInCardsName(card, 'expressvpn') ||
isWordInCardsName(card, 'surfshark') ||
isWordInCardsName(card, 'proton');
final isStreamingServices = isWordInCardsName(card, 'netflix') ||
isWordInCardsName(card, 'spotify') ||
isWordInCardsName(card, 'hulu') ||
isWordInCardsName(card, 'hbo') ||
isWordInCardsName(card, 'soundcloud') ||
isWordInCardsName(card, 'twitch');
final isDatingServices = isWordInCardsName(card, 'tinder');
return isDigitalGameStores ||
isGCodes ||
isApple ||
isTidal ||
isVPNServices ||
isStreamingServices ||
isDatingServices;
}
Future<void> alertIOSAvailability(BuildContext context, CakePayCard card) async {
cakePayBuyCardViewModel.isSimulatingFlow = false;
return await showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
alertTitle: S.of(context).error,
alertContent: S.of(context).cakepay_ios_not_available,
buttonText: S.of(context).ok,
buttonAction: () {
// _walletHardwareRestoreVM.error = null;
Navigator.of(context).pop();
});
});
}
Future<void> confirmPurchaseFirst(BuildContext context) async {
bool isLogged = await cakePayBuyCardViewModel.isUserLogged;
if (!isLogged) {
cakePayBuyCardViewModel.isSimulatingFlow = false;
Navigator.of(context).pushNamed(Routes.cakePayWelcomePage);
} else {
cakePayBuyCardViewModel.isPurchasing = true;
await _showconfirmPurchaseFirstAlert(context);
}
}
Future<void> _showconfirmPurchaseFirstAlert(BuildContext context) async {
if (!cakePayBuyCardViewModel.confirmsNoVpn ||
!cakePayBuyCardViewModel.confirmsVoidedRefund ||
!cakePayBuyCardViewModel.confirmsTermsAgreed) {
await showPopUp<void>(
context: context,
builder: (BuildContext context) => ThreeCheckboxAlert(
alertTitle: S.of(context).cakepay_confirm_purchase,
leftButtonText: S.of(context).cancel,
rightButtonText: S.of(context).confirm,
actionLeftButton: () {
cakePayBuyCardViewModel.isPurchasing = false;
cakePayBuyCardViewModel.isSimulatingFlow = false;
Navigator.of(context).pop();
},
actionRightButton: (confirmsNoVpn, confirmsVoidedRefund, confirmsTermsAgreed) {
cakePayBuyCardViewModel.confirmsNoVpn = confirmsNoVpn;
cakePayBuyCardViewModel.confirmsVoidedRefund = confirmsVoidedRefund;
cakePayBuyCardViewModel.confirmsTermsAgreed = confirmsTermsAgreed;
Navigator.of(context).pop();
},
),
);
}
if (cakePayBuyCardViewModel.confirmsNoVpn &&
cakePayBuyCardViewModel.confirmsVoidedRefund &&
cakePayBuyCardViewModel.confirmsTermsAgreed) {
await purchaseCard(context);
} else {
cakePayBuyCardViewModel.isPurchasing = false;
cakePayBuyCardViewModel.isSimulatingFlow = false;
}
}
Future<void> purchaseCard(BuildContext context) async {
bool isLogged = await cakePayBuyCardViewModel.isUserLogged;
if (!isLogged) {
cakePayBuyCardViewModel.isSimulatingFlow = false;
Navigator.of(context).pushNamed(Routes.cakePayWelcomePage);
} else {
try {
await cakePayBuyCardViewModel.createOrder();
} catch (_) {
cakePayBuyCardViewModel.isSimulatingFlow = false;
await cakePayBuyCardViewModel.logout();
}
}
cakePayBuyCardViewModel.isPurchasing = false;
}
BuildContext? dialogContext;
BuildContext? loadingBottomSheetContext;
BuildContext? confirmBottomSheetContext;
void _setEffects(BuildContext context) {
if (_effectsInstalled) {
return;
}
_overlayCtx = Navigator.of(context).context;
if (_sendViewModel.isElectrumWallet) {
bitcoin!.updateFeeRates(_sendViewModel.wallet);
}
reaction((_) => _sendViewModel.state, (ExecutionState state) async {
if (dialogContext != null && dialogContext!.mounted) Navigator.of(dialogContext!).pop();
if (confirmBottomSheetContext != null && confirmBottomSheetContext!.mounted) {
Navigator.of(confirmBottomSheetContext!).pop();
}
if (state is! IsExecutingState &&
loadingBottomSheetContext != null &&
loadingBottomSheetContext!.mounted) {
Navigator.of(loadingBottomSheetContext!).pop();
}
if (state is FailureState) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted)
showPopUp<void>(
context: context,
builder: (BuildContext context) {
return AlertWithOneAction(
key: ValueKey('cake_pay_buy_page_send_failure_dialog_key'),
buttonKey: ValueKey('cake_pay_buy_page_send_failure_dialog_button_key'),
alertTitle: S.of(context).error,
alertContent: state.error,
buttonText: S.of(context).ok,
buttonAction: () => Navigator.of(context).pop());
});
});
}
if (state is IsExecutingState) {
// wait a bit to avoid showing the loading dialog if transaction is failed
await Future.delayed(const Duration(milliseconds: 300));
final currentState = _sendViewModel.state;
if (currentState is ExecutedSuccessfullyState || currentState is FailureState) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
showModalBottomSheet<void>(
context: context,
isDismissible: false,
builder: (BuildContext context) {
loadingBottomSheetContext = context;
return LoadingBottomSheet(
titleText: S.of(context).generating_transaction,
);
},
);
}
});
}
if (state is ExecutedSuccessfullyState) {
if (cakePayBuyCardViewModel.order == null) return;
ReactionDisposer? disposer;
disposer = reaction((_) => cakePayBuyCardViewModel.isOrderExpired, (bool isExpired) {
if (isExpired) {
_sendViewModel.state = FailureState('Order expired');
disposer?.call();
}
});
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (context.mounted) {
final order = cakePayBuyCardViewModel.order;
final displayingOutputs = _sendViewModel.outputs
.map((o) => o.OutputCopyWithParsedAddress(
parsedAddress: ParsedAddress(
addresses: [o.address],
name: 'Cake Pay',
profileName: order?.cards.first.cardName ?? 'Cake Pay',
profileImageUrl: order?.cards.first.cardImagePath ?? '',
),
fiatAmount: '${order?.totalReceiveAmount}',
))
.toList();
final result = await showModalBottomSheet<bool>(
context: context,
isDismissible: false,
isScrollControlled: true,
builder: (BuildContext bottomSheetContext) {
confirmBottomSheetContext = bottomSheetContext;
return ConfirmSendingBottomSheet(
key: ValueKey('cake_pay_buy_page_confirm_sending_dialog_key'),
titleText: S.of(bottomSheetContext).confirm_transaction,
cakePayBuyCardViewModel: cakePayBuyCardViewModel,
paymentId: S.of(bottomSheetContext).payment_id,
paymentIdValue: cakePayBuyCardViewModel.order?.orderId,
expirationTime: cakePayBuyCardViewModel.formattedRemainingTime,
walletType: _sendViewModel.walletType,
titleIconPath: _sendViewModel.selectedCryptoCurrency.iconPath,
currency: _sendViewModel.selectedCryptoCurrency,
amount: S.of(bottomSheetContext).send_amount,
amountValue: _sendViewModel.amountParsingProxy.getDisplayCryptoAmount(
_sendViewModel.pendingTransaction!.amountFormatted,
_sendViewModel.selectedCryptoCurrency),
quantity: 'QTY: ${cakePayBuyCardViewModel.quantity}',
fiatAmountValue: _sendViewModel.pendingTransactionFiatAmountFormatted,
fee: S.of(bottomSheetContext).send_fee,
feeValue: "${_sendViewModel.amountParsingProxy.getDisplayCryptoAmount(
_sendViewModel.pendingTransaction!.feeFormattedValue,
_sendViewModel.selectedCryptoCurrency)} ${_sendViewModel.amountParsingProxy
.getCryptoSymbol(_sendViewModel.wallet.currency)}",
feeFiatAmount: _sendViewModel.pendingTransactionFeeFiatAmountFormatted,
outputs: displayingOutputs,
footerType: FooterType.slideActionButton,
isSlideActionEnabled: _sendViewModel.isReadyForSend,
slideActionButtonText:
cakePayBuyCardViewModel.isSimulating ? 'Swipe to simulate' : 'Swipe to send',
accessibleNavigationModeSlideActionButtonText:
cakePayBuyCardViewModel.isSimulating ? 'Simulate' : S.of(context).send,
onSlideActionComplete: () async {
Navigator.of(bottomSheetContext).pop(true);
cakePayBuyCardViewModel.isSimulating
? cakePayBuyCardViewModel.simulatePayment()
: _sendViewModel.commitTransaction(context);
},
change: _sendViewModel.pendingTransaction!.change,
isOpenCryptoPay: _sendViewModel.ocpRequest != null,
amountParsingProxy: _sendViewModel.amountParsingProxy,
);
},
);
confirmBottomSheetContext = null;
cakePayBuyCardViewModel.isSimulatingFlow = false;
_handleDispose(disposer);
if (result == null) _sendViewModel.dismissTransaction();
}
});
}
if (state is TransactionCommitted) {
final order = cakePayBuyCardViewModel.order;
final outputsCopy = List<Output>.from(_sendViewModel.outputs);
final displayingOutputs = outputsCopy
.map((o) => o.OutputCopyWithParsedAddress(
parsedAddress: ParsedAddress(
addresses: [o.address],
name: 'Cake Pay',
profileName: order?.cards.first.cardName ?? 'Cake Pay',
profileImageUrl: order?.cards.first.cardImagePath ?? '',
),
fiatAmount: '${order?.totalReceiveAmount}',
))
.toList();
_sendViewModel.clearOutputs();
final bool usePageContextLater = context.mounted;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (displayingOutputs.isEmpty) {
if (context.mounted) Navigator.of(context).pop();
return;
}
final BuildContext sheetParentCtx = usePageContextLater ? context : _overlayCtx;
if (!sheetParentCtx.mounted) {
return;
}
await showModalBottomSheet<void>(
context: sheetParentCtx,
useRootNavigator: !usePageContextLater,
isScrollControlled: true,
isDismissible: true,
backgroundColor: Colors.transparent,
builder: (sheetCtx) {
return CakePayTransactionSentBottomSheet(
key: const ValueKey('cake_pay_buy_page_transaction_sent_bottom_sheet_key'),
titleText: S.of(sheetCtx).transaction_sent,
titleIconWidget: const CircleAvatar(
radius: 10,
backgroundColor: Colors.green,
child: Icon(Icons.check, size: 16, color: Colors.white),
),
output: displayingOutputs.first,
currency: _sendViewModel.selectedCryptoCurrency,
amount: S.of(sheetCtx).send_amount,
amountValue: _sendViewModel.pendingTransaction!.amountFormatted,
quantity: 'QTY: ${cakePayBuyCardViewModel.quantity}',
fiatAmountValue: _sendViewModel.pendingTransactionFiatAmountFormatted,
fee: S.of(sheetCtx).send_fee,
feeValue: _sendViewModel.pendingTransaction!.feeFormatted,
feeFiatAmount: _sendViewModel.pendingTransactionFeeFiatAmountFormatted,
paymentId: 'Order ID',
paymentIdValue: order?.orderId ?? '',
onClose: () {
Navigator.of(sheetCtx).pop();
},
);
},
);
if (context.mounted) {
Navigator.of(context).pop();
}
});
}
if (state is IsAwaitingDeviceResponseState) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
showModalBottomSheet<void>(
context: context,
isDismissible: false,
builder: (BuildContext bottomSheetContext) => InfoBottomSheet(
footerType: FooterType.singleActionButton,
titleText: S.of(bottomSheetContext).proceed_on_device,
contentImage: 'assets/images/hardware_wallet/ledger_nano_x.png',
contentImageColor: Theme.of(context).textTheme.titleLarge!.color!,
content: S.of(bottomSheetContext).proceed_on_device_description,
singleActionButtonText: S.of(context).cancel,
onSingleActionButtonPressed: () {
_sendViewModel.state = InitialExecutionState();
Navigator.of(bottomSheetContext).pop();
},
),
);
});
}
});
_effectsInstalled = true;
}
void _handleDispose(ReactionDisposer? disposer) {
cakePayBuyCardViewModel.dispose();
if (disposer != null) {
disposer();
}
}
}
void _showHowToUseCard(BuildContext context, CakePayCard card) {
showPopUp<void>(
context: context,
builder: (BuildContext context) {
return CakePayAlertModal(
title: S.of(context).how_to_use_card,
dismissible: true,
content: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: EdgeInsets.all(10),
child: Text(card.name, style: Theme.of(context).textTheme.headlineSmall)),
ClickableLinksText(
text: card.howToUse ?? '',
textStyle: Theme.of(context).textTheme.bodyMedium!,
linkStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.primary, fontStyle: FontStyle.italic))
]),
actionTitle: S.current.got_it,
showCloseButton: false,
);
});
}
void _showTermsAndCondition(BuildContext context, String? termsAndConditions) {
showPopUp<void>(
context: context,
builder: (BuildContext context) {
return CakePayAlertModal(
title: S.of(context).settings_terms_and_conditions,
dismissible: true,
content: Align(
alignment: Alignment.bottomLeft,
child: ClickableLinksText(
text: termsAndConditions ?? '',
textStyle: Theme.of(context).textTheme.bodyMedium!,
linkStyle: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.primary, fontStyle: FontStyle.italic))),
actionTitle: S.of(context).agree,
showCloseButton: false,
);
});
}
@@ -1,510 +0,0 @@
import 'package:hash_wallet/cake_pay/src/cake_pay_states.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_card.dart';
import 'package:hash_wallet/cake_pay/src/widgets/cake_pay_search_bar_widget.dart';
import 'package:hash_wallet/cake_pay/src/widgets/user_card_item.dart';
import 'package:hash_wallet/entities/country.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/base_page.dart';
import 'package:hash_wallet/cake_pay/src/widgets/card_item.dart';
import 'package:hash_wallet/src/screens/dashboard/widgets/filter_widget.dart';
import 'package:hash_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart';
import 'package:hash_wallet/src/widgets/bottom_sheet/cake_pay_card_info_bottom_sheet_widget.dart';
import 'package:hash_wallet/src/widgets/gradient_background.dart';
import 'package:hash_wallet/src/widgets/picker.dart';
import 'package:hash_wallet/src/widgets/tab_view_wrapper_widget.dart';
import 'package:hash_wallet/typography.dart';
import 'package:hash_wallet/utils/feature_flag.dart';
import 'package:hash_wallet/utils/responsive_layout_util.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_cards_list_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
class CakePayCardsPage extends BasePage {
CakePayCardsPage(this._cardsListViewModel);
final CakePayCardsListViewModel _cardsListViewModel;
@override
bool get gradientBackground => true;
@override
Widget Function(BuildContext, Widget) get rootWrapper =>
(BuildContext context, Widget scaffold) => GradientBackground(scaffold: scaffold);
@override
bool get resizeToAvoidBottomInset => false;
@override
Widget middle(BuildContext context) {
return Text(
'Cake Pay',
style: textMediumSemiBold(
color: titleColor(context),
),
);
}
@override
Widget trailing(BuildContext context) {
return Observer(builder: (_) {
final loggedIn = _cardsListViewModel.isUserAuthenticated == true;
Future<void> _handleOnPressed() async {
if (loggedIn) {
Navigator.pushNamed(context, Routes.cakePayAccountPage);
return;
}
final success = await Navigator.pushNamed<bool>(context, Routes.cakePayWelcomePage);
if (success == true) await _cardsListViewModel.checkAuth();
}
if (!loggedIn || _cardsListViewModel.username == null) {
return _TrailingIcon(
asset: 'assets/images/profile.png',
iconColor: pageIconColor(context) ?? Colors.white,
onPressed: () async => await _handleOnPressed(),
);
}
final letter = _cardsListViewModel.username!.trim()[0].toUpperCase();
return IconButton(
padding: EdgeInsets.zero,
iconSize: 25,
onPressed: _handleOnPressed,
icon: CircleAvatar(
radius: 12,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
letter,
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.w600,
),
),
),
);
});
}
@override
Widget body(BuildContext context) {
return KeyboardHideOverlay(child:CakePayCardsPageBody(cardsListViewModel: _cardsListViewModel, titleColor: titleColor));
}
}
class CakePayCardsPageBody extends StatefulWidget {
const CakePayCardsPageBody({
super.key,
required CakePayCardsListViewModel cardsListViewModel,
required this.titleColor,
}) : _cardsListViewModel = cardsListViewModel;
final CakePayCardsListViewModel _cardsListViewModel;
final Color? Function(BuildContext) titleColor;
@override
State<CakePayCardsPageBody> createState() => _CakePayCardsPageBodyState();
}
class _CakePayCardsPageBodyState extends State<CakePayCardsPageBody> {
ReactionDisposer? _countryPickerDisposer;
@override
void initState() {
super.initState();
final viewModel = widget._cardsListViewModel;
_countryPickerDisposer = when(
(_) => viewModel.shouldShowCountryPicker,
() async {
viewModel.storeInitialFilterStates();
WidgetsBinding.instance.addPostFrameCallback(
(_) async {
await showCountryPicker(context, viewModel);
if (viewModel.hasFiltersChanged) {
viewModel.resetLoadingNextPageState();
viewModel.getVendors();
}
viewModel.settingsStore.selectedCakePayCountry = viewModel.selectedCountry;
},
);
},
);
}
@override
void dispose() {
_countryPickerDisposer?.call();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Observer(builder: (_) {
final isUserAuthenticated = widget._cardsListViewModel.isUserAuthenticated;
if (isUserAuthenticated == null) return const _Loading();
if (isUserAuthenticated == false || !FeatureFlag.isCakePayRedemptionFlowEnabled) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: _ShopTab(cardsListViewModel: widget._cardsListViewModel),
);
}
final titleColor = widget.titleColor(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Column(children: [
Expanded(
child: TabViewWrapper(
labelStyle: TextStyle(
color: titleColor,
fontFamily: 'Lato',
fontSize: 20,
fontWeight: FontWeight.w600),
unselectedLabelStyle: TextStyle(
color: titleColor?.withAlpha(150) ?? Colors.white70,
fontFamily: 'Lato',
fontSize: 20,
fontWeight: FontWeight.w400),
indicatorColor: titleColor,
tabs: const [
Tab(text: 'My Cards'),
Tab(text: 'Shop')
],
views: [
_MyCardsTab(
cardsListViewModel: widget._cardsListViewModel,
),
_ShopTab(cardsListViewModel: widget._cardsListViewModel)
]),
)
]));
});
}
}
Future<void> showFilterWidget(
BuildContext context, CakePayCardsListViewModel cardsListViewModel) async {
return showPopUp<void>(
context: context,
builder: (BuildContext context) {
return FilterWidget(filterItems: cardsListViewModel.createFilterItems);
},
);
}
Future<void> showCountryPicker(
BuildContext context, CakePayCardsListViewModel cardsListViewModel) async {
await showPopUp<void>(
context: context,
builder: (_) => Picker(
title: S.of(context).select_your_country,
items: cardsListViewModel.availableCountries,
images: cardsListViewModel.availableCountries
.map((e) => Image.asset(
e.iconPath,
errorBuilder: (context, error, stackTrace) => Container(
width: 58,
height: 58,
),
))
.toList(),
selectedAtIndex:
cardsListViewModel.availableCountries.indexOf(cardsListViewModel.selectedCountry),
onItemSelected: (Country country) => cardsListViewModel.setSelectedCountry(country),
isSeparated: false,
hintText: S.of(context).search,
matchingCriteria: (Country country, String searchText) =>
country.fullName.toLowerCase().contains(searchText.toLowerCase())));
}
class _TrailingIcon extends StatelessWidget {
const _TrailingIcon({required this.asset, this.onPressed, required this.iconColor});
final String asset;
final VoidCallback? onPressed;
final Color iconColor;
@override
Widget build(BuildContext context) {
return Semantics(
label: S.of(context).profile,
child: Material(
color: Colors.transparent,
child: IconButton(
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
highlightColor: Colors.transparent,
onPressed: onPressed,
icon: ImageIcon(AssetImage(asset), size: 25, color: iconColor),
),
));
}
}
class _MyCardsTab extends StatefulWidget {
const _MyCardsTab({required this.cardsListViewModel});
final CakePayCardsListViewModel cardsListViewModel;
@override
State<_MyCardsTab> createState() => _MyCardsTabState();
}
class _MyCardsTabState extends State<_MyCardsTab> {
late final TextEditingController _searchController;
@override
void initState() {
super.initState();
_searchController = TextEditingController(
text: widget.cardsListViewModel.searchMyCardsString,
);
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
final viewModel = widget.cardsListViewModel;
return Observer(builder: (_) {
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(2, 6, 0, 6),
child: CakePaySearchBar(
initialQuery: viewModel.searchMyCardsString,
controller: _searchController,
onSearch: viewModel.setMyCardsQuery,
onFilter: () async {}, // TODO: implement filter
)),
Expanded(
child: Observer(builder: (_) {
final cards = viewModel.filteredUserCards;
if (viewModel.userCardState is UserCakePayCardsStateFetching) return const _Loading();
if (viewModel.userCardState is UserCakePayCardsStateNoCards)
return Center(child: Text(S.of(context).no_cards_found));
final showThumb = cards.length > 6;
final userCardsList = Stack(
children: [
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
childAspectRatio: 1.25,
crossAxisCount: responsiveLayoutUtil.shouldRenderTabletUI ? 3 : 2,
crossAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5,
mainAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5),
padding: EdgeInsets.only(left: 2, right: showThumb ? 10 : 22),
itemCount: cards.length,
itemBuilder: (_, i) {
final card = cards[i];
return UserCardItem(
logoUrl: card.cardImageUrl,
title: card.name,
subTitle: '\$100',
onTap: () => _showCardInfoBottomSheet(context, card),
);
},
),
],
);
return showThumb
? Scrollbar(
key: ValueKey('cake_pay_my_cards_tab_scrollbar_key'),
thumbVisibility: true,
trackVisibility: true,
child: userCardsList,
)
: userCardsList;
}),
),
],
);
});
}
}
Future<void> _showCardInfoBottomSheet(BuildContext context, CakePayCard card) async {
bool isReloadable = false; // TODO: replace with real logic
if (card.name.toLowerCase().contains('prepaid')) {
isReloadable = true;
}
await showModalBottomSheet<void>(
context: context,
isDismissible: false,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext bottomSheetContext) {
return isReloadable
? CakePayCardInfoBottomSheet(
isReloadable: isReloadable,
titleText: 'Reloadable Card',
balance: '100 USD',
howToUse: card.howToUse,
footerType: FooterType.doubleActionButton,
applyBoxShadow: true,
contentImage: card.cardImageUrl,
leftActionButtonKey: const Key('cake_pay_cards_page_reload_card_left_button_key'),
doubleActionLeftButtonText: 'Archive',
onLeftActionButtonPressed: () {},
rightActionButtonKey: const Key('cake_pay_cards_page_reload_card_right_button_key'),
doubleActionRightButtonText: 'Top Up',
onRightActionButtonPressed: () {},
onUpdateBalancePressed: () {})
: CakePayCardInfoBottomSheet(
isReloadable: isReloadable,
titleText: card.name,
balance: '500 USD',
howToUse: card.howToUse,
footerType: FooterType.singleActionButton,
applyBoxShadow: true,
contentImage: card.cardImageUrl,
singleActionButtonKey: const Key('cake_pay_cards_page_card_info_bottom_sheet_key'),
singleActionButtonText: 'Mark As Used',
onSingleActionButtonPressed: () {},
onUpdateBalancePressed: () {});
},
);
}
class _ShopTab extends StatefulWidget {
const _ShopTab({required this.cardsListViewModel});
final CakePayCardsListViewModel cardsListViewModel;
@override
State<_ShopTab> createState() => _ShopTabState();
}
class _ShopTabState extends State<_ShopTab> {
late final ScrollController _scroll;
@override
void initState() {
super.initState();
_scroll = ScrollController()
..addListener(() {
if (!_scroll.hasClients) return;
final max = _scroll.position.maxScrollExtent;
final threshold = 200.0;
if (_scroll.offset >= max - threshold && !_scroll.position.outOfRange) {
widget.cardsListViewModel.fetchNextPage();
}
});
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final viewModel = widget.cardsListViewModel;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(2, 6, 0, 6),
child: CakePaySearchBar(
initialQuery: viewModel.searchString,
onSearch: (String searchText) {
if (searchText != viewModel.searchString) {
viewModel.searchString = searchText;
viewModel.resetLoadingNextPageState();
viewModel.getVendors(text: searchText);
}
},
onFilter: () async {
viewModel.storeInitialFilterStates();
await showFilterWidget(context, viewModel);
if (viewModel.hasFiltersChanged) {
viewModel.resetLoadingNextPageState();
viewModel.getVendors(text: viewModel.searchString);
}
},
onCountryPick: () async {
viewModel.storeInitialFilterStates();
await showCountryPicker(context, viewModel);
if (viewModel.hasFiltersChanged) {
viewModel.resetLoadingNextPageState();
viewModel.getVendors(text: viewModel.searchString);
}
},
selectedCountry: viewModel.selectedCountry,
),
),
Expanded(
child: Observer(builder: (_) {
final vendors = viewModel.cakePayVendors;
if (viewModel.vendorsState is! CakePayVendorLoadedState) {
return const _Loading();
}
if (vendors.isEmpty) return Center(child: Text(S.of(context).no_cards_found));
final loadingMore = viewModel.isLoadingNextPage;
final showThumb = vendors.length > 3;
final cardsList = Stack(
children: [
GridView.builder(
controller: _scroll,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: responsiveLayoutUtil.shouldRenderTabletUI ? 2 : 1,
childAspectRatio: 5,
crossAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5,
mainAxisSpacing: responsiveLayoutUtil.shouldRenderTabletUI ? 10 : 5),
padding: EdgeInsets.only(left: 2, right: showThumb ? 10 : 22),
itemCount: vendors.length + (loadingMore ? 1 : 0),
itemBuilder: (_, i) {
if (i >= vendors.length) return const _Loading();
final vendor = vendors[i];
return CardItem(
logoUrl: vendor.card?.cardImageUrl,
title: vendor.name,
subTitle: vendor.card?.description ?? '',
onTap: () => Navigator.pushNamed(context, Routes.cakePayBuyCardPage,
arguments: [vendor]));
},
),
],
);
return showThumb
? Scrollbar(
key: ValueKey('cake_pay_shop_tab_scrollbar_key'),
thumbVisibility: true,
trackVisibility: true,
controller: _scroll,
child: cardsList,
)
: cardsList;
}),
),
],
);
}
}
class _Loading extends StatelessWidget {
const _Loading();
@override
Widget build(BuildContext context) => Center(
child: CircularProgressIndicator(),
);
}
-114
View File
@@ -1,114 +0,0 @@
import 'package:hash_wallet/entities/fiat_currency.dart';
class CakePayCard {
final int id;
final String name;
final String? description;
final String? termsAndConditions;
final String? howToUse;
final String? expiryAndValidity;
final String? cardImageUrl;
final String? country;
final FiatCurrency fiatCurrency;
final String? minValueUsd;
final String? maxValueUsd;
final String? minValue;
final String? maxValue;
final List<Denomination> denominationItems;
CakePayCard({
required this.id,
required this.name,
this.description,
this.termsAndConditions,
this.howToUse,
this.expiryAndValidity,
this.cardImageUrl,
this.country,
required this.fiatCurrency,
this.minValueUsd,
this.maxValueUsd,
this.minValue,
this.maxValue,
List<Denomination>? denominationItems,
}) : denominationItems = denominationItems ?? const [];
factory CakePayCard.fromJson(Map<String, dynamic> json) {
final name = stripHtmlIfNeeded(json['name'] as String? ?? '');
final description = stripHtmlIfNeeded(json['description'] as String? ?? '');
final termsAndConditions = stripHtmlIfNeeded(json['terms_and_conditions'] as String? ?? '');
final howToUse = stripHtmlIfNeeded(json['how_to_use'] as String? ?? '');
final fiatCurrency = FiatCurrency.deserialize(raw: json['currency_code'] as String? ?? '');
String? minValue = json['min_value'] as String?;
final parsedMinValueLocal = _toDouble(json['min_value']);
final parsedMinValueUsd = _toDouble(json['min_value_usd']);
if (parsedMinValueLocal != null && parsedMinValueLocal > 0 && parsedMinValueUsd != null && parsedMinValueUsd > 0 && parsedMinValueUsd < 10.0) {
final rate = parsedMinValueLocal / parsedMinValueUsd;
final minLocalValueLimit = 10.0 * rate;
minValue = minLocalValueLimit.toStringAsFixed(2);
}
final raw = (json['denominations'] as List?) ?? const [];
final denominations = <Denomination>[];
for (final item in raw) {
if (item is Map) {
denominations.add(Denomination.fromJson(Map<String, dynamic>.from(item)));
}
}
return CakePayCard(
id: json['id'] as int? ?? 0,
name: name,
description: description,
termsAndConditions: termsAndConditions,
howToUse: howToUse,
expiryAndValidity: json['expiry_and_validity'] as String?,
cardImageUrl: json['card_image_url'] as String?,
country: json['country'] as String?,
fiatCurrency: fiatCurrency,
minValueUsd: json['min_value_usd'] as String?,
maxValueUsd: json['max_value_usd'] as String?,
minValue: minValue,
maxValue: json['max_value'] as String?,
denominationItems: denominations,
);
}
static String stripHtmlIfNeeded(String text) {
return text.replaceAll(RegExp(r'<[^>]*>|&[^;]+;'), ' ');
}
static double? _toDouble(dynamic v) {
if (v == null) return null;
if (v is num) return v.toDouble();
return double.tryParse(v.toString());
}
}
class Denomination {
final double value;
final int? cardId;
final double? usdValue;
Denomination({
required this.value,
required this.cardId,
this.usdValue,
});
static double? _toDouble(dynamic v) {
if (v == null) return null;
if (v is num) return v.toDouble();
return double.tryParse(v.toString());
}
factory Denomination.fromJson(Map<String, dynamic> json) {
return Denomination(
value: _toDouble(json['value']) ?? 0,
cardId: json['card_id'] is int ? json['card_id'] as int : int.tryParse('${json['card_id']}'),
usdValue: _toDouble(json['usd_value']),
);
}
}
-214
View File
@@ -1,214 +0,0 @@
enum CakePayPaymentMethod { BTC, BTC_LN, XMR, LTC, LTC_MWEB, ZEC }
extension CakePayPaymentMethodLabel on CakePayPaymentMethod {
String get label => switch (this) {
CakePayPaymentMethod.BTC => 'Bitcoin',
CakePayPaymentMethod.BTC_LN => 'Lightning',
CakePayPaymentMethod.XMR => 'Monero',
CakePayPaymentMethod.LTC => 'Litecoin',
CakePayPaymentMethod.LTC_MWEB => 'MWEB',
CakePayPaymentMethod.ZEC => 'Zcash',
};
}
class CakePayOrder {
final String orderId;
final List<OrderCard> cards;
final String? externalId;
final double amountUsd;
final String totalReceiveAmount;
final int quantity;
final String status;
final String? vouchers;
final String fiatCurrencyCode;
final PaymentData paymentData;
CakePayOrder({
required this.orderId,
required this.cards,
required this.externalId,
required this.amountUsd,
required this.totalReceiveAmount,
required this.quantity,
required this.status,
required this.vouchers,
required this.fiatCurrencyCode,
required this.paymentData,
});
factory CakePayOrder.fromMap(Map<String, dynamic> map) {
final cards = map['cards'] as List<dynamic>;
final firstCard = cards.isNotEmpty ? cards.first as Map<String, dynamic> : {};
return CakePayOrder(
orderId: map['order_id'] as String,
cards: cards
.map((x) => OrderCard.fromMap(x as Map<String, dynamic>))
.toList(),
externalId: map['external_id'] as String?,
amountUsd: map['amount_usd'] as double,
totalReceiveAmount: firstCard['subtotal'] as String? ?? '',
quantity: firstCard['quantity'] as int,
status: map['status'] as String,
vouchers: map['vouchers'] as String?,
fiatCurrencyCode: firstCard['currency_code'] as String? ?? '',
paymentData: PaymentData.fromMap(map['payment_data'] as Map<String, dynamic>));
}
static CryptoPaymentData? getPaymentDataFor({CakePayPaymentMethod? method,CakePayOrder? order}) {
if (order == null || method == null) return null;
final data = switch (method) {
CakePayPaymentMethod.BTC => order.paymentData.btc,
CakePayPaymentMethod.XMR => order.paymentData.xmr,
CakePayPaymentMethod.LTC => order.paymentData.ltc,
CakePayPaymentMethod.LTC_MWEB => order.paymentData.ltc_mweb,
CakePayPaymentMethod.ZEC => order.paymentData.zec,
CakePayPaymentMethod.BTC_LN => order.paymentData.btc_ln,
};
if (data == null) return null;
final bip21 = data.paymentUrls?.bip21;
if (bip21 != null && bip21.isNotEmpty) {
final uri = Uri.parse(bip21);
final addr = uri.path;
final price = uri.queryParameters['amount'] ?? data.price;
return CryptoPaymentData(price: price, address: addr, amount: data.amount, paymentUrls: data.paymentUrls);
}
return data;
}
static String getCurrencyCodeFromPaymentMethod(CakePayPaymentMethod method) {
return switch (method) {
CakePayPaymentMethod.BTC => 'BTC',
CakePayPaymentMethod.BTC_LN => 'BTC',
CakePayPaymentMethod.XMR => 'XMR',
CakePayPaymentMethod.LTC => 'LTC',
CakePayPaymentMethod.LTC_MWEB => 'LTC',
CakePayPaymentMethod.ZEC => 'ZEC',
};
}
}
class OrderCard {
final int cardId;
final int? externalId;
final String price;
final int quantity;
final String currencyCode;
final String? cardName;
final String? cardImagePath;
OrderCard({
required this.cardId,
required this.externalId,
required this.price,
required this.quantity,
required this.currencyCode,
required this.cardName,
required this.cardImagePath,
});
factory OrderCard.fromMap(Map<String, dynamic> map) {
return OrderCard(
cardId: map['card_id'] as int,
externalId: map['external_id'] as int?,
price: map['price'] as String,
quantity: map['quantity'] as int,
currencyCode: map['currency_code'] as String,
cardName: map['name'] as String?,
cardImagePath: map['card_image_url'] as String?,
);
}
}
class PaymentData {
final CryptoPaymentData? btc;
final CryptoPaymentData? btc_ln;
final CryptoPaymentData? xmr;
final CryptoPaymentData? ltc;
final CryptoPaymentData? ltc_mweb;
final CryptoPaymentData? zec;
final DateTime invoiceTime;
final DateTime expirationTime;
final int? commission;
PaymentData({
required this.btc,
required this.btc_ln,
required this.xmr,
required this.ltc,
required this.ltc_mweb,
required this.zec,
required this.invoiceTime,
required this.expirationTime,
required this.commission,
});
factory PaymentData.fromMap(Map<String, dynamic> map) {
return PaymentData(
btc: map['BTC'] != null ? CryptoPaymentData.fromMap(map['BTC'] as Map<String, dynamic>) : null,
btc_ln: map['BTC_LN'] != null ? CryptoPaymentData.fromMap(map['BTC_LN'] as Map<String, dynamic>) : null,
xmr: map['XMR'] != null ? CryptoPaymentData.fromMap(map['XMR'] as Map<String, dynamic>) : null,
ltc: map['LTC'] != null ? CryptoPaymentData.fromMap(map['LTC'] as Map<String, dynamic>) : null,
ltc_mweb: map['LTC_MWEB'] != null ? CryptoPaymentData.fromMap(map['LTC_MWEB'] as Map<String, dynamic>) : null,
zec: map['ZEC_CHAIN'] != null ? CryptoPaymentData.fromMap(map['ZEC_CHAIN'] as Map<String, dynamic>) : null,
invoiceTime: DateTime.fromMillisecondsSinceEpoch(map['invoice_time'] as int),
expirationTime: DateTime.fromMillisecondsSinceEpoch(map['expiration_time'] as int),
commission: map['commission'] as int?,
);
}
}
class CryptoPaymentData {
final String price;
final String amount;
final PaymentUrl? paymentUrls;
final String address;
CryptoPaymentData({
required this.price,
required this.amount,
this.paymentUrls,
required this.address,
});
factory CryptoPaymentData.fromMap(Map<String, dynamic> map) {
return CryptoPaymentData(
price: map['price'] as String,
amount: (map['amount_from'] as double?)?.toString() ?? '',
paymentUrls: PaymentUrl.fromMap(map['paymentUrls'] as Map<String, dynamic>?),
address: map['address'] as String,
);
}
}
class PaymentUrl {
final String? bip21;
final String? bip72;
final String? bip72b;
final String? bip73;
final String? bolt11;
PaymentUrl({
this.bip21,
this.bip72,
this.bip72b,
this.bip73,
this.bolt11,
});
factory PaymentUrl.fromMap(Map<String, dynamic>? map) {
return PaymentUrl(
bip21: map?['BIP21'] as String?,
bip72: map?['BIP72'] as String?,
bip72b: map?['BIP72b'] as String?,
bip73: map?['BIP73'] as String?,
bolt11: map?['BOLT11'] as String?,
);
}
}
@@ -1,6 +0,0 @@
class CakePayUserCredentials {
const CakePayUserCredentials(this.username, this.token);
final String username;
final String token;
}
@@ -1,55 +0,0 @@
import 'package:hash_wallet/entities/country.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'cake_pay_card.dart';
class CakePayVendor {
final int id;
final String name;
final bool available;
final String? cakeWarnings;
final String country;
final CakePayCard? card;
CakePayVendor({
required this.id,
required this.name,
required this.available,
this.cakeWarnings,
required this.country,
this.card,
});
factory CakePayVendor.fromJson(Map<String, dynamic> json) {
final name = stripHtmlIfNeeded(json['name'] as String);
final parsedCountry = json['country'] as String;
final country = Country.normalizeName(parsedCountry);
var cardsJson = json['cards'] as List?;
CakePayCard? cardForVendor;
if (cardsJson != null && cardsJson.isNotEmpty) {
try {
cardForVendor = CakePayCard.fromJson(cardsJson.firstWhere((card) {
return Country.normalizeName(card['country'] as String) == country;
}) as Map<String, dynamic>);
} catch (e) {
printV('Error parsing card for vendor: $e');
}
}
return CakePayVendor(
id: json['id'] as int,
name: name,
available: json['available'] as bool? ?? false,
cakeWarnings: json['cake_warnings'] as String?,
country: country,
card: cardForVendor,
);
}
static String stripHtmlIfNeeded(String text) {
return text.replaceAll(RegExp(r'<[^>]*>|&[^;]+;'), ' ');
}
}
-289
View File
@@ -1,289 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_user_credentials.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_vendor.dart';
import 'package:hash_wallet/utils/feature_flag.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/utils/print_verbose.dart';
class CakePayApi {
static const testBaseUri = FeatureFlag.hasDevOptions;
static const baseTestCakePayUri = 'api-stg.cakepay.com';
static const baseProdCakePayUri = 'api-prod.cakepay.com';
static const baseCakePayUri = testBaseUri ? baseTestCakePayUri : baseProdCakePayUri;
static const vendorsPath = '/api/marketplace/vendors';
static const authPath = '/api/accounts/auth';
static final verifyEmailPath = '/api/accounts/auth/verify';
static final logoutPath = '/api/accounts/logout';
static final orderPath = '/api/orders/order';
static final simulatePaymentPath = '/api/orders/simulate-payment';
/// AuthenticateUser
Future<String> authenticateUser({required String email, required String apiKey}) async {
try {
final uri = Uri.https(baseCakePayUri, authPath);
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Api-Key $apiKey',
};
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode({'email': email}),
);
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final bodyJson = json.decode(response.body) as Map<String, dynamic>;
if (bodyJson.containsKey('user') && bodyJson['user']['email'] != null) {
return bodyJson['user']['email'] as String;
}
throw Exception('Failed to authenticate user with error: $bodyJson');
} catch (e) {
throw Exception('Failed to authenticate user with error: $e');
}
}
/// Verify email
Future<CakePayUserCredentials> verifyEmail({
required String email,
required String code,
required String apiKey,
}) async {
final uri = Uri.https(baseCakePayUri, verifyEmailPath);
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Api-Key $apiKey',
};
final query = <String, String>{'email': email, 'otp': code};
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(query),
);
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final bodyJson = json.decode(response.body) as Map<String, dynamic>;
if (bodyJson.containsKey('error')) {
throw Exception(bodyJson['error'] as String);
}
if (bodyJson.containsKey('token')) {
final token = bodyJson['token'] as String;
final userEmail = bodyJson['user']['email'] as String;
return CakePayUserCredentials(userEmail, token);
} else {
throw Exception('E-mail verification failed.');
}
}
/// createOrder
Future<CakePayOrder> createOrder({
required String apiKey,
required int cardId,
required String price,
required int quantity,
required String userEmail,
required String token,
required bool confirmsNoVpn,
required bool confirmsVoidedRefund,
required bool confirmsTermsAgreed,
}) async {
final uri = Uri.https(baseCakePayUri, orderPath);
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Token $token',
};
final body = json.encode({
'card_id': cardId,
'price': price,
'quantity': quantity,
'user_email': userEmail,
'send_email': true,
'confirms_no_vpn': confirmsNoVpn,
'confirms_voided_refund': confirmsVoidedRefund,
'confirms_terms_agreed': confirmsTermsAgreed,
});
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: body,
);
if (response.statusCode == 201) {
final data = json.decode(response.body) as Map<String, dynamic>;
return CakePayOrder.fromMap(data);
}
String message = 'Server error ${response.statusCode}';
final isJson = response.headers['content-type']?.contains('application/json') == true ||
response.body.trim().startsWith(RegExp(r'[\{\[]'));
if (isJson) {
try {
final decoded = json.decode(response.body);
if (decoded is List && decoded.isNotEmpty) {
message = decoded.first.toString();
} else if (decoded is Map && decoded['detail'] != null) {
message = decoded['detail'].toString();
} else {
message = decoded.toString();
}
} on FormatException {}
}
throw Exception(message);
}
/// Get Order by ID
Future<CakePayOrder> getOrderById({required String orderId, required String token}) async {
final uri = Uri.https(baseCakePayUri, '$orderPath/$orderId');
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Token $token',
};
final response = await ProxyWrapper().get(
clearnetUri: uri,
headers: headers,
);
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final bodyJson = json.decode(response.body) as Map<String, dynamic>;
return CakePayOrder.fromMap(bodyJson);
}
///Simulate Payment
Future<String> simulatePayment(
{required String CSRFToken,
required String authorization,
required String orderId,
required String token}) async {
final uri = Uri.https(baseCakePayUri, simulatePaymentPath);
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Token $token',
};
final body = json.encode({
'order_id': orderId,
});
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: body,
);
printV('Response: ${response.statusCode}');
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final bodyJson = json.decode(response.body) as Map<String, dynamic>;
return 'You just SIMULATED a buying of a gift card with ID: ${bodyJson['order_id']}';
}
/// Logout
Future<void> logoutUser({required String email, required String apiKey}) async {
final uri = Uri.https(baseCakePayUri, logoutPath);
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Api-Key $apiKey',
};
try {
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode({'email': email}),
);
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
} catch (e) {
printV('Caught exception: $e');
}
}
/// Get Vendors
Future<List<CakePayVendor>> getVendors({
required String apiKey,
required int page,
required String countryCode,
String? country,
String? search,
List<String>? vendorIds,
bool? giftCards = true,
bool? prepaidCards = true,
bool? onDemand,
bool? custom,
}) async {
var queryParams = {
'page': page.toString(),
'country_code': countryCode,
if (search != null && search.isNotEmpty) 'search': search,
if (vendorIds != null && vendorIds.isNotEmpty) 'vendor_ids': vendorIds.join(','),
};
if (giftCards == false || prepaidCards == false) {
queryParams['gift_cards'] = giftCards.toString();
queryParams['prepaid_cards'] = prepaidCards.toString();
}
final uri = Uri.https(baseCakePayUri, vendorsPath, queryParams);
var headers = {
'accept': 'application/json; charset=UTF-8',
'Content-Type': 'application/json; charset=UTF-8',
'Authorization': 'Api-Key $apiKey',
};
var response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
if (response.statusCode != 200) {
throw Exception(
'Failed to fetch vendors: statusCode - ${response.statusCode}, queryParams -$queryParams, response - ${response.body}');
}
final bodyJson = json.decode(response.body);
if (bodyJson is List<dynamic> && bodyJson.isEmpty) {
return [];
}
return (bodyJson['results'] as List)
.map((e) => CakePayVendor.fromJson(e as Map<String, dynamic>))
.toList();
}
}
@@ -1,123 +0,0 @@
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/cake_pay/src/services/cake_pay_api.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_vendor.dart';
import 'package:hash_wallet/core/secure_storage.dart';
class CakePayService {
CakePayService(this.secureStorage, this.cakePayApi);
/// `_v2` was added to log out users so they can login using the new backend
static const cakePayEmailStorageKey = 'cake_pay_email_v2';
static const cakePayUsernameStorageKey = 'cake_pay_username_v2';
static const cakePayUserTokenKey = 'cake_pay_user_token_v2';
static String get testCakePayApiKey => secrets.testCakePayApiKey;
static String get cakePayApiKey => secrets.cakePayApiKey;
static String get CSRFToken => secrets.CSRFToken;
static String get authorization => secrets.authorization;
final SecureStorage secureStorage;
final CakePayApi cakePayApi;
/// Get Vendors
Future<List<CakePayVendor>> getVendors({
required int page,
required String countryCode,
String? country,
String? search,
List<String>? vendorIds,
bool? giftCards,
bool? prepaidCards,
bool? onDemand,
bool? custom,
}) async {
final result = await cakePayApi.getVendors(
apiKey: cakePayApiKey,
page: page,
country: country,
countryCode: countryCode,
search: search,
vendorIds: vendorIds,
giftCards: giftCards,
prepaidCards: prepaidCards,
onDemand: onDemand,
custom: custom);
return result;
}
/// LogIn
Future<void> logIn(String email) async {
final userName = await cakePayApi.authenticateUser(email: email, apiKey: cakePayApiKey);
await secureStorage.write(key: cakePayEmailStorageKey, value: userName);
await secureStorage.write(key: cakePayUsernameStorageKey, value: userName);
}
/// Verify email
Future<void> verifyEmail(String code) async {
final email = (await secureStorage.read(key: cakePayEmailStorageKey))!;
final credentials =
await cakePayApi.verifyEmail(email: email, code: code, apiKey: cakePayApiKey);
await secureStorage.write(key: cakePayUserTokenKey, value: credentials.token);
await secureStorage.write(key: cakePayUsernameStorageKey, value: credentials.username);
}
Future<String?> getUserEmail() async {
return (await secureStorage.read(key: cakePayEmailStorageKey));
}
/// Check is user logged
Future<bool> isLogged() async {
final username = await secureStorage.read(key: cakePayUsernameStorageKey) ?? '';
final password = await secureStorage.read(key: cakePayUserTokenKey) ?? '';
return username.isNotEmpty && password.isNotEmpty;
}
/// Logout
Future<void> logout([String? email]) async {
await secureStorage.delete(key: cakePayUsernameStorageKey);
await secureStorage.delete(key: cakePayUserTokenKey);
if (email != null) {
await cakePayApi.logoutUser(email: email, apiKey: cakePayApiKey);
}
}
/// Purchase Gift Card
Future<CakePayOrder> createOrder({
required int cardId,
required String price,
required int quantity,
required bool confirmsNoVpn,
required bool confirmsVoidedRefund,
required bool confirmsTermsAgreed,
}) async {
final userEmail = (await secureStorage.read(key: cakePayEmailStorageKey))!;
final token = (await secureStorage.read(key: cakePayUserTokenKey))!;
return await cakePayApi.createOrder(
apiKey: cakePayApiKey,
cardId: cardId,
price: price,
quantity: quantity,
token: token,
userEmail: userEmail,
confirmsNoVpn: confirmsNoVpn,
confirmsVoidedRefund: confirmsVoidedRefund,
confirmsTermsAgreed: confirmsTermsAgreed,
);
}
Future<CakePayOrder> findOrderById({required String orderId}) async {
final token = (await secureStorage.read(key: cakePayUserTokenKey))!;
return await cakePayApi.getOrderById(orderId: orderId, token: token);
}
///Simulate Purchase Gift Card
Future<String> simulatePayment({required String orderId}) async {
final token = (await secureStorage.read(key: cakePayUserTokenKey))!;
return await cakePayApi.simulatePayment(
CSRFToken: CSRFToken, authorization: authorization, token: token, orderId: orderId);
}
}
@@ -1,73 +0,0 @@
import 'package:hash_wallet/src/widgets/alert_background.dart';
import 'package:hash_wallet/src/widgets/primary_button.dart';
import 'package:flutter/material.dart';
class CakePayAlertModal extends StatelessWidget {
const CakePayAlertModal({
super.key,
required this.title,
required this.content,
required this.actionTitle,
this.showCloseButton = true,
this.dismissible = false,
});
final String title;
final Widget content;
final String actionTitle;
final bool showCloseButton;
final bool dismissible;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final maxHeight = MediaQuery.of(context).size.height * 0.8;
return AlertBackground(
dismissible: dismissible,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: Material(
color: Colors.transparent,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(30)),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (title.isNotEmpty) ...[
Text(title,
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface, fontWeight: FontWeight.bold),
textAlign: TextAlign.center),
const SizedBox(height: 12),
],
Flexible(child: SingleChildScrollView(child: content)),
const SizedBox(height: 24),
PrimaryButton(
onPressed: () => Navigator.pop(context),
text: actionTitle,
color: theme.colorScheme.surfaceContainer,
textColor: theme.colorScheme.primary),
if (showCloseButton) ...[
const SizedBox(height: 16),
InkWell(
onTap: () => Navigator.pop(context),
child: CircleAvatar(
backgroundColor: theme.colorScheme.surfaceContainer,
child: Icon(Icons.close, color: theme.colorScheme.onSurface),
),
),
],
],
),
),
),
),
),
);
}
}
@@ -1,126 +0,0 @@
import 'package:hash_wallet/entities/country.dart';
import 'package:hash_wallet/src/widgets/search_bar_widget.dart';
import 'package:hash_wallet/utils/debounce.dart';
import 'package:flutter/material.dart';
class CakePaySearchBar extends StatefulWidget {
const CakePaySearchBar(
{required this.initialQuery,
required this.onSearch,
required this.onFilter,
this.onCountryPick,
this.controller,
this.selectedCountry});
final String initialQuery;
final ValueChanged<String> onSearch;
final VoidCallback onFilter;
final VoidCallback? onCountryPick;
final Country? selectedCountry;
final TextEditingController? controller;
@override
State<CakePaySearchBar> createState() => _CakePaySearchBarState();
}
class _CakePaySearchBarState extends State<CakePaySearchBar> {
late final TextEditingController _searchController =
widget.controller ?? TextEditingController(text: widget.initialQuery);
final _searchFocusNode = FocusNode();
final _debounce = Debounce(const Duration(milliseconds: 500));
@override
void initState() {
super.initState();
_searchController
.addListener(() => _debounce.run(() => widget.onSearch(_searchController.text)));
}
@override
void dispose() {
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 32,
child: Row(
children: [
Expanded(child: SearchBarWidget(searchController: _searchController)),
const SizedBox(width: 5),
_CakePayFilterButton(onFilter: widget.onFilter),
if (widget.selectedCountry != null)
_CountryPickerWidget(
onTap: widget.onCountryPick,
selectedCountry: widget.selectedCountry!,
),
],
),
);
}
}
class _CakePayFilterButton extends StatelessWidget {
const _CakePayFilterButton({required this.onFilter});
final VoidCallback onFilter;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onFilter,
child: Container(
width: 32,
padding: const EdgeInsets.symmetric(vertical: 7),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(10)),
child: Image.asset('assets/images/filter_icon.png',
color: Theme.of(context).colorScheme.onSurface)),
);
}
}
class _CountryPickerWidget extends StatelessWidget {
const _CountryPickerWidget({required this.selectedCountry, this.onTap});
final Country selectedCountry;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 5),
child: GestureDetector(
onTap: onTap,
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(10)),
child: Row(
children: [
Image.asset(selectedCountry.iconPath,
width: 24,
height: 24,
errorBuilder: (_, __, ___) => const SizedBox(width: 24, height: 24)),
const SizedBox(width: 6),
Text(
selectedCountry.countryCode,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
),
);
}
}
@@ -1,47 +0,0 @@
import 'package:flutter/material.dart';
class CakePayTile extends StatelessWidget {
const CakePayTile({
Key? key,
required this.title,
required this.subTitle,
this.onTap,
}) : super(key: key);
final VoidCallback? onTap;
final String title;
final String subTitle;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
SizedBox(height: 8),
Text(
subTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
],
)
],
),
);
}
}
-82
View File
@@ -1,82 +0,0 @@
import 'package:flutter/material.dart';
import 'image_placeholder.dart';
class CardItem extends StatelessWidget {
CardItem({
required this.title,
required this.subTitle,
this.isAmount = false,
this.onTap,
this.logoUrl,
});
final VoidCallback? onTap;
final String title;
final String subTitle;
final String? logoUrl;
final bool isAmount;
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
if (logoUrl != null)
AspectRatio(
aspectRatio: 1.8,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(10)),
child: Image.network(
logoUrl!,
fit: BoxFit.cover,
loadingBuilder:
(BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(child: CircularProgressIndicator());
},
errorBuilder: (context, error, stackTrace) => CakePayCardImagePlaceholder(),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(fontSize: 18, fontWeight: FontWeight.w700)),
Text(subTitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(fontSize: 10, fontWeight: FontWeight.w700)),
],
),
),
),
],
),
),
),
);
}
}
@@ -1,172 +0,0 @@
import 'package:hash_wallet/cake_pay/src/models/cake_pay_card.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/number_text_fild_widget.dart';
import 'package:hash_wallet/typography.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/dropdown_filter_item_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
class DenominationsAmountWidget extends StatefulWidget {
const DenominationsAmountWidget({
required this.fiatCurrency,
required this.denominations,
required this.amountFieldFocus,
required this.amountController,
required this.quantityFieldFocus,
required this.quantityController,
required this.cakePayBuyCardViewModel,
required this.onAmountChanged,
required this.onQuantityChanged,
});
final String fiatCurrency;
final List<Denomination> denominations;
final FocusNode amountFieldFocus;
final TextEditingController amountController;
final FocusNode quantityFieldFocus;
final TextEditingController quantityController;
final CakePayBuyCardViewModel cakePayBuyCardViewModel;
final Function(String) onAmountChanged;
final Function(int?) onQuantityChanged;
@override
State<DenominationsAmountWidget> createState() => _DenominationsAmountWidgetState();
}
class _DenominationsAmountWidgetState extends State<DenominationsAmountWidget> {
late (String, int?) _selected;
@override
void initState() {
super.initState();
final first = widget.denominations.first;
final amount = widget.amountController.text.isNotEmpty
? widget.amountController.text
: first.value.toString();
_selected = (amount, first.cardId);
widget.cakePayBuyCardViewModel.selectedDenomination = _selected;
widget.amountController.text = _selected.$1;
widget.onAmountChanged(_selected.$1);
}
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height * 0.1,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: 8,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownFilterList(
items: widget.denominations
.map((e) => e.value.toString())
.toList(),
itemPrefix: widget.fiatCurrency,
selectedItem: _selected.$1,
onItemSelected: (value) {
setState(() => _selected = (value, widget.denominations
.firstWhere((e) => e.value.toString() == value)
.cardId));
widget.amountController.text = value;
widget.onAmountChanged(value);
widget.cakePayBuyCardViewModel.selectedDenomination = (_selected.$1, _selected.$2);
},
),
const SizedBox(height: 4),
Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
width: 1.0,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
child: Text(
S.of(context).value,
maxLines: 2,
style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
],
),
),
Spacer(),
Expanded(
flex: 5,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
NumberTextField(
controller: widget.quantityController,
focusNode: widget.quantityFieldFocus,
min: 1,
max: 99,
onChanged: (value) => widget.onQuantityChanged(value),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
width: 1.0,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
child: Text(
S.of(context).quantity,
maxLines: 1,
style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
],
),
),
Spacer(),
Expanded(
flex: 8,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Observer(
builder: (_) => Text(
'${widget.fiatCurrency} ${widget.cakePayBuyCardViewModel.totalAmount}',
maxLines: 1,
style: Theme.of(context).textTheme.titleMedium!,
),
),
const SizedBox(height: 4),
Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
width: 1.0,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
child: Text(
S.of(context).total,
maxLines: 1,
style: textSmall(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
],
),
),
],
),
);
}
}
@@ -1,103 +0,0 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/base_text_form_field.dart';
import 'package:hash_wallet/typography.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class EnterAmountWidget extends StatelessWidget {
const EnterAmountWidget(
{required this.minValue,
required this.maxValue,
required this.fiatCurrency,
required this.amountFieldFocus,
required this.amountController,
required this.onAmountChanged});
final String minValue;
final String maxValue;
final String fiatCurrency;
final FocusNode amountFieldFocus;
final TextEditingController amountController;
final Function(String) onAmountChanged;
@override
Widget build(BuildContext context) {
return Container(
height: MediaQuery.of(context).size.height * 0.1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Spacer(flex: 1),
Text(
S.of(context).enter_amount,
style: Theme.of(context).textTheme.titleLarge!,
),
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
width: 1.0, color: Theme.of(context).colorScheme.onSurfaceVariant))),
child: BaseTextFormField(
isDense: true,
contentPadding: EdgeInsets.zero,
controller: amountController,
keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true),
hintText: '0.00',
maxLines: 1,
prefixIcon: Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
'$fiatCurrency: ',
style: Theme.of(context).textTheme.titleMedium!,
),
),
prefixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0),
suffixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0),
suffixIcon: Padding(
padding: const EdgeInsets.only(top: 2, right: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('QTY ', style: Theme.of(context).textTheme.titleMedium!),
Text(
'1',
style: Theme.of(context).textTheme
.titleMedium!
.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant)
),
],
),
),
textStyle: Theme.of(context).textTheme.titleMedium!,
placeholderTextStyle: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp('[\-|\ ]')),
FilteringTextInputFormatter.allow(
RegExp(r'^\d+(\.|\,)?\d{0,2}'),
),
],
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S.of(context).min_amount(minValue) + ' $fiatCurrency',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
)),
Text(S.of(context).max_amount(maxValue) + ' $fiatCurrency',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
)),
],
),
Spacer(flex: 1),
],
),
);
}
}
@@ -1,62 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
class FlipCard extends StatefulWidget {
const FlipCard({
super.key,
required this.front,
required this.back,
this.duration = const Duration(milliseconds: 400),
this.flipOnTouch = true,
});
final Widget front;
final Widget back;
final Duration duration;
final bool flipOnTouch;
@override
FlipCardState createState() => FlipCardState();
}
class FlipCardState extends State<FlipCard> with SingleTickerProviderStateMixin {
late final AnimationController _ctrl =
AnimationController(vsync: this, duration: widget.duration);
bool _isFront = true;
void toggleCard() {
if (_isFront) {
_ctrl.forward();
} else {
_ctrl.reverse();
}
_isFront = !_isFront;
}
@override
Widget build(BuildContext context) {
final content = AnimatedBuilder(
animation: _ctrl,
builder: (_, __) {
final angle = _ctrl.value * math.pi;
final isFront = angle < math.pi / 2;
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(angle),
child: isFront ? widget.front
: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(math.pi),
child: widget.back,
),
);
},
);
return widget.flipOnTouch
? GestureDetector(onTap: toggleCard, child: content)
: content;
}
}
@@ -1,28 +0,0 @@
import 'package:flutter/material.dart';
class CakePayCardImagePlaceholder extends StatelessWidget {
const CakePayCardImagePlaceholder({this.text});
final String? text;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 1.8,
child: Container(
child: Center(
child: Text(
text ?? 'Image not found!',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w900,
),
),
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
),
),
);
}
}
@@ -1,65 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
class ClickableLinksText extends StatelessWidget {
const ClickableLinksText({
required this.text,
required this.textStyle,
this.linkStyle,
});
final String text;
final TextStyle textStyle;
final TextStyle? linkStyle;
@override
Widget build(BuildContext context) {
List<InlineSpan> spans = [];
RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
Iterable<Match> matches = linkRegExp.allMatches(text);
int previousEnd = 0;
matches.forEach((match) {
if (match.start > previousEnd) {
spans.add(TextSpan(text: text.substring(previousEnd, match.start), style: textStyle));
}
String url = text.substring(match.start, match.end);
if (url.toLowerCase().endsWith('.md')) {
spans.add(
TextSpan(
text: url,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontSize: 18,
),
recognizer: TapGestureRecognizer()
..onTap = () async {
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
},
),
);
} else {
spans.add(
TextSpan(
text: url,
style: linkStyle,
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrl(Uri.parse(url));
},
),
);
}
previousEnd = match.end;
});
if (previousEnd < text.length) {
spans.add(TextSpan(text: text.substring(previousEnd), style: textStyle));
}
return RichText(text: TextSpan(children: spans));
}
}
@@ -1,47 +0,0 @@
import 'package:flutter/material.dart';
class RoundedOverlayCards extends StatelessWidget {
const RoundedOverlayCards({
this.topCardChild = const SizedBox(),
this.bottomCardChild = const SizedBox(),
});
final Widget topCardChild;
final Widget bottomCardChild;
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
return ClipRRect(
borderRadius:
BorderRadius.only(bottomLeft: Radius.circular(25.0), bottomRight: Radius.circular(25.0)),
child: Container(
height: screenHeight * 0.50,
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(24),
bottomRight: Radius.circular(24),
),
color: Theme.of(context).colorScheme.surfaceContainer
),
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(25.0), bottomRight: Radius.circular(25.0)),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24)),
color: Theme.of(context).colorScheme.surfaceContainerLow,
),
height: screenHeight * 0.38,
width: double.infinity,
child: topCardChild)),
bottomCardChild,
],
),
),
);
}
}
@@ -1,36 +0,0 @@
import 'package:flutter/material.dart';
class TextIconButton extends StatelessWidget {
const TextIconButton({
Key? key,
required this.label,
this.onTap,
}) : super(key: key);
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge!.color,
fontSize: 16,
fontWeight: FontWeight.w900,
),
),
Icon(
Icons.chevron_right_rounded,
color: Theme.of(context).textTheme.titleLarge!.color,
),
],
),
);
}
}
@@ -1,203 +0,0 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/base_alert_dialog.dart';
import 'package:hash_wallet/src/widgets/standard_checkbox.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
class ThreeCheckboxAlert extends BaseAlertDialog {
ThreeCheckboxAlert({
required this.alertTitle,
required this.leftButtonText,
required this.rightButtonText,
required this.actionLeftButton,
required this.actionRightButton,
this.alertBarrierDismissible = true,
Key? key,
});
final String alertTitle;
final String leftButtonText;
final String rightButtonText;
final VoidCallback actionLeftButton;
final Function(bool, bool, bool) actionRightButton;
final bool alertBarrierDismissible;
bool checkbox1 = false;
void toggleCheckbox1() => checkbox1 = !checkbox1;
bool checkbox2 = false;
void toggleCheckbox2() => checkbox2 = !checkbox2;
bool checkbox3 = false;
void toggleCheckbox3() => checkbox3 = !checkbox3;
bool showValidationMessage = true;
@override
String get titleText => alertTitle;
@override
bool get isDividerExists => true;
@override
String get leftActionButtonText => leftButtonText;
@override
String get rightActionButtonText => rightButtonText;
@override
VoidCallback get actionLeft => actionLeftButton;
@override
VoidCallback get actionRight => () {
actionRightButton(checkbox1, checkbox2, checkbox3);
};
@override
bool get barrierDismissible => alertBarrierDismissible;
@override
Widget content(BuildContext context) {
return ThreeCheckboxAlertContent(
checkbox1: checkbox1,
toggleCheckbox1: toggleCheckbox1,
checkbox2: checkbox2,
toggleCheckbox2: toggleCheckbox2,
checkbox3: checkbox3,
toggleCheckbox3: toggleCheckbox3,
);
}
}
class ThreeCheckboxAlertContent extends StatefulWidget {
ThreeCheckboxAlertContent({
required this.checkbox1,
required this.toggleCheckbox1,
required this.checkbox2,
required this.toggleCheckbox2,
required this.checkbox3,
required this.toggleCheckbox3,
Key? key,
}) : super(key: key);
bool checkbox1;
void Function() toggleCheckbox1;
bool checkbox2;
void Function() toggleCheckbox2;
bool checkbox3;
void Function() toggleCheckbox3;
@override
_ThreeCheckboxAlertContentState createState() => _ThreeCheckboxAlertContentState(
checkbox1: checkbox1,
toggleCheckbox1: toggleCheckbox1,
checkbox2: checkbox2,
toggleCheckbox2: toggleCheckbox2,
checkbox3: checkbox3,
toggleCheckbox3: toggleCheckbox3,
);
static _ThreeCheckboxAlertContentState? of(BuildContext context) {
return context.findAncestorStateOfType<_ThreeCheckboxAlertContentState>();
}
}
class _ThreeCheckboxAlertContentState extends State<ThreeCheckboxAlertContent> {
_ThreeCheckboxAlertContentState({
required this.checkbox1,
required this.toggleCheckbox1,
required this.checkbox2,
required this.toggleCheckbox2,
required this.checkbox3,
required this.toggleCheckbox3,
});
bool checkbox1;
void Function() toggleCheckbox1;
bool checkbox2;
void Function() toggleCheckbox2;
bool checkbox3;
void Function() toggleCheckbox3;
bool showValidationMessage = true;
bool get areAllCheckboxesChecked => checkbox1 && checkbox2 && checkbox3;
@override
Widget build(BuildContext context) {
return Form(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
StandardCheckbox(
value: checkbox1,
caption: S.of(context).cakepay_confirm_no_vpn,
onChanged: (bool? value) {
setState(() {
checkbox1 = value ?? false;
toggleCheckbox1();
showValidationMessage = !areAllCheckboxesChecked;
});
},
),
StandardCheckbox(
value: checkbox2,
caption: S.of(context).cakepay_confirm_voided_refund,
onChanged: (bool? value) {
setState(() {
checkbox2 = value ?? false;
toggleCheckbox2();
showValidationMessage = !areAllCheckboxesChecked;
});
},
),
StandardCheckbox(
value: checkbox3,
caption: S.of(context).cakepay_confirm_terms_agreed,
onChanged: (bool? value) {
setState(() {
checkbox3 = value ?? false;
toggleCheckbox3();
showValidationMessage = !areAllCheckboxesChecked;
});
},
),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => launchUrl(
Uri.parse("https://cakepay.com/cakepay-web-terms.txt"),
mode: LaunchMode.externalApplication,
),
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
S.of(context).settings_terms_and_conditions,
style: TextStyle(
fontSize: 16,
fontFamily: 'Lato',
fontWeight: FontWeight.w400,
color: Colors.blueAccent,
decoration: TextDecoration.none,
height: 1,
),
softWrap: true,
),
),
),
if (showValidationMessage)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Please confirm all checkboxes',
style: TextStyle(
color: Colors.red,
fontSize: 14,
fontFamily: 'Lato',
fontWeight: FontWeight.w400,
decoration: TextDecoration.none,
),
),
),
],
),
);
}
}
@@ -1,83 +0,0 @@
import 'package:flutter/material.dart';
import 'image_placeholder.dart';
class UserCardItem extends StatelessWidget {
UserCardItem({
required this.title,
required this.subTitle,
this.onTap,
this.logoUrl
});
final VoidCallback? onTap;
final String title;
final String subTitle;
final String? logoUrl;
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: InkWell(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(15),
),
child: Column(
children: [
if (logoUrl != null)
Padding(
padding: const EdgeInsets.all(2.0),
child: AspectRatio(
aspectRatio: 1.65,
child: ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(13)),
child: Image.network(
logoUrl!,
fit: BoxFit.cover,
loadingBuilder:
(BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(child: CircularProgressIndicator());
},
errorBuilder: (context, error, stackTrace) => CakePayCardImagePlaceholder(),
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
children: [
Expanded(
child: Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(fontSize: 16, fontWeight: FontWeight.w700)),
),
SizedBox(width: 8),
Text(subTitle,
maxLines: 1,
overflow: TextOverflow.clip,
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(fontSize: 16, fontWeight: FontWeight.w700)),
],
),
),
],
),
),
),
);
}
}
+4 -33
View File
@@ -1,23 +1,12 @@
import 'dart:async';
import 'package:hash_wallet/exchange/provider/near_Intents_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/simpleswap_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:hash_wallet/store/dashboard/trades_store.dart';
import 'package:hash_wallet/entities/exchange_api_mode.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/provider/chainflip_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/changenow_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/exolix_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/letsexchange_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/swaptrade_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/sideshift_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/stealth_ex_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:hash_wallet/exchange/provider/trocador_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/xoswap_exchange_provider.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:hash_wallet/store/app_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -38,31 +27,13 @@ class TradeMonitor {
final SharedPreferences preferences;
ExchangeProvider? _getProviderByDescription(ExchangeProviderDescription description) {
// Providers whose implementations were removed (ChangeNow, SideShift,
// SimpleSwap, Exolix, ThorChain, SwapTrade, LetsExchange, StealthEx,
// Chainflip, XOSwap, SwapsXyz) fall through to null; their trades are
// skipped as "provider not supported".
switch (description) {
case ExchangeProviderDescription.changeNow:
return ChangeNowExchangeProvider(settingsStore: appStore.settingsStore);
case ExchangeProviderDescription.sideShift:
return SideShiftExchangeProvider();
case ExchangeProviderDescription.simpleSwap:
return SimpleSwapExchangeProvider();
case ExchangeProviderDescription.trocador:
return TrocadorExchangeProvider();
case ExchangeProviderDescription.exolix:
return ExolixExchangeProvider();
case ExchangeProviderDescription.thorChain:
return ThorChainExchangeProvider();
case ExchangeProviderDescription.swapTrade:
return SwapTradeExchangeProvider();
case ExchangeProviderDescription.letsExchange:
return LetsExchangeExchangeProvider();
case ExchangeProviderDescription.stealthEx:
return StealthExExchangeProvider();
case ExchangeProviderDescription.chainflip:
return ChainflipExchangeProvider();
case ExchangeProviderDescription.xoSwap:
return XOSwapExchangeProvider();
case ExchangeProviderDescription.swapsXyz:
return SwapsXyzExchangeProvider();
case ExchangeProviderDescription.nearIntents:
return NearIntentsExchangeProvider();
}
+47 -94
View File
@@ -8,14 +8,7 @@ import 'package:hash_wallet/bitcoin/bitcoin.dart';
import 'package:hash_wallet/bitcoin_cash/bitcoin_cash.dart';
import 'package:hash_wallet/entities/bitcoin_amount_display_mode.dart';
import 'package:hash_wallet/evm/evm.dart';
import 'package:hash_wallet/buy/dfx/dfx_buy_provider.dart';
import 'package:hash_wallet/buy/moonpay/moonpay_provider.dart';
import 'package:hash_wallet/buy/onramper/onramper_buy_provider.dart';
import 'package:hash_wallet/buy/robinhood/robinhood_buy_provider.dart';
import 'package:hash_wallet/cake_pay/cake_pay.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_vendor.dart';
import 'package:hash_wallet/cake_pay/src/services/cake_pay_api.dart';
import 'package:hash_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:hash_wallet/core/auth_service.dart';
import 'package:hash_wallet/core/backup_service_v3.dart';
import 'package:hash_wallet/core/key_service.dart';
@@ -209,14 +202,9 @@ import 'package:hash_wallet/view_model/bridge/bridge_history_view_model.dart';
import 'package:hash_wallet/view_model/buy/buy_amount_view_model.dart';
import 'package:hash_wallet/view_model/buy/buy_sell_view_model.dart';
import 'package:hash_wallet/view_model/buy/buy_view_model.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_account_view_model.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_auth_view_model.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_cards_list_view_model.dart';
import 'package:hash_wallet/view_model/contact_list/contact_list_view_model.dart';
import 'package:hash_wallet/view_model/contact_list/contact_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/balance_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/cake_features_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/desktop_sidebar_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/home_settings_view_model.dart';
@@ -309,8 +297,6 @@ import 'package:mobx/mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:trezor_connect/trezor_connect.dart';
import 'buy/kryptonim/kryptonim.dart';
import 'buy/meld/meld_buy_provider.dart';
import 'dogecoin/dogecoin.dart';
import 'new-ui/viewmodels/card_customizer/card_customizer_bloc.dart';
import 'new-ui/widgets/addresses_page/address_info.dart';
@@ -376,6 +362,34 @@ Future<void> setup({
return;
}
_registerCoreSingletons(settingsStore);
final walletList = await WalletInfo.getAll();
_registerCoreViewModels(settingsStore);
_registerAuthAndSecurity(walletList, navigatorKey);
final appStore = getIt.get<AppStore>();
_registerDashboardAndSendPages(appStore);
_registerWalletListAndAccounts();
_registerSettingsAndNodes(walletList);
_registerExchangeAndSwap();
_registerRestoreAndBackup();
_registerBuyAndSupport();
_registerDevToolsAndBridge(appStore);
_isSetupFinished = true;
}
void _registerCoreSingletons(SettingsStore settingsStore) {
getIt.registerFactory<Box<Node>>(() => _nodeSource);
getIt.registerFactory<Box<Node>>(() => _powNodeSource, instanceName: Node.boxName + "pow");
@@ -457,10 +471,9 @@ Future<void> setup({
getIt.get<SeedSettingsViewModel>(),
newWalletArguments: newWalletArgs,
));
}
final walletList = await WalletInfo.getAll();
void _registerCoreViewModels(SettingsStore settingsStore) {
getIt.registerFactory<WalletManager>(
() => WalletManager(
getIt.get<SharedPreferences>(),
@@ -605,7 +618,9 @@ Future<void> setup({
getIt.registerFactory<LightningUsernameBloc>(
() => LightningUsernameBloc(getIt.get<AppStore>().wallet!));
}
void _registerAuthAndSecurity(List<WalletInfo> walletList, GlobalKey<NavigatorState> navigatorKey) {
getIt.registerFactory<AuthService>(
() => AuthService(
secureStorage: getIt.get<SecureStorage>(),
@@ -751,9 +766,9 @@ Future<void> setup({
});
getIt.registerSingleton<BottomSheetService>(BottomSheetServiceImpl());
}
final appStore = getIt.get<AppStore>();
void _registerDashboardAndSendPages(AppStore appStore) {
getIt.registerLazySingleton<WalletConnectKeyService>(() => KeyServiceImpl());
getIt.registerLazySingleton<WalletKitService>(() {
@@ -934,7 +949,9 @@ Future<void> setup({
getIt.registerFactory(
() => SendTemplatePage(sendTemplateViewModel: getIt.get<SendTemplateViewModel>()));
}
void _registerWalletListAndAccounts() {
if (DeviceInfo.instance.isMobile) {
getIt.registerFactory(
() => WalletListViewModel(
@@ -1050,7 +1067,9 @@ Future<void> setup({
(NanoAccount? account, _) => NanoAccountEditOrCreatePage(
nanoAccountCreationViewModel:
getIt.get<NanoAccountEditOrCreateViewModel>(param1: account)));
}
void _registerSettingsAndNodes(List<WalletInfo> walletList) {
getIt.registerFactory(() =>
DisplaySettingsViewModel(getIt.get<AppStore>(), getIt.get<ThemeStore>()));
@@ -1198,39 +1217,15 @@ Future<void> setup({
nodeCreateOrEditViewModel: getIt.get<NodeCreateOrEditViewModel>(param1: {'isPow' : true, 'editingNode': editingNode}),
editingNode: editingNode,
isSelected: isSelected));
}
getIt.registerFactory<RobinhoodBuyProvider>(() => RobinhoodBuyProvider(
wallet: getIt.get<AppStore>().wallet!,
hardwareWalletVM: getIt<AppStore>().wallet!.isHardwareWallet
? getIt<HardwareWalletViewModel>(param1: getIt<AppStore>().wallet!.hardwareWalletType!)
: null,
));
getIt.registerFactory<DFXBuyProvider>(() => DFXBuyProvider(
wallet: getIt<AppStore>().wallet!,
hardwareWalletVM: getIt<AppStore>().wallet!.isHardwareWallet
? getIt<HardwareWalletViewModel>(param1: getIt<AppStore>().wallet!.hardwareWalletType!)
: null,
));
void _registerExchangeAndSwap() {
getIt.registerFactory<MoonPayProvider>(() => MoonPayProvider(
appStore: getIt.get<AppStore>(),
wallet: getIt.get<AppStore>().wallet!,
isTestEnvironment: kDebugMode || kProfileMode,
));
getIt.registerFactory<OnRamperBuyProvider>(() => OnRamperBuyProvider(
wallet: getIt.get<AppStore>().wallet!,
));
getIt.registerFactory<MeldBuyProvider>(() => MeldBuyProvider(
wallet: getIt.get<AppStore>().wallet!,
));
getIt.registerFactory<KryptonimBuyProvider>(() => KryptonimBuyProvider(
wallet: getIt.get<AppStore>().wallet!,
));
getIt.registerFactoryParam<WebViewPage, String, Uri>((title, uri) => WebViewPage(title, uri));
getIt.registerFactory<FeesViewModel>(
@@ -1335,7 +1330,9 @@ Future<void> setup({
throw Exception('Unexpected token: ${param1.toString()} for generating of WalletService');
}
});
}
void _registerRestoreAndBackup() {
getIt.registerFactoryParam<SetupPinCodeViewModel, bool?, void>(
(isDuressPin, _) => SetupPinCodeViewModel(
getIt.get<AuthService>(),
@@ -1446,12 +1443,8 @@ Future<void> setup({
getIt.registerFactoryParam<TradeDetailsViewModel, Trade, void>(
(trade, _) => TradeDetailsViewModel(tradeForDetails: trade, appStore: getIt.get<AppStore>()));
getIt.registerFactory(() => CakeFeaturesViewModel(getIt.get<CakePayService>()));
getIt.registerFactory(() => CakeFeaturesPage(
dashboardViewModel: getIt.get<DashboardViewModel>(),
cakeFeaturesViewModel: getIt.get<CakeFeaturesViewModel>()));
dashboardViewModel: getIt.get<DashboardViewModel>()));
getIt.registerFactory(() => BackupServiceV3(getIt.get<SecureStorage>(),
_transactionDescriptionBox,
@@ -1478,7 +1471,9 @@ Future<void> setup({
getIt.registerFactoryParam<TradeDetailsPage, Trade, void>(
(Trade trade, _) => TradeDetailsPage(getIt.get<TradeDetailsViewModel>(param1: trade)));
}
void _registerBuyAndSupport() {
getIt.registerFactory(() => BuyAmountViewModel());
getIt.registerFactory(() => BuySellViewModel(getIt.get<AppStore>()));
@@ -1519,12 +1514,10 @@ Future<void> setup({
getIt.registerFactoryParam<OrderDetailsViewModel, Order, void>((order, _) {
final wallet = getIt.get<AppStore>().wallet;
final cakePayService = getIt.get<CakePayService>();
return OrderDetailsViewModel(
wallet: wallet!,
orderForDetails: order,
cakePayService: cakePayService,
orders: _ordersSource);
});
@@ -1578,7 +1571,9 @@ Future<void> setup({
unspentCoinsDetailsViewModel: getIt.get<UnspentCoinsDetailsViewModel>(
param1: item, param2: unspentCoinsListViewModel));
});
}
void _registerDevToolsAndBridge(AppStore appStore) {
getIt.registerFactory(() => AddressResolver(
wallet: getIt.get<AppStore>().wallet!,
settingsStore: getIt.get<SettingsStore>()));
@@ -1586,48 +1581,8 @@ Future<void> setup({
getIt.registerFactoryParam<FullscreenQRPage, QrViewData, void>(
(QrViewData viewData, _) => FullscreenQRPage(qrViewData: viewData));
getIt.registerFactory(() => CakePayApi());
getIt.registerFactory(() => AnyPayApi());
getIt.registerFactory<CakePayService>(
() => CakePayService(getIt.get<SecureStorage>(), getIt.get<CakePayApi>()));
getIt.registerFactory(
() => CakePayCardsListViewModel(cakePayService: getIt.get<CakePayService>(),
settingsStore: getIt.get<SettingsStore>()));
getIt.registerFactory(() => CakePayAuthViewModel(cakePayService: getIt.get<CakePayService>()));
getIt.registerFactoryParam<CakePayBuyCardViewModel, CakePayVendor, void>(
(CakePayVendor vendor, _) {
return CakePayBuyCardViewModel(vendor: vendor,
cakePayService: getIt.get<CakePayService>(),
sendViewModel: getIt.get<SendViewModel>(),
orders: _ordersSource);
});
getIt.registerFactory(() => CakePayAccountViewModel(cakePayService: getIt.get<CakePayService>()));
getIt.registerFactory(() => CakePayWelcomePage(getIt.get<CakePayAuthViewModel>()));
getIt.registerFactoryParam<CakePayVerifyOtpPage, List<dynamic>, void>((List<dynamic> args, _) {
final email = args.first as String;
final isSignIn = args[1] as bool;
return CakePayVerifyOtpPage(getIt.get<CakePayAuthViewModel>(), email, isSignIn);
});
getIt.registerFactoryParam<CakePayBuyCardPage, List<dynamic>, void>((List<dynamic> args, _) {
final vendor = args.first as CakePayVendor;
return CakePayBuyCardPage(getIt.get<CakePayBuyCardViewModel>(param1: vendor));
});
getIt.registerFactory(() => CakePayCardsPage(getIt.get<CakePayCardsListViewModel>()));
getIt.registerFactory(() => CakePayAccountPage(getIt.get<CakePayAccountViewModel>()));
getIt.registerFactory(() => AnonPayApi(
useTorOnly: getIt.get<SettingsStore>().exchangeStatus == ExchangeApiMode.torOnly,
wallet: getIt.get<AppStore>().wallet!));
@@ -1776,6 +1731,4 @@ Future<void> setup({
initialToken: initialToken,
),
);
_isSetupFinished = true;
}
+5 -16
View File
@@ -1,10 +1,5 @@
import 'package:hash_wallet/buy/buy_provider.dart';
import 'package:hash_wallet/buy/dfx/dfx_buy_provider.dart';
import 'package:hash_wallet/buy/kryptonim/kryptonim.dart';
import 'package:hash_wallet/buy/meld/meld_buy_provider.dart';
import 'package:hash_wallet/buy/moonpay/moonpay_provider.dart';
import 'package:hash_wallet/buy/onramper/onramper_buy_provider.dart';
import 'package:hash_wallet/buy/robinhood/robinhood_buy_provider.dart';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
@@ -61,18 +56,12 @@ class ProvidersHelper {
static BuyProvider getProviderByType(ProviderType type) {
switch (type) {
case ProviderType.robinhood:
return getIt.get<RobinhoodBuyProvider>();
case ProviderType.dfx:
return getIt.get<DFXBuyProvider>();
case ProviderType.onramper:
return getIt.get<OnRamperBuyProvider>();
case ProviderType.moonpay:
return getIt.get<MoonPayProvider>();
case ProviderType.meld:
return getIt.get<MeldBuyProvider>();
case ProviderType.kriptonim:
return getIt.get<KryptonimBuyProvider>();
}
// The remaining provider implementations (Robinhood/DFX/Onramper/Meld/
// Kryptonim) were removed; their enum values stay for future re-enable.
default:
throw UnsupportedError('Buy provider ${type.title} is not available in this build');
}
}
}
@@ -1,431 +0,0 @@
import 'dart:convert';
import 'dart:math';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:hash_wallet/utils/exchange_provider_logger.dart';
class ChainflipExchangeProvider extends ExchangeProvider {
ChainflipExchangeProvider();
static final List<CryptoCurrency> _supported = [
CryptoCurrency.btc,
CryptoCurrency.eth,
CryptoCurrency.usdc,
CryptoCurrency.usdterc20,
CryptoCurrency.flip,
CryptoCurrency.wbtc,
CryptoCurrency.sol,
CryptoCurrency.usdcsol,
CryptoCurrency.usdtSol,
CryptoCurrency.arbEth,
CryptoCurrency.usdcArb,
CryptoCurrency.usdtArb,
];
static const _baseURL = 'chainflip-broker.io';
static const _assetsPath = '/assets';
static const _quotePath = '/quotes-native';
static const _swapPath = '/swap';
static const _txInfoPath = '/status-by-deposit-channel';
static const _affiliateBps = secrets.chainflipAffiliateFee;
static const _affiliateKey = secrets.chainflipApiKey;
@override
String get title => 'Chainflip';
@override
bool get isAvailable => true;
@override
bool get isEnabled => true;
@override
bool get supportsFixedRate => false;
@override
ExchangeProviderDescription get description =>
ExchangeProviderDescription.chainflip;
@override
Future<bool> checkIsAvailable() async => true;
@override
Future<Limits?> fetchLimits(
{required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode}) async {
try {
if(!_supported.contains(from) || !_supported.contains(to)) {
throw Exception('No rates found for $from to $to');
}
final assetId = _normalizeCurrency(from);
final assetsResponse = await _getAssets();
final assets = assetsResponse['assets'] as List<dynamic>;
final minAmount = assets.firstWhere(
(asset) => asset['id'] == assetId,
orElse: () => null)?['minimalAmountNative'] ?? '0';
if (minAmount == '0') throw Exception('No rates found for $from to $to');
return Limits(min: _amountFromNative(minAmount.toString(), from));
} catch (e) {
printV(e.toString());
throw Exception('Chainflip failed to fetch limits');
}
}
@override
Future<double> fetchRate({
required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount
}) async {
// TODO: It seems this rate is getting cached, and re-used for different amounts, can we not do this?
try {
if (amount == 0) return 0.0;
if(!_supported.contains(from) || !_supported.contains(to)) return 0.0;
final quoteParams = {
'apiKey': _affiliateKey,
'sourceAsset': _normalizeCurrency(from),
'destinationAsset': _normalizeCurrency(to),
'amount': _amountToNative(amount, from),
'commissionBps': _affiliateBps
};
final quoteResponse = await _getSwapQuote(quoteParams);
final expectedAmountOut =
quoteResponse['egressAmountNative'] as String? ?? '0';
final rate = _amountFromNative(expectedAmountOut, to) / amount;
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'fetchRate',
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'quoteParams': quoteParams,
},
responseData: {
'expectedAmountOut': expectedAmountOut,
'rate': rate,
'quoteResponse': quoteResponse,
},
);
return rate;
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: e,
stackTrace: s,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
printV(e.toString());
return 0.0;
}
}
@override
Future<Trade> createTrade(
{required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll}) async {
try {
final maxSlippage = 2;
final quoteParams = {
'apiKey': _affiliateKey,
'sourceAsset': _normalizeCurrency(request.fromCurrency),
'destinationAsset': _normalizeCurrency(request.toCurrency),
'amount': _amountToNative(double.parse(request.fromAmount), request.fromCurrency),
'commissionBps': _affiliateBps
};
final quoteResponse = await _getSwapQuote(quoteParams);
final estimatedPrice = quoteResponse['estimatedPrice'] as double;
final minimumPrice = estimatedPrice * (100 - maxSlippage) / 100;
final swapParams = {
'apiKey': _affiliateKey,
'sourceAsset': _normalizeCurrency(request.fromCurrency),
'destinationAsset': _normalizeCurrency(request.toCurrency),
'destinationAddress': request.toAddress,
'commissionBps': _affiliateBps,
'minimumPrice': minimumPrice.toString(),
'refundAddress': request.refundAddress,
'boostFee': '6',
'retryDurationInBlocks': '150'
};
if (quoteResponse.containsKey('numberOfChunks') && quoteResponse.containsKey('chunkIntervalBlocks')) {
swapParams.addAll({
'numberOfChunks': quoteResponse['numberOfChunks'].toString(),
'chunkIntervalBlocks': quoteResponse['chunkIntervalBlocks'].toString(),
});
}
final swapResponse = await _openDepositChannel(swapParams);
final id = '${swapResponse['issuedBlock']}-${swapResponse['network'].toString()}-${swapResponse['channelId']}';
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'createTrade',
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'quoteParams': quoteParams,
'swapParams': swapParams,
},
responseData: {
'id': id,
'inputAddress': swapResponse['address'].toString(),
'estimatedPrice': estimatedPrice,
'minimumPrice': minimumPrice,
'swapResponse': swapResponse,
},
);
return Trade(
id: id,
from: request.fromCurrency,
to: request.toCurrency,
provider: description,
inputAddress: swapResponse['address'].toString(),
createdAt: DateTime.now(),
amount: request.fromAmount,
receiveAmount: request.toAmount,
state: TradeState.waiting,
payoutAddress: request.toAddress,
isSendAll: isSendAll,
);
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: e,
stackTrace: s,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
},
);
printV(e.toString());
rethrow;
}
}
@override
Future<Trade> findTradeById({required String id}) async {
try {
final channelParts = id.split('-');
final network = channelParts[1];
final normalizedNetwork = _normalizeNetworkName(network);
final statusParams = {
'apiKey': _affiliateKey,
'issuedBlock': channelParts[0],
'network': normalizedNetwork,
'channelId': channelParts[2]
};
final statusResponse = await _getStatus(statusParams);
if (statusResponse == null)
throw Exception('Trade not found for id: $id');
final status = statusResponse['status'];
final currentState = _determineState(status['state'].toString());
final depositAmount = status['deposit']?['amount']?.toString() ?? '0.0';
final receiveAmount = status['swapEgress']?['amount']?.toString() ?? '0.0';
final refundAmount = status['refundEgress']?['amount']?.toString() ?? '0.0';
final isRefund = status['refundEgress'] != null;
final amount = isRefund ? refundAmount : receiveAmount;
final from = status['sourceAsset'].toString();
final to = status['destinationAsset'].toString();
final newTrade = Trade(
id: id,
from: _toCurrency(from),
to: _toCurrency(to),
provider: description,
amount: depositAmount,
receiveAmount: amount,
state: currentState,
payoutAddress: status['destinationAddress'].toString(),
outputTransaction:
status['swapEgress']?['transactionReference']?.toString(),
isRefund: isRefund,
);
return newTrade;
} catch (e) {
printV(e.toString());
rethrow;
}
}
String _normalizeCurrency(CryptoCurrency currency) {
final tag = currency.tag?.toLowerCase();
final title = currency.title.toLowerCase();
// Naive assets without network tag
if (tag == null) return '$title.$title';
return '$title.$tag';
}
String _normalizeNetworkName (String name) {
final networkName = switch (name) {
'BITCOIN' => 'Bitcoin',
'ETHEREUM' => 'Ethereum',
'ARBITRUM' => 'Arbitrum',
'SOLANA' => 'Solana',
_ => name
};
return networkName;
}
CryptoCurrency? _toCurrency(String name) {
final currency = switch (name) {
'btc.btc' => CryptoCurrency.btc,
'eth.eth' => CryptoCurrency.eth,
'usdc.eth' => CryptoCurrency.usdc,
'usdt.eth' => CryptoCurrency.usdterc20,
'flip.eth' => CryptoCurrency.flip,
'wbtc.eth' => CryptoCurrency.wbtc,
'sol.sol' => CryptoCurrency.sol,
'usdc.sol' => CryptoCurrency.usdcsol,
'eth.arb' => CryptoCurrency.arbEth,
'usdc.arb' => CryptoCurrency.usdcArb,
'usdt.arb' => CryptoCurrency.usdtArb,
_ => null
};
return currency;
}
String _amountToNative(double amount, CryptoCurrency currency) =>
(amount * pow(10, currency.decimals)).toInt().toString();
double _amountFromNative(String amount, CryptoCurrency currency) =>
double.parse(amount) / pow(10, currency.decimals);
Future<Map<String, dynamic>> _getAssets() async =>
_getRequest(_assetsPath, {});
Future<Map<String, dynamic>> _openDepositChannel(Map<String, String> params) async =>
_getRequest(_swapPath, params);
Future<Map<String, dynamic>> _getRequest(String path, Map<String, String> params) async {
final uri = Uri.https(_baseURL, path, params);
final response = await ProxyWrapper().get(clearnetUri: uri);
if ((response.statusCode != 200) || (response.body.contains('error'))) {
throw Exception('Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}');
}
return json.decode(response.body) as Map<String, dynamic>;
}
Future<Map<String, dynamic>> _getSwapQuote(Map<String, String> params) async {
final uri = Uri.https(_baseURL, _quotePath, params);
final response = await ProxyWrapper().get(clearnetUri: uri);
if ((response.statusCode != 200) || (response.body.contains('error'))) {
throw Exception('Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}');
}
final List<dynamic> jsonList = json.decode(response.body) as List<dynamic>;
final List<Map<String, dynamic>> quotes =
jsonList.map((e) => e as Map<String, dynamic>).toList();
Map<String, dynamic> highestQuote = quotes.reduce((current, next) {
double currentAmount = current['egressAmount'] as double;
double nextAmount = next['egressAmount'] as double;
return currentAmount > nextAmount ? current : next;
});
return highestQuote;
}
Future<Map<String, dynamic>?> _getStatus(Map<String, String> params) async {
final uri = Uri.https(_baseURL, _txInfoPath, params);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode == 404) return null;
if ((response.statusCode != 200) || (response.body.contains('error'))) {
throw Exception('Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}');
}
return json.decode(response.body) as Map<String, dynamic>;
}
TradeState _determineState(String state) {
final swapState = switch (state) {
'waiting' => TradeState.waiting,
'receiving' => TradeState.processing,
'swapping' => TradeState.processing,
'sending' => TradeState.processing,
'sent' => TradeState.processing,
'completed' => TradeState.success,
'failed' => TradeState.failed,
_ => TradeState.notFound
};
return swapState;
}
}
@@ -1,366 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_not_found_exception.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:hash_wallet/utils/distribution_info.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:hash_wallet/wallet_type_utils.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:hash_wallet/utils/exchange_provider_logger.dart';
class ChangeNowExchangeProvider extends ExchangeProvider {
ChangeNowExchangeProvider({required SettingsStore settingsStore})
: _settingsStore = settingsStore,
_lastUsedRateId = '';
static final apiKey =
isMoneroOnly ? secrets.changeNowMoneroApiKey : secrets.changeNowCakeWalletApiKey;
static const apiAuthority = 'api.changenow.io';
static const createTradePath = '/v2/exchange';
static const findTradeByIdPath = '/v2/exchange/by-id';
static const estimatedAmountPath = '/v2/exchange/estimated-amount';
static const rangePath = '/v2/exchange/range';
static const apiHeaderKey = 'x-changenow-api-key';
final SettingsStore _settingsStore;
String _lastUsedRateId;
@override
String get title => 'ChangeNOW';
@override
bool get isAvailable => true;
@override
bool get isEnabled => true;
@override
bool get supportsFixedRate => true;
@override
ExchangeProviderDescription get description => ExchangeProviderDescription.changeNow;
@override
Future<bool> checkIsAvailable() async => true;
@override
Future<Limits?> fetchLimits(
{required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode}) async {
final headers = {apiHeaderKey: apiKey};
final params = <String, String>{
'fromCurrency': _normalizeCurrency(from),
'toCurrency': _normalizeCurrency(to),
'fromNetwork': _networkFor(from),
'toNetwork': _networkFor(to),
'flow': _getFlow(isFixedRateMode)
};
final uri = Uri.https(apiAuthority, rangePath, params);
final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['error'] as String;
final message = responseJSON['message'] as String;
throw Exception('${error}\n$message');
}
if (response.statusCode != 200)
throw Exception('Unexpected http status: ${response.statusCode}');
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final min = double.tryParse(responseJSON['minAmount']?.toString() ?? '');
final max = double.tryParse(responseJSON['maxAmount']?.toString() ?? '');
if (max == 0) return null;
return Limits(min: min, max: max);
}
@override
Future<double> fetchRate(
{required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount}) async {
try {
if (amount == 0) return 0.0;
final headers = {apiHeaderKey: apiKey};
final isReverse = isReceiveAmount;
final type = isReverse ? 'reverse' : 'direct';
final params = <String, String>{
'fromCurrency': _normalizeCurrency(from),
'toCurrency': _normalizeCurrency(to),
'fromNetwork': _networkFor(from),
'toNetwork': _networkFor(to),
'type': type,
'flow': _getFlow(isFixedRateMode)
};
if (isReverse)
params['toAmount'] = amount.toString();
else
params['fromAmount'] = amount.toString();
final uri = Uri.https(apiAuthority, estimatedAmountPath, params);
final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final fromAmount = double.tryParse(responseJSON['fromAmount']?.toString() ?? '') ?? 0.0;
final toAmount = double.tryParse(responseJSON['toAmount']?.toString() ?? '') ?? 0.0;
if (fromAmount <= 0 || toAmount <= 0) return 0.0;
final rateId = responseJSON['rateId'] as String? ?? '';
if (rateId.isNotEmpty) _lastUsedRateId = rateId;
final rate = isReverse ? (amount / fromAmount) : (toAmount / amount);
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'fetchRate',
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'type': type,
'flow': _getFlow(isFixedRateMode),
},
responseData: {
'fromAmount': fromAmount,
'toAmount': toAmount,
'rateId': rateId,
'rate': rate,
},
);
return rate;
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: e,
stackTrace: s,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
printV(e.toString());
return 0.0;
}
}
@override
Future<Trade> createTrade({
required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll,
}) async {
final distributionPath = await DistributionInfo.instance.getDistributionPath();
final formattedAppVersion = int.tryParse(_settingsStore.appVersion.replaceAll('.', '')) ?? 0;
final payload = {
'app': isMoneroOnly ? 'monerocom' : 'cakewallet',
'device': Platform.operatingSystem,
'distribution': distributionPath,
'version': formattedAppVersion
};
final headers = {apiHeaderKey: apiKey, 'Content-Type': 'application/json'};
final type = isFixedRateMode ? 'reverse' : 'direct';
final body = <String, dynamic>{
'fromCurrency': _normalizeCurrency(request.fromCurrency),
'toCurrency': _normalizeCurrency(request.toCurrency),
'fromNetwork': _networkFor(request.fromCurrency),
'toNetwork': _networkFor(request.toCurrency),
if (!isFixedRateMode) 'fromAmount': request.fromAmount,
if (isFixedRateMode) 'toAmount': request.toAmount,
'address': request.toAddress,
'flow': _getFlow(isFixedRateMode),
'type': type,
'refundAddress': request.refundAddress,
'payload': payload,
};
if (isFixedRateMode) {
// since we schedule to calculate the rate every 5 seconds we need to ensure that
// we have the latest rate id with the given inputs before creating the trade
await fetchRate(
from: request.fromCurrency,
to: request.toCurrency,
amount: double.tryParse(request.toAmount) ?? 0,
isFixedRateMode: true,
isReceiveAmount: true,
);
body['rateId'] = _lastUsedRateId;
}
final uri = Uri.https(apiAuthority, createTradePath);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(body),
);
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['error'] as String;
final message = responseJSON['message'] as String;
throw Exception('${error}\n$message');
}
if (response.statusCode != 200)
throw Exception('Unexpected http status: ${response.statusCode}');
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final id = responseJSON['id'] as String;
final inputAddress = responseJSON['payinAddress'] as String;
final refundAddress = responseJSON['refundAddress'] as String;
final extraId = responseJSON['payinExtraId'] as String?;
final payoutAddress = responseJSON['payoutAddress'] as String;
final fromAmount = responseJSON['fromAmount']?.toString();
final toAmount = responseJSON['toAmount']?.toString();
return Trade(
id: id,
from: request.fromCurrency,
to: request.toCurrency,
provider: description,
inputAddress: inputAddress,
refundAddress: refundAddress,
extraId: extraId,
createdAt: DateTime.now(),
amount: fromAmount ?? request.fromAmount,
receiveAmount: toAmount ?? request.toAmount,
state: TradeState.created,
payoutAddress: payoutAddress,
isSendAll: isSendAll,
);
}
@override
Future<Trade> findTradeById({required String id}) async {
final headers = {apiHeaderKey: apiKey};
final params = <String, String>{'id': id};
final uri = Uri.https(apiAuthority, findTradeByIdPath, params);
final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
if (response.statusCode == 404) throw TradeNotFoundException(id, provider: description);
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['message'] as String;
throw TradeNotFoundException(id, provider: description, description: error);
}
if (response.statusCode != 200)
throw Exception('Unexpected http status: ${response.statusCode}');
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
// Parsing 'from' currency
final fromCurrency = responseJSON['fromCurrency'] as String;
final fromNetwork = responseJSON['fromNetwork'] as String?;
final _normalizedFromNetwork = _normalizeNetworkType(fromNetwork ?? '');
final fromTag = fromCurrency.toUpperCase() == _normalizedFromNetwork.toUpperCase()
? null
: _normalizedFromNetwork;
final from = CryptoCurrency.safeParseCurrencyFromString(fromCurrency, tag: fromTag);
// Parsing 'to' currency
final toCurrency = responseJSON['toCurrency'] as String;
final toNetwork = responseJSON['toNetwork'] as String?;
final _normalizedToNetwork = _normalizeNetworkType(toNetwork ?? '');
final toTag = toCurrency.toUpperCase() == _normalizedToNetwork.toUpperCase()
? null
: _normalizedToNetwork;
final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency, tag: toTag);
final inputAddress = responseJSON['payinAddress'] as String;
final expectedSendAmount = responseJSON['expectedAmountFrom'].toString();
final status = responseJSON['status'] as String;
final state = TradeState.deserialize(raw: status);
final extraId = responseJSON['payinExtraId'] as String?;
final outputTransaction = responseJSON['payoutHash'] as String?;
final expiredAtRaw = responseJSON['validUntil'] as String?;
final payoutAddress = responseJSON['payoutAddress'] as String;
final expiredAt = DateTime.tryParse(expiredAtRaw ?? '')?.toLocal();
return Trade(
id: id,
from: from,
to: to,
provider: description,
inputAddress: inputAddress,
amount: expectedSendAmount,
state: state,
extraId: extraId,
expiredAt: expiredAt,
outputTransaction: outputTransaction,
payoutAddress: payoutAddress,
);
}
String _getFlow(bool isFixedRate) => isFixedRate ? 'fixed-rate' : 'standard';
String _networkFor(CryptoCurrency currency) {
switch (currency) {
case CryptoCurrency.usdt:
return 'btc';
case CryptoCurrency.arb:
return 'arbitrum';
default:
return currency.tag != null ? _normalizeTag(currency.tag!) : currency.title.toLowerCase();
}
}
String _normalizeCurrency(CryptoCurrency currency) {
if (currency.title == "USDC" && currency.tag == "POLY") {
throw "Only Bridged USDC (USDC.e) is allowed in ChangeNow";
}
switch (currency) {
case CryptoCurrency.zec:
return 'zec';
default:
return currency.title.toLowerCase();
}
}
String _normalizeTag(String tag) {
switch (tag) {
case 'POLY':
return 'matic';
case 'LN':
return 'lightning';
case 'AVAXC':
return 'cchain';
case 'ARB':
return 'arbitrum';
default:
return tag.toLowerCase();
}
}
String _normalizeNetworkType(String network) {
return switch (network.toUpperCase()) {
'POLY' => 'MATIC',
'AVAXC' => 'CCHAIN',
'ARBITRUM' => 'ARB',
_ => network,
};
}
}
@@ -1,467 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/core/lightning_invoice_service.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_not_found_exception.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:hash_wallet/wallet_type_utils.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:hash_wallet/utils/exchange_provider_logger.dart';
class ExolixExchangeProvider extends ExchangeProvider {
ExolixExchangeProvider();
static final apiKey = isMoneroOnly ? secrets.exolixMoneroApiKey : secrets.exolixCakeWalletApiKey;
static const apiBaseUrl = 'exolix.com';
static const transactionsPath = '/api/v2/transactions';
static const ratePath = '/api/v2/rate';
@override
String get title => 'Exolix';
@override
bool get isAvailable => true;
@override
bool get isEnabled => true;
@override
bool get supportsFixedRate => true;
@override
ExchangeProviderDescription get description => ExchangeProviderDescription.exolix;
@override
Future<bool> checkIsAvailable() async => true;
@override
Future<Limits?> fetchLimits({
required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode,
}) async {
final params = <String, String>{
'rateType': _getRateType(isFixedRateMode),
'amount': '1',
'apiToken': apiKey,
};
if (isFixedRateMode) {
params['coinFrom'] = _normalizeCurrency(to);
params['coinTo'] = _normalizeCurrency(_overrideFromCryptoCurrency(from));
params['networkFrom'] = _networkFor(to);
params['networkTo'] = _networkFor(from);
} else {
params['coinFrom'] = _normalizeCurrency(_overrideFromCryptoCurrency(from));
params['coinTo'] = _normalizeCurrency(to);
params['networkFrom'] = _networkFor(from);
params['networkTo'] = _networkFor(to);
}
// Maximum of 2 attempts to fetch limits
for (int i = 0; i < 2; i++) {
final uri = Uri.https(apiBaseUrl, ratePath, params);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode == 200) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final minAmount = responseJSON['minAmount'];
final maxAmount = responseJSON['maxAmount'];
return Limits(min: _toDouble(minAmount), max: _toDouble(maxAmount));
} else if (response.statusCode == 422) {
final errorResponse = json.decode(response.body) as Map<String, dynamic>;
if (errorResponse.containsKey('minAmount')) {
params['amount'] = errorResponse['minAmount'].toString();
continue;
}
throw Exception('Error 422: ${errorResponse['message'] ?? 'Unknown error'}');
} else {
throw Exception('Unexpected HTTP status: ${response.statusCode}');
}
}
throw Exception('Failed to fetch limits after retrying.');
}
@override
Future<double> fetchRate({
required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount
}) async {
try {
if (amount == 0) return 0.0;
final params = {
'coinFrom': _normalizeCurrency(_overrideFromCryptoCurrency(from)),
'coinTo': _normalizeCurrency(to),
'networkFrom': _networkFor(from),
'networkTo': _networkFor(to),
'rateType': _getRateType(isFixedRateMode),
'apiToken': apiKey,
};
if (isReceiveAmount)
params['withdrawalAmount'] = amount.toString();
else
params['amount'] = amount.toString();
final uri = Uri.https(apiBaseUrl, ratePath, params);
final response = await ProxyWrapper().get(clearnetUri: uri);
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode != 200) {
final message = responseJSON['message'] as String?;
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: Exception(message ?? 'Unknown error'),
stackTrace: StackTrace.current,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'params': params,
'url': uri.toString(),
},
);
throw Exception(message);
}
final rate = double.tryParse(responseJSON['rate']?.toString() ?? '') ?? 0.0;
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'fetchRate',
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'params': params,
'url': uri.toString(),
},
responseData: {
'rate': rate,
'statusCode': response.statusCode,
'responseJSON': responseJSON,
},
);
return rate;
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: e,
stackTrace: s,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
printV(e.toString());
printV(s.toString());
return 0.0;
}
}
@override
Future<Trade> createTrade({
required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll,
}) async {
final headers = {'Content-Type': 'application/json'};
final body = {
'coinFrom': _normalizeCurrency(_overrideFromCryptoCurrency(request.fromCurrency)),
'coinTo': _normalizeCurrency(_overrideToCryptoCurrency(request.toCurrency, request.toAddress)),
'networkFrom': _networkFor(request.fromCurrency),
'networkTo': _networkFor(request.toCurrency),
'withdrawalAddress': await _normalizeAddress(request.toAddress),
'refundAddress': await _normalizeAddress(request.refundAddress),
'rateType': _getRateType(isFixedRateMode),
'apiToken': apiKey,
};
if (isFixedRateMode)
body['withdrawalAmount'] = request.toAmount;
else
body['amount'] = request.fromAmount;
final uri = Uri.https(apiBaseUrl, transactionsPath);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(body),
);
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final errors = responseJSON['error'] as Map<String, String>;
final errorMessage = errors.values.join(', ');
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: Exception(errorMessage),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'url': uri.toString(),
},
);
throw Exception(errorMessage);
}
if (response.statusCode != 200 && response.statusCode != 201) {
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: Exception('Unexpected http status: ${response.statusCode}'),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'url': uri.toString(),
},
);
throw Exception('Unexpected http status: ${response.statusCode}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final id = responseJSON['id'] as String;
final inputAddress = responseJSON['depositAddress'] as String;
final refundAddress = responseJSON['refundAddress'] as String?;
final extraId = responseJSON['depositExtraId'] as String?;
final payoutAddress = responseJSON['withdrawalAddress'] as String;
final amount = responseJSON['amount'].toString();
final receiveAmount = responseJSON['amountTo']?.toString();
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'createTrade',
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'url': uri.toString(),
},
responseData: {
'id': id,
'inputAddress': inputAddress,
'refundAddress': refundAddress,
'extraId': extraId,
'payoutAddress': payoutAddress,
'amount': amount,
'receiveAmount': receiveAmount,
'statusCode': response.statusCode,
'responseJSON': responseJSON,
},
);
return Trade(
id: id,
from: request.fromCurrency,
to: request.toCurrency,
provider: description,
inputAddress: inputAddress,
refundAddress: refundAddress,
extraId: extraId,
createdAt: DateTime.now(),
amount: amount,
receiveAmount: receiveAmount ?? request.toAmount,
state: TradeState.created,
payoutAddress: payoutAddress,
isSendAll: isSendAll,
);
}
@override
Future<Trade> findTradeById({required String id}) async {
final findTradeByIdPath = '$transactionsPath/$id';
final uri = Uri.https(apiBaseUrl, findTradeByIdPath);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode == 404) throw TradeNotFoundException(id, provider: description);
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final errors = responseJSON['errors'] as Map<String, String>;
final errorMessage = errors.values.join(', ');
throw TradeNotFoundException(id, provider: description, description: errorMessage);
}
if (response.statusCode != 200)
throw Exception('Unexpected http status: ${response.statusCode}');
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
// Parsing 'from' currency
final coinFrom = responseJSON['coinFrom']['coinCode'] as String;
final coinFromNetwork = responseJSON['coinFrom']['network'] as String?;
final _normalizedFromNetwork = _normalizeNetworkType(coinFromNetwork ?? '');
final fromTag = coinFrom.toUpperCase() == _normalizedFromNetwork.toUpperCase() ? null : coinFromNetwork;
final from = CryptoCurrency.safeParseCurrencyFromString(coinFrom, tag: fromTag);
// Parsing 'to' currency
final coinTo = responseJSON['coinTo']['coinCode'] as String;
final coinToNetwork = responseJSON['coinTo']['network'] as String?;
final _normalizedToNetwork = _normalizeNetworkType(coinToNetwork ?? '');
final toTag = coinTo.toUpperCase() == _normalizedToNetwork.toUpperCase() ? null : coinToNetwork;
final to = CryptoCurrency.safeParseCurrencyFromString(coinTo, tag: toTag);
final inputAddress = responseJSON['depositAddress'] as String;
final amount = responseJSON['amount'].toString();
final status = responseJSON['status'] as String;
final extraId = responseJSON['depositExtraId'] as String?;
final outputTransaction = responseJSON['hashOut']['hash'] as String?;
final payoutAddress = responseJSON['withdrawalAddress'] as String;
return Trade(
id: id,
from: from,
to: to,
provider: description,
inputAddress: inputAddress,
amount: amount,
state: TradeState.deserialize(raw: _prepareStatus(status)),
extraId: extraId,
outputTransaction: outputTransaction,
payoutAddress: payoutAddress,
);
}
String _getRateType(bool isFixedRate) => isFixedRate ? 'fixed' : 'float';
String _prepareStatus(String status) {
switch (status) {
case 'deleted':
case 'error':
return 'overdue';
default:
return status;
}
}
String _networkFor(CryptoCurrency currency) {
switch (currency) {
case CryptoCurrency.arb:
return 'ARBITRUM';
case CryptoCurrency.btcln:
return 'LIGHTNING';
default:
return currency.tag != null ? _normalizeTag(currency.tag!) : currency.title;
}
}
String _normalizeNetworkType(String network) {
return switch (network.toUpperCase()) {
'ARBITRUM' => 'ARB',
_ => network,
};
}
CryptoCurrency _overrideFromCryptoCurrency(CryptoCurrency currency) {
if (currency == CryptoCurrency.zec)
return CryptoCurrency.zaddr; // Sending is always shielded zcash
return currency;
}
CryptoCurrency _overrideToCryptoCurrency(CryptoCurrency currency, String address) {
if (RegExp(r'u1[a-zA-Z0-9]{100,300}').hasMatch(address) && currency == CryptoCurrency.zec)
return CryptoCurrency.zaddr; // If the user pastes a unified address use shielded zcash
return currency;
}
String _normalizeCurrency(CryptoCurrency currency) {
switch (currency) {
case CryptoCurrency.nano:
return 'XNO';
case CryptoCurrency.bttc:
return 'BTT';
case CryptoCurrency.zec:
return 'ZEC';
case CryptoCurrency.zaddr:
return 'ZEC-SHIELDED';
default:
return currency.title;
}
}
String _normalizeTag(String tag) {
switch (tag) {
case 'POLY':
return 'Polygon';
case 'ARB':
return 'Arbitrum';
default:
return tag;
}
}
Future<String> _normalizeAddress(String address) async {
if (address.startsWith('bitcoincash:'))
return address.replaceFirst('bitcoincash:', '');
// Lightning addresses
if(address.contains("@"))
return await getBolt11FromLightingAddress(address) ?? address;
return address;
}
static double? _toDouble(dynamic value) {
if (value is int) {
return value.toDouble();
} else if (value is double) {
return value;
} else if (value is String) {
return double.tryParse(value);
}
return null;
}
}
@@ -1,447 +0,0 @@
import 'dart:convert';
import 'dart:developer';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_not_created_exception.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:hash_wallet/utils/exchange_provider_logger.dart';
class LetsExchangeExchangeProvider extends ExchangeProvider {
LetsExchangeExchangeProvider();
static const apiKey = secrets.letsExchangeBearerToken;
static const _baseUrl = 'api.letsexchange.io';
static const _infoPath = '/api/v1/info';
static const _infoRevertPath = '/api/v1/info-revert';
static const _createTransactionPath = '/api/v1/transaction';
static const _createTransactionRevertPath = '/api/v1/transaction-revert';
static const _getTransactionPath = '/api/v1/transaction';
static const _affiliateId = secrets.letsExchangeAffiliateId;
@override
String get title => 'LetsExchange';
@override
bool get isAvailable => true;
@override
bool get isEnabled => true;
@override
bool get supportsFixedRate => true;
@override
ExchangeProviderDescription get description => ExchangeProviderDescription.letsExchange;
@override
Future<bool> checkIsAvailable() async => true;
@override
Future<Limits?> fetchLimits(
{required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode}) async {
final networkFrom = _getNetworkType(from);
final networkTo = _getNetworkType(to);
try {
final params = {
'from': from.title,
'to': to.title,
if (networkFrom != null) 'network_from': networkFrom,
if (networkTo != null) 'network_to': networkTo,
'amount': '1',
'affiliate_id': _affiliateId,
'float': isFixedRateMode ? 'false' : 'true',
};
final responseJSON = await _getInfo(params, isFixedRateMode);
final min = double.tryParse(responseJSON['min_amount'] as String);
final max = double.tryParse(responseJSON['max_amount'] as String);
return Limits(min: min, max: max);
} catch (e) {
log(e.toString());
throw Exception('Failed to fetch limits');
}
}
@override
Future<double> fetchRate(
{required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount}) async {
final networkFrom = _getNetworkType(from);
final networkTo = _getNetworkType(to);
try {
final params = {
'from': from.title,
'to': to.title,
if (networkFrom != null) 'network_from': networkFrom,
if (networkTo != null) 'network_to': networkTo,
'amount': amount.toString(),
'affiliate_id': _affiliateId,
'float': isFixedRateMode ? 'false' : 'true',
};
final responseJSON = await _getInfo(params, isFixedRateMode);
final amountToGet = double.tryParse(responseJSON['amount'] as String) ?? 0.0;
if (amountToGet == 0.0) return 0.0;
final rate = isFixedRateMode ? amount / amountToGet : amountToGet / amount;
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'fetchRate',
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'networkFrom': networkFrom,
'networkTo': networkTo,
'params': params,
},
responseData: {
'amountToGet': amountToGet,
'rate': rate,
'responseJSON': responseJSON,
},
);
return rate;
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: e,
stackTrace: s,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'networkFrom': networkFrom,
'networkTo': networkTo,
},
);
printV(e.toString());
return 0.0;
}
}
@override
Future<Trade> createTrade(
{required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll}) async {
final networkFrom = _getNetworkType(request.fromCurrency);
final networkTo = _getNetworkType(request.toCurrency);
try {
final params = {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
if (networkFrom != null) 'network_from': networkFrom,
if (networkTo != null) 'network_to': networkTo,
'amount': isFixedRateMode ? request.toAmount.toString() : request.fromAmount.toString(),
'affiliate_id': _affiliateId,
'float': isFixedRateMode ? 'false' : 'true',
};
final responseInfoJSON = await _getInfo(params, isFixedRateMode);
final rateId = responseInfoJSON['rate_id'] as String;
final withdrawalAddress = _normalizeBchAddress(request.toAddress);
final returnAddress = _normalizeBchAddress(request.refundAddress);
final tradeParams = {
'coin_from': request.fromCurrency.title,
'coin_to': request.toCurrency.title,
if (!isFixedRateMode) 'deposit_amount': request.fromAmount.toString(),
'withdrawal': withdrawalAddress,
if (isFixedRateMode) 'withdrawal_amount': request.toAmount.toString(),
'withdrawal_extra_id': '',
'return': returnAddress,
'rate_id': rateId,
if (networkFrom != null) 'network_from': networkFrom,
if (networkTo != null) 'network_to': networkTo,
'affiliate_id': _affiliateId,
'float': isFixedRateMode ? 'false' : 'true',
};
final headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': apiKey
};
final uri = Uri.https(
_baseUrl, isFixedRateMode ? _createTransactionRevertPath : _createTransactionPath);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(tradeParams),
);
if (response.statusCode != 200) {
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: Exception('LetsExchange create trade failed: ${response.body}'),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'networkFrom': networkFrom,
'networkTo': networkTo,
'tradeParams': tradeParams,
'url': uri.toString(),
},
);
throw Exception('LetsExchange create trade failed: ${response.body}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final id = responseJSON['transaction_id'] as String;
final from = responseJSON['coin_from'] as String;
final to = responseJSON['coin_to'] as String;
final payoutAddress = responseJSON['withdrawal'] as String;
final depositAddress = responseJSON['deposit'] as String;
final refundAddress = responseJSON['return'] as String;
final depositAmount = responseJSON['deposit_amount'] as String;
final receiveAmount = responseJSON['withdrawal_amount'] as String;
final status = responseJSON['status'] as String;
// We ignore the created_at from response and use DateTime.now() instead
final createdAtString = responseJSON['created_at'] as String;
final expiredAtTimestamp = responseJSON['expired_at'] as int;
final extraId = responseJSON['deposit_extra_id'] as String?;
final createdAt = DateTime.now();
final expiredAt = createdAt.add(Duration(minutes: 30));
CryptoCurrency fromCurrency;
if (request.fromCurrency.tag != null && request.fromCurrency.title == from) {
fromCurrency = request.fromCurrency;
} else {
fromCurrency = CryptoCurrency.fromString(from);
}
CryptoCurrency toCurrency;
if (request.toCurrency.tag != null && request.toCurrency.title == to) {
toCurrency = request.toCurrency;
} else {
toCurrency = CryptoCurrency.fromString(to);
}
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'createTrade',
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'networkFrom': networkFrom,
'networkTo': networkTo,
'tradeParams': tradeParams,
'url': uri.toString(),
},
responseData: {
'id': id,
'from': from,
'to': to,
'depositAddress': depositAddress,
'payoutAddress': payoutAddress,
'refundAddress': refundAddress,
'depositAmount': depositAmount,
'receiveAmount': receiveAmount,
'status': status,
'createdAt': createdAtString,
'expiredAt': expiredAtTimestamp,
'extraId': extraId,
'statusCode': response.statusCode,
},
);
return Trade(
id: id,
from: fromCurrency,
to: toCurrency,
provider: description,
inputAddress: depositAddress,
payoutAddress: payoutAddress,
refundAddress: refundAddress,
amount: depositAmount,
receiveAmount: receiveAmount,
state: TradeState.deserialize(raw: status),
createdAt: createdAt,
expiredAt: expiredAt,
extraId: extraId,
isSendAll: isSendAll,
);
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: e,
stackTrace: s,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'networkFrom': networkFrom,
'networkTo': networkTo,
},
);
log(e.toString());
throw TradeNotCreatedException(description);
}
}
@override
Future<Trade> findTradeById({required String id}) async {
final headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': apiKey
};
final url = Uri.https(_baseUrl, '$_getTransactionPath/$id');
final response = await ProxyWrapper().get(clearnetUri: url, headers: headers);
if (response.statusCode != 200) {
throw Exception('LetsExchange fetch trade failed: ${response.body}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
// Parsing 'from' currency
final fromCurrency = responseJSON['coin_from'] as String;
final fromNetwork = responseJSON['coin_from_network'] as String?;
final normalizedFromNetwork = _normalizeNetworkType(fromNetwork ?? '');
final fromTag = fromCurrency == normalizedFromNetwork ? null : normalizedFromNetwork;
final from = CryptoCurrency.safeParseCurrencyFromString(fromCurrency, tag: fromTag);
// Parsing 'to' currency
final toCurrency = responseJSON['coin_to'] as String;
final toNetwork = responseJSON['coin_to_network'] as String?;
final normalizedToNetwork = _normalizeNetworkType(toNetwork ?? '');
final toTag = toCurrency == normalizedToNetwork ? null : normalizedToNetwork;
final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency, tag: toTag);
final payoutAddress = responseJSON['withdrawal'] as String;
final depositAddress = responseJSON['deposit'] as String;
final refundAddress = responseJSON['return'] as String;
final depositAmount = responseJSON['deposit_amount'] as String;
final receiveAmount = responseJSON['withdrawal_amount'] as String;
final status = responseJSON['status'] as String;
final extraId = responseJSON['deposit_extra_id'] as String?;
return Trade(
id: id,
from: from,
to: to,
provider: description,
inputAddress: depositAddress,
payoutAddress: payoutAddress,
refundAddress: refundAddress,
amount: depositAmount,
receiveAmount: receiveAmount,
state: TradeState.deserialize(raw: status),
isRefund: status == 'refund',
extraId: extraId,
);
}
Future<Map<String, dynamic>> _getInfo(Map<String, String> params, bool isFixedRateMode) async {
final headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': apiKey
};
try {
final uri = Uri.https(_baseUrl, isFixedRateMode ? _infoRevertPath : _infoPath);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(params),
);
if (response.statusCode != 200) {
throw Exception('LetsExchange fetch info failed: ${response.body}');
}
return json.decode(response.body) as Map<String, dynamic>;
} catch (e) {
throw Exception('LetsExchange failed to fetch info ${e.toString()}');
}
}
String? _getNetworkType(CryptoCurrency currency) {
if (currency.tag != null && currency.tag!.isNotEmpty) {
switch (currency.tag!) {
case 'TRX':
return 'TRC20';
case 'ETH':
return 'ERC20';
case 'BSC':
return 'BEP20';
case 'ARB':
return 'ARBITRUM';
default:
return currency.tag!;
}
}
return _normalizeTitleToNetwork(currency.title);
}
String _normalizeNetworkType(String network) {
return switch (network.toUpperCase()) {
'ERC20' => 'ETH',
'TRC20' => 'TRX',
'BEP20' => 'BSC',
'ARBITRUM' => 'ARB',
_ => network,
};
}
String _normalizeTitleToNetwork(String title) {
return switch (title.toUpperCase()) {
'ARB' => 'ARBITRUM',
_ => title,
};
}
String _normalizeBchAddress(String address) =>
address.startsWith('bitcoincash:') ? address.substring(12) : address;
}
@@ -1,479 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_not_created_exception.dart';
import 'package:hash_wallet/exchange/trade_not_found_exception.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:hash_wallet/utils/exchange_provider_logger.dart';
class SideShiftExchangeProvider extends ExchangeProvider {
SideShiftExchangeProvider();
static const affiliateId = secrets.sideShiftAffiliateId;
static const apiBaseUrl = 'https://sideshift.ai/api';
static const rangePath = '/v2/pair';
static const orderPath = '/v2/shifts';
static const quotePath = '/v2/quotes';
static const permissionPath = '/v2/permissions';
@override
String get title => 'SideShift';
@override
bool get isAvailable => true;
@override
bool get isEnabled => true;
@override
bool get supportsFixedRate => true;
@override
ExchangeProviderDescription get description => ExchangeProviderDescription.sideShift;
@override
Future<bool> checkIsAvailable() async {
const url = apiBaseUrl + permissionPath;
final uri = Uri.parse(url);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode == 500) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['error']['message'] as String;
throw Exception('$error');
}
if (response.statusCode != 200) return false;
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
return responseJSON['createShift'] as bool;
}
@override
Future<Limits?> fetchLimits(
{required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode}) async {
final fromCurrency = isFixedRateMode ? to : from;
final toCurrency = isFixedRateMode ? from : to;
final fromNetwork = _networkFor(fromCurrency);
final toNetwork = _networkFor(toCurrency);
final url =
"$apiBaseUrl$rangePath/${fromCurrency.title.toLowerCase()}-$fromNetwork/${toCurrency.title.toLowerCase()}-$toNetwork";
final uri = Uri.parse(url);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode == 500) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['error']['message'] as String;
throw Exception('$error');
}
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final min = double.tryParse(responseJSON['min'] as String? ?? '');
final max = double.tryParse(responseJSON['max'] as String? ?? '');
if (isFixedRateMode) {
final currentRate = double.parse(responseJSON['rate'] as String);
return Limits(
min: min != null ? (min * currentRate) : null,
max: max != null ? (max * currentRate) : null,
);
}
return Limits(min: min, max: max);
}
@override
Future<double> fetchRate({
required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount
}) async {
try {
if (amount == 0) return 0.0;
final fromCurrency = from.title.toLowerCase();
final toCurrency = to.title.toLowerCase();
final depositNetwork = _networkFor(from);
final settleNetwork = _networkFor(to);
final url =
"$apiBaseUrl$rangePath/$fromCurrency-$depositNetwork/$toCurrency-$settleNetwork?amount=$amount";
final uri = Uri.parse(url);
final response = await ProxyWrapper().get(clearnetUri: uri);
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode == 500) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['error']['message'] as String;
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: Exception('SideShift Internal Server Error: $error'),
stackTrace: StackTrace.current,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'url': url,
},
);
throw Exception('SideShift Internal Server Error: $error');
}
if (response.statusCode != 200) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: Exception('Unexpected http status: ${response.statusCode}'),
stackTrace: StackTrace.current,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'url': url,
},
);
throw Exception('Unexpected http status: ${response.statusCode}');
}
final rate = double.parse(responseJSON['rate'] as String);
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'fetchRate',
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'url': url,
},
responseData: {
'rate': rate,
'statusCode': response.statusCode,
},
);
return rate;
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: e,
stackTrace: s,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
printV(e.toString());
return 0.00;
}
}
@override
Future<Trade> createTrade({
required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll,
}) async {
String url = '';
final body = {
'affiliateId': affiliateId,
'settleAddress': request.toAddress,
'refundAddress': request.refundAddress,
};
if (isFixedRateMode) {
final quoteId = await _createQuote(request);
body['quoteId'] = quoteId;
url = apiBaseUrl + orderPath + '/fixed';
} else {
url = apiBaseUrl + orderPath + '/variable';
body["depositCoin"] = _normalizeCurrency(request.fromCurrency);
body["settleCoin"] = _normalizeCurrency(request.toCurrency);
body["settleNetwork"] = _networkFor(request.toCurrency);
body["depositNetwork"] = _networkFor(request.fromCurrency);
}
final headers = {'Content-Type': 'application/json'};
final uri = Uri.parse(url);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(body),
);
if (response.statusCode != 201) {
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['error']['message'] as String;
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: TradeNotCreatedException(description, description: error),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'url': url,
'body': body,
},
);
throw TradeNotCreatedException(description, description: error);
}
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: TradeNotCreatedException(description),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'url': url,
'body': body,
},
);
throw TradeNotCreatedException(description);
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final id = responseJSON['id'] as String;
final inputAddress = responseJSON['depositAddress'] as String;
final settleAddress = responseJSON['settleAddress'] as String;
final depositAmount = responseJSON['depositAmount'] as String?;
final depositMemo = responseJSON['depositMemo'] as String?;
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'createTrade',
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'url': url,
'body': body,
},
responseData: {
'id': id,
'inputAddress': inputAddress,
'settleAddress': settleAddress,
'depositAmount': depositAmount,
'depositMemo': depositMemo,
'statusCode': response.statusCode,
},
);
return Trade(
id: id,
provider: description,
from: request.fromCurrency,
to: request.toCurrency,
inputAddress: inputAddress,
refundAddress: settleAddress,
state: TradeState.created,
amount: depositAmount ?? request.fromAmount,
receiveAmount: request.toAmount,
payoutAddress: settleAddress,
createdAt: DateTime.now(),
isSendAll: isSendAll,
extraId: depositMemo,
);
}
@override
Future<Trade> findTradeById({required String id}) async {
final url = apiBaseUrl + orderPath + '/' + id;
final uri = Uri.parse(url);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode == 404) {
throw TradeNotFoundException(id, provider: description);
}
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['error']['message'] as String;
throw TradeNotFoundException(id, provider: description, description: error);
}
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final fromCurrency = responseJSON['depositCoin'] as String;
final fromNetwork = responseJSON['depositNetwork'] as String?;
final toCurrency = responseJSON['settleCoin'] as String;
final toNetwork = responseJSON['settleNetwork'] as String?;
final inputAddress = responseJSON['depositAddress'] as String;
final expectedSendAmount = responseJSON['depositAmount'] as String?;
final status = responseJSON['status'] as String?;
final settleAddress = responseJSON['settleAddress'] as String;
final isVariable = (responseJSON['type'] as String) == 'variable';
final expiredAtRaw = responseJSON['expiresAt'] as String;
final expiredAt = isVariable ? null : DateTime.tryParse(expiredAtRaw)?.toLocal();
final depositMemo = responseJSON['depositMemo'] as String?;
final fromParsed = CryptoCurrency.safeParseCurrencyFromString(
fromCurrency,
tag: fromNetwork,
);
final toParsed = CryptoCurrency.safeParseCurrencyFromString(
toCurrency,
tag: toNetwork,
);
return Trade(
id: id,
from: fromParsed,
to: toParsed,
provider: description,
inputAddress: inputAddress,
amount: expectedSendAmount ?? '',
state: TradeState.deserialize(raw: status ?? 'created'),
expiredAt: expiredAt,
payoutAddress: settleAddress,
extraId: depositMemo,
);
}
Future<String> _createQuote(TradeRequest request) async {
final url = apiBaseUrl + quotePath;
final headers = {'Content-Type': 'application/json'};
final body = {
'depositCoin': _normalizeCurrency(request.fromCurrency),
'settleCoin': _normalizeCurrency(request.toCurrency),
'affiliateId': affiliateId,
'settleAmount': request.toAmount,
'settleNetwork': _networkFor(request.toCurrency),
'depositNetwork': _networkFor(request.fromCurrency),
};
final uri = Uri.parse(url);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(body),
);
if (response.statusCode != 201) {
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['error']['message'] as String;
throw TradeNotCreatedException(description, description: error);
}
throw TradeNotCreatedException(description);
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
return responseJSON['id'] as String;
}
String _normalizeCurrency(CryptoCurrency currency) {
switch (currency) {
case CryptoCurrency.usdcEPoly:
return 'usdc';
default:
return currency.title.toLowerCase();
}
}
String _networkFor(CryptoCurrency currency) =>
currency.tag != null ? _normalizeTag(currency.tag!) : 'mainnet';
String _normalizeTag(String tag) {
switch (tag) {
case 'ETH':
return 'ethereum';
case 'TRX':
return 'tron';
case 'LN':
return 'lightning';
case 'POL':
return 'polygon';
case 'ARB':
return 'arbitrum';
case 'ZEC':
return 'zcash';
case 'AVAXC':
return 'avax';
default:
return tag.toLowerCase();
}
}
String _normalizeNetworkType(String network) {
return switch (network) {
'ethereum' => 'ETH',
'tron' => 'TRX',
'lightning' => 'LN',
'polygon' => 'POL',
'arbitrum' => 'ARB',
'zcash' => 'ZEC',
'avax' => 'AVAXC',
_ => network,
};
}
}
@@ -1,371 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_not_created_exception.dart';
import 'package:hash_wallet/exchange/trade_not_found_exception.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:hash_wallet/utils/device_info.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:hash_wallet/utils/exchange_provider_logger.dart';
class SimpleSwapExchangeProvider extends ExchangeProvider {
SimpleSwapExchangeProvider();
static final apiKey =
DeviceInfo.instance.isMobile ? secrets.simpleSwapApiKey : secrets.simpleSwapApiKeyDesktop;
static const apiAuthority = 'api.simpleswap.io';
static const getEstimatePath = '/v1/get_estimated';
static const rangePath = '/v1/get_ranges';
static const getExchangePath = '/v1/get_exchange';
static const createExchangePath = '/v1/create_exchange';
@override
String get title => 'SimpleSwap';
@override
bool get isAvailable => false;
@override
bool get isEnabled => false;
@override
bool get supportsFixedRate => false;
@override
ExchangeProviderDescription get description => ExchangeProviderDescription.simpleSwap;
@override
Future<bool> checkIsAvailable() async {
final uri = Uri.https(apiAuthority, getEstimatePath, <String, String>{'api_key': apiKey});
final response = await ProxyWrapper().get(clearnetUri: uri);
return !(response.statusCode == 403);
}
@override
Future<Limits?> fetchLimits(
{required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode}) async {
final params = <String, dynamic>{
'api_key': apiKey,
'fixed': isFixedRateMode.toString(),
'currency_from': _normalizeCurrency(from),
'currency_to': _normalizeCurrency(to),
};
final uri = Uri.https(apiAuthority, rangePath, params);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode == 500) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['message'] as String;
throw Exception('$error');
}
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final min = double.tryParse(responseJSON['min'] as String? ?? '');
final max = double.tryParse(responseJSON['max'] as String? ?? '');
return Limits(min: min, max: max);
}
@override
Future<double> fetchRate({
required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount
}) async {
try {
if (amount == 0) return 0.0;
final params = {
'api_key': apiKey,
'currency_from': _normalizeCurrency(from),
'currency_to': _normalizeCurrency(to),
'amount': amount.toString(),
'fixed': isFixedRateMode.toString()
};
final uri = Uri.https(apiAuthority, getEstimatePath, params);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.body == "null") {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: Exception('Null response body'),
stackTrace: StackTrace.current,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'params': params,
'url': uri.toString(),
},
);
return 0.00;
}
final data = json.decode(response.body) as String;
final rate = double.parse(data) / amount;
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'fetchRate',
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'params': params,
'url': uri.toString(),
},
responseData: {
'data': data,
'rate': rate,
'statusCode': response.statusCode,
'responseBody': response.body,
},
);
return rate;
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: e,
stackTrace: s,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
return 0.00;
}
}
@override
Future<Trade> createTrade({
required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll,
}) async {
final headers = {'Content-Type': 'application/json'};
final params = {'api_key': apiKey};
final body = <String, dynamic>{
"currency_from": _normalizeCurrency(request.fromCurrency),
"currency_to": _normalizeCurrency(request.toCurrency),
"amount": request.fromAmount,
"fixed": isFixedRateMode,
"user_refund_address": _normalizeAddress(request.refundAddress),
"address_to": _normalizeAddress(request.toAddress)
};
final uri = Uri.https(apiAuthority, createExchangePath, params);
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: headers,
body: json.encode(body),
);
if (response.statusCode != 200 && response.statusCode != 201) {
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['message'] as String;
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: TradeNotCreatedException(description, description: error),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'url': uri.toString(),
},
);
throw TradeNotCreatedException(description, description: error);
}
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: TradeNotCreatedException(description),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'url': uri.toString(),
},
);
throw TradeNotCreatedException(description);
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final id = responseJSON['id'] as String;
final inputAddress = responseJSON['address_from'] as String;
final payoutAddress = responseJSON['address_to'] as String;
final settleAddress = responseJSON['user_refund_address'] as String;
final extraId = responseJSON['extra_id_from'] as String?;
final receiveAmount = responseJSON['amount_to'] as String?;
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'createTrade',
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'url': uri.toString(),
},
responseData: {
'id': id,
'inputAddress': inputAddress,
'payoutAddress': payoutAddress,
'settleAddress': settleAddress,
'extraId': extraId,
'receiveAmount': receiveAmount,
'statusCode': response.statusCode,
'responseJSON': responseJSON,
},
);
return Trade(
id: id,
provider: description,
from: request.fromCurrency,
to: request.toCurrency,
inputAddress: inputAddress,
refundAddress: settleAddress,
extraId: extraId,
state: TradeState.created,
amount: request.fromAmount,
receiveAmount: receiveAmount ?? request.toAmount,
payoutAddress: payoutAddress,
createdAt: DateTime.now(),
isSendAll: isSendAll,
);
}
@override
Future<Trade> findTradeById({required String id}) async {
final params = {'api_key': apiKey, 'id': id};
final uri = Uri.https(apiAuthority, getExchangePath, params);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode == 404) {
throw TradeNotFoundException(id, provider: description);
}
if (response.statusCode == 400) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['message'] as String;
throw TradeNotFoundException(id, provider: description, description: error);
}
if (response.statusCode != 200) {
throw Exception('Unexpected http status: ${response.statusCode}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final fromCurrency = responseJSON['currency_from'] as String;
final toCurrency = responseJSON['currency_to'] as String;
final inputAddress = responseJSON['address_from'] as String;
final expectedSendAmount = responseJSON['expected_amount'].toString();
final extraId = responseJSON['extra_id_from'] as String?;
final status = responseJSON['status'] as String;
final payoutAddress = responseJSON['address_to'] as String;
final fromParsed =
CryptoCurrency.safeParseCurrencyFromString(fromCurrency);
final toParsed = CryptoCurrency.safeParseCurrencyFromString(toCurrency);
return Trade(
id: id,
from: fromParsed,
to: toParsed,
extraId: extraId,
provider: description,
inputAddress: inputAddress,
amount: expectedSendAmount,
state: TradeState.deserialize(raw: status),
payoutAddress: payoutAddress,
);
}
static String _normalizeCurrency(CryptoCurrency currency) {
switch (currency) {
case CryptoCurrency.zec:
return 'zec';
case CryptoCurrency.bnb:
return currency.tag!.toLowerCase();
case CryptoCurrency.usdterc20:
return 'usdterc20';
case CryptoCurrency.usdttrc20:
return 'usdttrc20';
case CryptoCurrency.usdcpoly:
return 'usdcpoly';
case CryptoCurrency.usdtPoly:
return 'usdtpoly';
case CryptoCurrency.usdcEPoly:
return 'usdcepoly';
case CryptoCurrency.usdcsol:
return 'usdcspl';
case CryptoCurrency.matic:
return 'pol';
case CryptoCurrency.maticpoly:
return 'matic';
default:
return currency.title.toLowerCase();
}
}
String _normalizeAddress(String address) =>
address.startsWith('bitcoincash:') ? address.replaceFirst('bitcoincash:', '') : address;
}
@@ -1,448 +0,0 @@
import 'dart:convert';
import 'dart:developer';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_not_created_exception.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:hash_wallet/utils/exchange_provider_logger.dart';
class StealthExExchangeProvider extends ExchangeProvider {
StealthExExchangeProvider();
static final apiKey = secrets.stealthExBearerToken;
static final _additionalFeePercent = double.tryParse(secrets.stealthExAdditionalFeePercent);
static const _baseUrl = 'https://api.stealthex.io';
static const _rangePath = '/v4/rates/range';
static const _amountPath = '/v4/rates/estimated-amount';
static const _exchangesPath = '/v4/exchanges';
@override
String get title => 'StealthEX';
@override
bool get isAvailable => true;
@override
bool get isEnabled => true;
@override
bool get supportsFixedRate => true;
@override
ExchangeProviderDescription get description => ExchangeProviderDescription.stealthEx;
@override
Future<bool> checkIsAvailable() async => true;
@override
Future<Limits?> fetchLimits(
{required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode}) async {
final curFrom = isFixedRateMode ? to : from;
final curTo = isFixedRateMode ? from : to;
final headers = {'Authorization': apiKey, 'Content-Type': 'application/json'};
final body = {
'route': {
'from': {'symbol': _getName(curFrom), 'network': _getNetwork(curFrom)},
'to': {'symbol': _getName(curTo), 'network': _getNetwork(curTo)}
},
'estimation': isFixedRateMode ? 'reversed' : 'direct',
'rate': isFixedRateMode ? 'fixed' : 'floating',
'additional_fee_percent': _additionalFeePercent,
};
try {
final response = await ProxyWrapper().post(
clearnetUri: Uri.parse(_baseUrl + _rangePath),
headers: headers,
body: json.encode(body),
);
if (response.statusCode != 200) {
throw Exception('StealthEx fetch limits failed: ${response.body}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final min = _toDouble(responseJSON['min_amount']);
final max = _toDouble(responseJSON['max_amount']);
return Limits(min: min, max: max);
} catch (e) {
log(e.toString());
throw Exception('StealthEx failed to fetch limits');
}
}
@override
Future<double> fetchRate(
{required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount}) async {
try {
final response = await getEstimatedExchangeAmount(
from: from,
to: to,
amount: amount,
isFixedRateMode: isFixedRateMode,
);
final estimatedAmount = response['estimated_amount'] as double? ?? 0.0;
if (estimatedAmount <= 0.0) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: Exception('Invalid estimated amount: $estimatedAmount'),
stackTrace: StackTrace.current,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
return 0.0;
}
final rate = isFixedRateMode ? amount / estimatedAmount : estimatedAmount / amount;
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'fetchRate',
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
responseData: {
'estimatedAmount': estimatedAmount,
'rate': rate,
'response': response,
},
);
return rate;
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: e,
stackTrace: s,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
return 0.0;
}
}
@override
Future<Trade> createTrade(
{required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll}) async {
String? rateId;
String? validUntil;
try {
if (isFixedRateMode) {
final response = await getEstimatedExchangeAmount(
from: request.fromCurrency,
to: request.toCurrency,
amount: double.parse(request.toAmount),
isFixedRateMode: isFixedRateMode);
rateId = response['rate_id'] as String?;
validUntil = response['valid_until'] as String?;
if (rateId == null) throw TradeNotCreatedException(description);
}
final headers = {'Authorization': apiKey, 'Content-Type': 'application/json'};
final body = {
'route': {
'from': {
'symbol': _getName(request.fromCurrency),
'network': _getNetwork(request.fromCurrency)
},
'to': {'symbol': _getName(request.toCurrency), 'network': _getNetwork(request.toCurrency)}
},
'estimation': isFixedRateMode ? 'reversed' : 'direct',
'rate': isFixedRateMode ? 'fixed' : 'floating',
if (isFixedRateMode) 'rate_id': rateId,
'amount':
isFixedRateMode ? double.parse(request.toAmount) : double.parse(request.fromAmount),
'address': _normalizeAddress(request.toAddress),
'refund_address': _normalizeAddress(request.refundAddress),
'additional_fee_percent': _additionalFeePercent,
};
final response = await ProxyWrapper().post(
clearnetUri: Uri.parse(_baseUrl + _exchangesPath),
headers: headers,
body: json.encode(body),
);
if (response.statusCode != 201) {
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: Exception('StealthEx create trade failed: ${response.body}'),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'rateId': rateId,
},
);
throw Exception('StealthEx create trade failed: ${response.body}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final deposit = responseJSON['deposit'] as Map<String, dynamic>;
final withdrawal = responseJSON['withdrawal'] as Map<String, dynamic>;
final id = responseJSON['id'] as String;
final from = deposit['symbol'] as String;
final to = withdrawal['symbol'] as String;
final payoutAddress = withdrawal['address'] as String;
final depositAddress = deposit['address'] as String;
final refundAddress = responseJSON['refund_address'] as String;
final depositAmount = _toDouble(deposit['amount']);
final receiveAmount = _toDouble(withdrawal['amount']);
final status = responseJSON['status'] as String;
final createdAtString = responseJSON['created_at'] as String;
final extraId = deposit['extra_id'] as String?;
final createdAt = DateTime.parse(createdAtString).toLocal();
final expiredAt = validUntil != null
? DateTime.parse(validUntil).toLocal()
: DateTime.now().add(Duration(minutes: 5));
CryptoCurrency fromCurrency;
if (request.fromCurrency.tag != null && request.fromCurrency.title.toLowerCase() == from) {
fromCurrency = request.fromCurrency;
} else {
fromCurrency = CryptoCurrency.fromString(from);
}
CryptoCurrency toCurrency;
if (request.toCurrency.tag != null && request.toCurrency.title.toLowerCase() == to) {
toCurrency = request.toCurrency;
} else {
toCurrency = CryptoCurrency.fromString(to);
}
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'createTrade',
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'rateId': rateId,
},
responseData: {
'id': id,
'from': from,
'to': to,
'depositAddress': depositAddress,
'payoutAddress': payoutAddress,
'refundAddress': refundAddress,
'depositAmount': depositAmount,
'receiveAmount': receiveAmount,
'status': status,
'createdAt': createdAtString,
'extraId': extraId,
'statusCode': response.statusCode,
'responseJSON': responseJSON,
},
);
return Trade(
id: id,
from: fromCurrency,
to: toCurrency,
provider: description,
inputAddress: depositAddress,
payoutAddress: payoutAddress,
refundAddress: refundAddress,
amount: depositAmount.toString(),
receiveAmount: receiveAmount.toString(),
state: TradeState.deserialize(raw: status),
createdAt: createdAt,
expiredAt: expiredAt,
extraId: extraId,
isSendAll: isSendAll,
);
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: e,
stackTrace: s,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
},
);
log(e.toString());
throw TradeNotCreatedException(description);
}
}
@override
Future<Trade> findTradeById({required String id}) async {
final headers = {'Authorization': apiKey, 'Content-Type': 'application/json'};
final uri = Uri.parse('$_baseUrl$_exchangesPath/$id');
final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers);
if (response.statusCode != 200) {
throw Exception('StealthEx fetch trade failed: ${response.body}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final deposit = responseJSON['deposit'] as Map<String, dynamic>;
final withdrawal = responseJSON['withdrawal'] as Map<String, dynamic>;
final respId = responseJSON['id'] as String;
// Parsing 'from' currency with network tag
final fromCurrency = deposit['symbol'] as String;
final fromNetwork = deposit['network'] as String?;
final fromTag = fromNetwork == 'mainnet' ? null : fromNetwork;
final from = CryptoCurrency.safeParseCurrencyFromString(fromCurrency, tag: fromTag);
// Parsing 'to' currency with network tag
final toCurrency = withdrawal['symbol'] as String;
final toNetwork = withdrawal['network'] as String?;
final toTag = toNetwork == 'mainnet' ? null : toNetwork;
final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency, tag: toTag);
final payoutAddress = withdrawal['address'] as String;
final depositAddress = deposit['address'] as String;
final refundAddress = responseJSON['refund_address'] as String;
final depositAmount = _toDouble(deposit['amount']);
final receiveAmount = _toDouble(withdrawal['amount']);
final status = responseJSON['status'] as String;
final createdAtString = responseJSON['created_at'] as String;
final createdAt = DateTime.parse(createdAtString).toLocal();
final extraId = deposit['extra_id'] as String?;
return Trade(
id: respId,
from: from,
to: to,
provider: description,
inputAddress: depositAddress,
payoutAddress: payoutAddress,
refundAddress: refundAddress,
amount: depositAmount.toString(),
receiveAmount: receiveAmount.toString(),
state: TradeState.deserialize(raw: status),
createdAt: createdAt,
isRefund: status == 'refunded',
extraId: extraId,
);
}
Future<Map<String, dynamic>> getEstimatedExchangeAmount(
{required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode}) async {
final headers = {'Authorization': apiKey, 'Content-Type': 'application/json'};
final body = {
'route': {
'from': {'symbol': _getName(from), 'network': _getNetwork(from)},
'to': {'symbol': _getName(to), 'network': _getNetwork(to)}
},
'estimation': isFixedRateMode ? 'reversed' : 'direct',
'rate': isFixedRateMode ? 'fixed' : 'floating',
'amount': amount,
'additional_fee_percent': _additionalFeePercent,
};
try {
final response = await ProxyWrapper().post(
clearnetUri: Uri.parse(_baseUrl + _amountPath),
headers: headers,
body: json.encode(body),
);
if (response.statusCode != 200) return {};
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final rate = responseJSON['rate'] as Map<String, dynamic>?;
return {
'estimated_amount': responseJSON['estimated_amount'] as double?,
if (rate != null) 'valid_until': rate['valid_until'] as String?,
if (rate != null) 'rate_id': rate['id'] as String?
};
} catch (e) {
log(e.toString());
return {};
}
}
static double? _toDouble(dynamic value) {
if (value is int) {
return value.toDouble();
} else if (value is double) {
return value;
} else if (value is String) {
return double.tryParse(value);
}
return null;
}
String _getName(CryptoCurrency currency) {
if (currency == CryptoCurrency.usdcEPoly) return 'usdce';
return currency.title.toLowerCase();
}
String _getNetwork(CryptoCurrency currency) {
if (currency == CryptoCurrency.arb || currency.tag == 'ARB') return 'arbitrum';
if (currency.tag == null) return 'mainnet';
if (currency == CryptoCurrency.maticpoly) return 'mainnet';
if (currency.tag == 'POLY') return 'matic';
return currency.tag!.toLowerCase();
}
String _normalizeAddress(String address) =>
address.startsWith('bitcoincash:') ? address.replaceFirst('bitcoincash:', '') : address;
}
@@ -1,865 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_not_created_exception.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:cw_core/amount_converter.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/crypto_currency.dart';
class SwapsXyzExchangeProvider extends ExchangeProvider {
SwapsXyzExchangeProvider();
static final List<CryptoCurrency> _notSupportedAsSourceToken = [
CryptoCurrency.sol,
...CryptoCurrency.all.where(
(c) => (c.tag ?? '').toUpperCase() == 'SOL' || c.tag == CryptoCurrency.bnb.tag,
),
];
static const _transferSig = '0xa9059cbb';
static const _swapAndExecuteSig = '0x9be111d1';
static final _apiKey = secrets.swapsXyzApiKey;
static const _baseUrl = 'api-v2.swaps.xyz';
static const _getChainList = 'api/getChainList';
static const _getPaths = 'api/getPaths';
static const _getQuotePaths = 'api/getQuote';
static const _getAction = 'api/getAction';
static const _registerTxs = 'api/registerTxs';
static const _getStatus = 'api/getStatus';
static final _headers = {'x-api-key': _apiKey};
static final _supportedChainList = <Chain>[];
final Map<int, List<TokenPathInfo>> _tokensCache = {};
@override
String get title => 'Swaps.XYZ';
@override
bool get isAvailable => true;
@override
bool get isEnabled => true;
// There is an issue with fixed-rate swaps on Swaps.XYZ
// when they don't guarantee that the trade will be created at the requested amount.
@override
bool get supportsFixedRate => false;
@override
ExchangeProviderDescription get description =>
ExchangeProviderDescription.swapsXyz;
@override
Future<bool> checkIsAvailable() async => true;
@override
Future<Limits?> fetchLimits({
required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode,
}) async {
try {
final chains = await _geSupportedChain();
if (chains.isEmpty) throw Exception('Failed to fetch supported chains');
final fromToUse = isFixedRateMode ? to : from;
final toToUse = isFixedRateMode ? from : to;
final srcChain = _findChainByCurrency(fromToUse, chains);
final dstChain = _findChainByCurrency(toToUse, chains);
await _ensureTokensCached(
fromChain: srcChain, toChain: dstChain, from: fromToUse, to: toToUse);
final srcToken = _getTokenAddress(currency: fromToUse, chain: srcChain);
final dstToken = _getTokenAddress(currency: toToUse, chain: dstChain);
final params = {
'srcChainId': '${srcChain.chainId}',
'srcToken': srcToken,
'dstChainId': '${dstChain.chainId}',
'dstToken': dstToken,
};
final uri = Uri.https(_baseUrl, _getPaths, params);
final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
if (res.statusCode != 200) {
throw Exception('Unexpected http status: ${res.statusCode}');
}
final body = json.decode(res.body) as Map<String, dynamic>;
final paths =
(body['paths'] as List? ?? const []).cast<Map<String, dynamic>>();
if (paths.isEmpty) {
throw Exception('No paths for ${fromToUse.title} -> ${toToUse.title}');
}
final int requestedDstId = dstChain.chainId;
Map<String, dynamic> path = paths.firstWhere(
(p) => p['chainId'] == requestedDstId,
orElse: () => <String, dynamic>{},
);
if (path.isEmpty) {
path = paths.firstWhere(
(p) => (p['tokens'] is List) || p['amountLimits'] != null,
orElse: () => paths.first,
);
}
final supportsExactAmountIn =
path['supportsExactAmountIn'] as bool? ?? false;
final supportsExactAmountOut =
path['supportsExactAmountOut'] as bool? ?? false;
if (isFixedRateMode && !supportsExactAmountOut) {
throw Exception(
'This route does not support fixed receive (exact-amount-out)');
}
if (!isFixedRateMode && !supportsExactAmountIn) {
throw Exception(
'This route does not support exact send (exact-amount-in)');
}
Map<String, dynamic>? useLimits;
if (isFixedRateMode) {
final tokensField = path['tokens'];
useLimits = null;
if (tokensField is List && tokensField.isNotEmpty) {
final tokens = tokensField.cast<Map<String, dynamic>>();
String norm(String s) => s.toUpperCase();
final wantSym = norm(_normalizeCakeNativeTokenName(toToUse.title));
final wantAddr = (dstToken).toLowerCase();
final match = tokens.firstWhere(
(t) {
final sym = norm(t['symbol']?.toString() ?? '');
final addr = (t['address']?.toString() ?? '').toLowerCase();
return sym == wantSym || (addr.isNotEmpty && addr == wantAddr);
},
orElse: () => const <String, dynamic>{},
);
if (match.isNotEmpty) {
useLimits = {
'minAmount': match['minAmount'],
'maxAmount': match['maxAmount'],
};
}
}
} else {
// Floating/Exact-in: use the route-level limits
useLimits = path['amountLimits'] as Map<String, dynamic>?;
}
final min = double.tryParse((useLimits?['minAmount'])?.toString() ?? '');
final max = double.tryParse((useLimits?['maxAmount'])?.toString() ?? '');
return Limits(min: min, max: max);
} catch (e) {
printV('fetchLimits error: $e');
throw Exception('Error fetching limits: $e');
}
}
Future<_PathInfo?> _pickPath({
required int srcChainId,
required String srcToken,
required int dstChainId,
required String dstToken,
}) async {
final uri = Uri.https(_baseUrl, _getPaths, {
'srcChainId': '$srcChainId',
'srcToken': srcToken,
'dstChainId': '$dstChainId',
'dstToken': dstToken,
});
final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
if (res.statusCode != 200) return null;
final body = json.decode(res.body) as Map<String, dynamic>;
final paths =
(body['paths'] as List?)?.cast<Map<String, dynamic>>() ?? const [];
if (paths.isEmpty) return null;
final p = paths.first;
return _PathInfo(
supportsExactOut: p['supportsExactAmountOut'] == true,
minToAmountHuman: (p['amountLimits']?['minAmount'] as String?) ?? '0',
);
}
@override
Future<double> fetchRate(
{required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount}) async {
try {
if(_notSupportedAsSourceToken.contains(from) || _notSupportedAsSourceToken.contains(to)) {
printV(
'fetchRate: source token ${from.title} is not supported as source token');
return 0.0;
}
final chains = await _geSupportedChain();
if (chains.isEmpty) return 0.0;
final srcChain = _findChainByCurrency(from, chains);
final dstChain = _findChainByCurrency(to, chains);
await _ensureTokensCached(
fromChain: srcChain, toChain: dstChain, from: from, to: to);
final srcToken = _getTokenAddress(currency: from, chain: srcChain);
final dstToken = _getTokenAddress(currency: to, chain: dstChain);
if (isReceiveAmount) {
final path = await _pickPath(
srcChainId: srcChain.chainId,
srcToken: srcToken,
dstChainId: dstChain.chainId,
dstToken: dstToken,
);
if (path == null || !path.supportsExactOut) {
printV(
'fetchRate: route does not support exact-amount-out for ${from.title} -> ${to.title}');
return 0.0;
}
}
final humanAmountStr = amount.toString();
final formattedAmount = AmountConverter.toBaseUnits(
humanAmountStr,
isReceiveAmount ? to.decimals : from.decimals,
);
final params = {
'swapDirection':
isReceiveAmount ? 'exact-amount-out' : 'exact-amount-in',
'srcToken': srcToken,
'dstToken': dstToken,
'srcChainId': '${srcChain.chainId}',
'dstChainId': '${dstChain.chainId}',
'amount': formattedAmount,
};
final uri = Uri.https(_baseUrl, _getQuotePaths, params);
final response =
await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
if (response.statusCode != 200) {
printV('fetchRate failed: ${response.body}');
return 0.0;
}
final data = json.decode(response.body) as Map<String, dynamic>;
final exchangeRate = (data['exchangeRate'] as num?)?.toDouble() ?? 0.0;
return exchangeRate;
} catch (e) {
printV('fetchRate error: $e');
return 0.0;
}
}
@override
Future<Trade> createTrade({
required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll,
}) async {
try {
final sender = request.refundAddress.trim();
final recipient = request.toAddress.trim();
if (sender.isEmpty || recipient.isEmpty) {
throw Exception(
'Sender (refundAddress) or recipient (toAddress) is empty');
}
final chains = await _geSupportedChain();
if (chains.isEmpty) throw Exception('Failed to fetch supported chains');
final srcChain = _findChainByCurrency(request.fromCurrency, chains);
final dstChain = _findChainByCurrency(request.toCurrency, chains);
await _ensureTokensCached(
fromChain: srcChain,
toChain: dstChain,
from: request.fromCurrency,
to: request.toCurrency,
);
final srcToken =
_getTokenAddress(currency: request.fromCurrency, chain: srcChain);
final dstToken =
_getTokenAddress(currency: request.toCurrency, chain: dstChain);
final amountStr = isFixedRateMode ? request.toAmount : request.fromAmount;
final rawAmount = double.tryParse(amountStr) ?? 0.0;
if (rawAmount <= 0) throw Exception('Invalid amount');
final formattedAmount = AmountConverter.toBaseUnits(
amountStr,
isFixedRateMode
? request.toCurrency.decimals
: request.fromCurrency.decimals,
);
final params = {
'actionType': 'swap-action',
'sender': sender,
'srcChainId': '${srcChain.chainId}',
'srcToken': srcToken,
'dstChainId': '${dstChain.chainId}',
'dstToken': dstToken,
'slippage': '300',
'swapDirection':
isFixedRateMode ? 'exact-amount-out' : 'exact-amount-in',
'amount': formattedAmount,
'recipient': recipient,
};
final uri = Uri.https(_baseUrl, _getAction, params);
final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
if (res.statusCode != 200) {
throw Exception('getAction failed: ${res.statusCode} ${res.body}');
}
final data = json.decode(res.body) as Map<String, dynamic>;
final txId = data['txId'] as String? ?? '';
final vmId = data['vmId'] as String? ?? '';
final txObj = (data['tx'] as Map?) ?? const {};
final txTo = txObj['to']?.toString();
final chainId = txObj['chainId']?.toString();
final routerData = txObj['data']?.toString();
// Allow only:
// - null (native / deposit-address flow)
// - '0x' (no call data)
// - ERC20 transfer(0xa9059cbb) selector
// - swapAndExecute(0x9be111d1) selector
final isAllowed = routerData == null ||
routerData == '0x' ||
_decodeMethodSelector(routerData) == _transferSig ||
_decodeMethodSelector(routerData) == _swapAndExecuteSig;
if (!isAllowed) {
throw Exception('Does not support that method selector');
}
final txValue = txObj['value']?.toString() ?? '0';
final bridgeIds = (data['bridgeIds'] as List?) ?? const [];
if (txId.isEmpty) throw Exception('No txId returned by getAction');
final amtIn = (data['amountIn'] as Map?) ?? const {};
final amtInMax = (data['amountInMax'] as Map?) ?? const {};
final srcTokenAddr = amtIn['address']?.toString();
final srcTokenDecs =
(amtIn['decimals'] as num?)?.toInt() ?? request.fromCurrency.decimals;
final requiresTokenApproval =
data['requiresTokenApproval'] as bool? ?? false;
final reqAmountStr =
(amtInMax['amount'] ?? amtIn['amount'])?.toString() ?? '0';
final reqAmountRaw = reqAmountStr.replaceAll('n', '');
final needToRegisterInSwapXyz =
vmId == 'alt-vm' || bridgeIds.contains('alt-vm')
|| chainId == 'solana' || bridgeIds.contains('solana');
final trade = Trade(
id: txId,
router: chainId,
providerId: vmId,
from: request.fromCurrency,
to: request.toCurrency,
provider: description,
inputAddress: txTo,
refundAddress: request.refundAddress,
state: TradeState.created,
providerName: title,
createdAt: DateTime.now(),
amount: request.fromAmount,
receiveAmount: request.toAmount,
payoutAddress: request.toAddress,
isSendAll: isSendAll,
needToRegisterInSwapXyz: needToRegisterInSwapXyz,
sourceTokenAddress: srcTokenAddr ?? srcToken,
sourceTokenDecimals: srcTokenDecs,
sourceTokenAmountRaw: reqAmountRaw,
requiresTokenApproval: requiresTokenApproval,
routerData: routerData,
routerValue: txValue,
);
return trade;
} catch (e) {
printV('createTrade error: $e');
throw TradeNotCreatedException(description, description: e.toString());
}
}
/// Register a broadcasted tx with Swaps.xyz (required for alt-vm).
static Future<bool> registerAltVmTx({
required String txId,
required String txHash,
required int chainId,
required String vmId,
}) async {
try {
final uri = Uri.https(_baseUrl, _registerTxs);
final payload = {
'txId': txId,
'vmId': vmId,
'txHash': txHash,
'chainId': chainId,
};
final res = await ProxyWrapper().post(
clearnetUri: uri,
headers: {
..._headers,
'content-type': 'application/json',
},
body: jsonEncode(payload),
);
if (res.statusCode != 200) {
printV('registerTxs failed: ${res.statusCode} ${res.body}');
return false;
}
final List<dynamic> body = json.decode(res.body) as List<dynamic>;
if (body.isEmpty) return false;
final isSuccess =
(body[0] as Map<String, dynamic>)['success'] as bool? ?? false;
return isSuccess;
} catch (e) {
printV('registerAltVmTx error: $e');
return false;
}
}
@override
Future<Trade> findTradeById({required String id}) async {
if (id.isEmpty) {
throw Exception('Trade id is empty');
}
final uri = Uri.https(_baseUrl, _getStatus, {'txId': id});
final resp = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
if (resp.statusCode != 200) {
throw Exception('getStatus failed: ${resp.statusCode} ${resp.body}');
}
final data = json.decode(resp.body) as Map<String, dynamic>;
final isSuccess = (data['success'] as bool?);
if (isSuccess != null && !isSuccess) {
final error = data['error'] as Map<String, dynamic>?;
if (error != null) {
final code = error['code']?.toString() ?? 'unknown';
throw Exception(
'SwapXyzExchangeProvider findTradeById error: ($id) $code');
}
}
final statusStr = (data['status'] as String?)?.toLowerCase() ?? 'NOT_FOUND';
final state = _mapSwapsStatusToTradeState(statusStr);
final refundAddress = data['sender']?.toString();
final srcTransaction = (data['srcTx'] as Map?)?.cast<String, dynamic>();
final dstTransaction = (data['dstTx'] as Map?)?.cast<String, dynamic>();
final inputAddress = srcTransaction?['toAddress']?.toString();
final payoutAddress = dstTransaction?['toAddress']?.toString();
final srcPaymentToken =
(srcTransaction?['paymentToken'] as Map?)?.cast<String, dynamic>();
final dstPaymentToken =
(dstTransaction?['paymentToken'] as Map?)?.cast<String, dynamic>();
final fromSymbol = (srcPaymentToken?['symbol'] as String?) ?? '';
final toSymbol = (dstPaymentToken?['symbol'] as String?) ?? '';
CryptoCurrency? toCurrency;
if (toSymbol.isNotEmpty) {
toCurrency = CryptoCurrency.safeParseCurrencyFromString(toSymbol);
}
final srcDecimals = (srcPaymentToken?['decimals'] as num?)?.toInt() ?? 0;
final dstDecimals = (dstPaymentToken?['decimals'] as num?)?.toInt() ?? 0;
final txHash = srcTransaction?['txHash'] as String?;
// Minimal-unit amounts like "12000n"
final srcAmountRaw = srcPaymentToken?['amount']?.toString();
final dstAmountRaw = dstPaymentToken?['amount']?.toString();
String? receiveAmount;
if (dstAmountRaw != null) {
final dstAmountMinimal = _stripN(dstAmountRaw);
receiveAmount =
AmountConverter.fromBaseUnits(dstAmountMinimal, dstDecimals);
}
final srcAmountMinimal = _stripN(srcAmountRaw);
final amount = AmountConverter.fromBaseUnits(srcAmountMinimal, srcDecimals);
final fromCurrency = CryptoCurrency.safeParseCurrencyFromString(fromSymbol);
// Timestamps can be num or "123n" handle both
final srcTs = _parseUnixSeconds(srcTransaction?['timestamp']);
final dstTs = _parseUnixSeconds(dstTransaction?['timestamp']);
final timestamp = srcTs ?? dstTs;
final createdAt = timestamp != null
? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000, isUtc: true)
.toLocal()
: null;
return Trade(
id: (data['txId'] as String?) ?? id,
from: fromCurrency,
to: toCurrency,
provider: description,
inputAddress: inputAddress,
payoutAddress: payoutAddress,
amount: amount,
receiveAmount: receiveAmount,
txId: txHash,
state: state,
createdAt: createdAt,
refundAddress: refundAddress,
);
}
TradeState _mapSwapsStatusToTradeState(String s) {
switch (s) {
case 'pending':
case 'processing':
return TradeState.pending;
case 'success':
case 'completed':
case 'complete':
return TradeState.finished;
case 'failed':
case 'cancelled':
case 'canceled':
case 'error':
return TradeState.failed;
default:
return TradeState.pending;
}
}
// Load & cache supported chains once
Future<List<Chain>> _geSupportedChain() async {
if (_supportedChainList.isNotEmpty) return _supportedChainList;
try {
final uri = Uri.https(_baseUrl, _getChainList);
final response =
await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
if (response.statusCode != 200) return [];
final data = json.decode(response.body) as List<dynamic>;
_supportedChainList
..clear()
..addAll(data.map((e) => Chain.fromJson(e as Map<String, dynamic>)));
return _supportedChainList;
} catch (e) {
printV(e);
return [];
}
}
Future<void> _ensureTokensCached({
required Chain fromChain,
required Chain toChain,
required CryptoCurrency from,
required CryptoCurrency to,
}) async {
final needSrc = !_tokensCache.containsKey(fromChain.chainId) ||
(_tokensCache[fromChain.chainId]?.isEmpty ?? true);
final needDst = !_tokensCache.containsKey(toChain.chainId) ||
(_tokensCache[toChain.chainId]?.isEmpty ?? true);
if (!needSrc && !needDst) return;
if (needSrc) {
await _fetchAndCacheTokens(srcChainId: fromChain.chainId);
}
if (needDst) {
await _fetchAndCacheTokens(srcChainId: toChain.chainId);
}
}
// call getPaths and merge tokens into cache keyed by the chainId
Future<void> _fetchAndCacheTokens({
required int srcChainId,
}) async {
final params = <String, String>{
'srcChainId': '$srcChainId',
'srcToken': '0x0000000000000000000000000000000000000000',
// Native placeholder
};
final uri = Uri.https(_baseUrl, _getPaths, params);
final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
if (res.statusCode != 200) {
printV('getPaths failed: ${res.statusCode} ${res.body}');
return;
}
Map<String, dynamic> body;
try {
body = json.decode(res.body) as Map<String, dynamic>;
} catch (e) {
printV('getPaths JSON decode error: $e');
return;
}
// Always cache the source chain's native token (from body['srcToken'])
final srcTokenJson = body['srcToken'] as Map<String, dynamic>?;
if (srcTokenJson != null) {
final symbol = (srcTokenJson['symbol'] as String? ?? '').toUpperCase();
if (symbol.isNotEmpty) {
final isNative = srcTokenJson['isNative'] == true;
final decimals = (srcTokenJson['decimals'] as num?)?.toInt();
// Treat native token as address = null so _getTokenAddress() emits zero-address
final addr = isNative ? null : (srcTokenJson['address'] as String?);
_mergeCache(srcChainId, [
TokenPathInfo(
symbol: symbol,
address: addr,
decimals: decimals,
minAmount: srcTokenJson['minAmount']?.toString(),
maxAmount: srcTokenJson['maxAmount']?.toString(),
),
]);
}
}
final paths = (body['paths'] as List?) ?? const [];
if (paths.isEmpty) return;
for (final path in paths) {
final map = path as Map<String, dynamic>;
final pathChainId = (map['chainId'] as num?)?.toInt();
if (pathChainId == null) continue;
final tokensField = map['tokens'];
// Case 1: String "all" -> cache empty list to indicate all tokens supported
if (tokensField is String) {
if (tokensField.toLowerCase() == 'all') {
_tokensCache[pathChainId] =
_tokensCache[pathChainId] ?? <TokenPathInfo>[];
}
continue;
}
// Case 2: List -> parse and merge
if (tokensField is List) {
final parsed = <TokenPathInfo>[];
for (final token in tokensField) {
if (token is Map<String, dynamic>) {
try {
parsed.add(TokenPathInfo.fromJson(token));
} catch (e) {
printV('Token parse error on chain $pathChainId: $e : $token');
}
}
}
if (parsed.isNotEmpty) {
_mergeCache(pathChainId, parsed);
}
}
}
}
// Merge by symbol, prefer entries that have a non-empty address/decimals
void _mergeCache(int chainId, List<TokenPathInfo> incoming) {
final existing = _tokensCache[chainId] ?? const <TokenPathInfo>[];
final bySymbol = <String, TokenPathInfo>{
for (final t in existing) t.symbol: t
};
for (final t in incoming) {
final cur = bySymbol[t.symbol];
if (cur == null) {
bySymbol[t.symbol] = t;
} else {
// If incoming has a real address/decimals, prefer it
final hasBetterAddr = (t.address != null && t.address!.isNotEmpty) &&
(cur.address == null || cur.address!.isEmpty);
final hasBetterDec = (t.decimals != null) && (cur.decimals == null);
if (hasBetterAddr || hasBetterDec) {
bySymbol[t.symbol] = t;
}
}
}
_tokensCache[chainId] = bySymbol.values.toList();
}
String _normalizeCakeNativeTokenName(String title) {
final name = title.toUpperCase();
return switch (name) {
'ZZEC' => 'ZEC',
_ => name,
};
}
String _getTokenAddress({
required CryptoCurrency currency,
required Chain chain,
}) {
final symbol = _normalizeCakeNativeTokenName(currency.title);
final list = _tokensCache[chain.chainId];
// Try cache hit
if (list != null && list.isNotEmpty) {
for (final t in list) {
if (t.symbol == symbol && t.address != null && t.address!.isNotEmpty) {
return t.address!;
} else if (t.symbol == symbol && (t.address == null)) {
// Native token on this chain
return '0x0000000000000000000000000000000000000000';
}
}
}
// May fail for non-native Alt-VM assets
return symbol;
}
Map<String, dynamic>? findTokenBySymbol(
{required String title, required List<dynamic> tokens}) {
final reqSymbol = title.toUpperCase();
for (final token in tokens) {
final map = token as Map<String, dynamic>;
final symbol = (map['symbol'] as String?)?.toUpperCase();
if (symbol == reqSymbol) return map;
}
return null;
}
Chain _findChainByCurrency(CryptoCurrency cur, List<Chain> chains) {
final network = _normalizeCakeNetwork(cur.tag ?? cur.title);
return chains.firstWhere(
(c) {
return c.name.toUpperCase() == network;
},
orElse: () => throw Exception('Unsupported chain for ${cur.title}'),
);
}
String _normalizeCakeNetwork(String network) {
return switch (network.toUpperCase()) {
'ETH' => 'ETHEREUM',
'BSC' => 'BNB SMART CHAIN',
'POL' => 'POLYGON',
'AVAXC' => 'AVALANCHE',
'TRX' => 'TRON',
'SOL' => 'SOLANA',
'CRO' => 'CRONOS',
'ADA' => 'CARDANO',
'KAS' => 'KASPA',
'TON' => 'TONCOIN',
'BCH' => 'BITCOIN CASH',
'ARB' => 'ARBITRUM',
_ => network.toUpperCase(),
};
}
int? _parseUnixSeconds(dynamic value) {
if (value == null) return null;
if (value is num) return value.toInt();
if (value is String) {
final clean = _stripN(value);
return int.tryParse(clean);
}
return null;
}
String _stripN(String? str) {
final s = str ?? '0';
return s.endsWith('n') ? s.substring(0, s.length - 1) : s;
}
String _decodeMethodSelector(String s) =>
(s.startsWith('0x') && s.length >= 10) ? s.substring(0, 10) : '';
}
class TokenPathInfo {
final String symbol;
final String? address;
final int? decimals;
final String? minAmount;
final String? maxAmount;
TokenPathInfo({
required this.symbol,
required this.address,
required this.decimals,
required this.minAmount,
required this.maxAmount,
});
factory TokenPathInfo.fromJson(Map<String, dynamic> json) => TokenPathInfo(
symbol: (json['symbol'] as String?)?.toUpperCase() ?? '',
address: json['address'] as String?,
decimals: json['decimals'] as int?,
minAmount: json['minAmount']?.toString(),
maxAmount: json['maxAmount']?.toString(),
);
}
class Chain {
final int chainId;
final String name;
final String vmId;
Chain({
required this.chainId,
required this.name,
required this.vmId,
});
factory Chain.fromJson(Map<String, dynamic> json) {
return Chain(
chainId: json['chainId'] as int,
name: json['name'] as String,
vmId: json['vmId'] as String,
);
}
}
class _PathInfo {
final bool supportsExactOut;
final String minToAmountHuman;
_PathInfo({required this.supportsExactOut, required this.minToAmountHuman});
}
@@ -1,411 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/.secrets.g.dart' as secrets;
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_not_created_exception.dart';
import 'package:hash_wallet/exchange/trade_not_found_exception.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:hash_wallet/utils/exchange_provider_logger.dart';
class SwapTradeExchangeProvider extends ExchangeProvider {
SwapTradeExchangeProvider();
static final markup = secrets.swapTradeExchangeMarkup;
static const apiAuthority = 'api.swaptrade.io';
static const getRate = '/api/swap/get-rate';
static const getCoins = '/api/swap/get-coins';
static const createOrder = '/api/swap/create-order';
static const order = '/api/swap/order';
@override
String get title => 'SwapTrade';
@override
bool get isAvailable => true;
@override
bool get isEnabled => true;
@override
bool get supportsFixedRate => false;
@override
ExchangeProviderDescription get description => ExchangeProviderDescription.swapTrade;
static const _headers = <String, String>{'Content-Type': 'application/json'};
@override
Future<bool> checkIsAvailable() async => true;
@override
Future<Limits?> fetchLimits({
required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode,
}) async {
try {
final uri = Uri.https(apiAuthority, getCoins);
final response = await ProxyWrapper().get(clearnetUri: uri);
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode != 200)
throw Exception('Unexpected http status: ${response.statusCode}');
final coinsInfoRaw = responseJSON['data'];
final coinsInfo = coinsInfoRaw is List<dynamic> ? coinsInfoRaw : <dynamic>[];
final normalized = _normalizeCurrency(from);
final coin = coinsInfo.cast<Map<String, dynamic>>().firstWhere(
(c) => (c['id']?.toString().toUpperCase() ?? '') == normalized,
orElse: () => <String, dynamic>{},
);
if (coin.isEmpty) {
// Currency not supported by SwapTrade (e.g. USDC, DOGE).
return null;
}
final min = double.tryParse(coin['min']?.toString() ?? '') ?? 0.0;
final max = double.tryParse(coin['max']?.toString() ?? '') ?? 0.0;
if (max == 0) return null;
return Limits(min: min, max: max);
} catch (e) {
printV(e.toString());
return null;
}
}
@override
Future<double> fetchRate({
required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount
}) async {
try {
if (amount == 0) return 0.0;
if (isFixedRateMode && !supportsFixedRate) {
return 0.0;
}
if(from == CryptoCurrency.btcln || to == CryptoCurrency.btcln) return 0;
final params = <String, dynamic>{};
final body = <String, String>{
'coin_send': _normalizeCurrency(from),
'coin_receive': _normalizeCurrency(to),
'amount': amount.toString(),
'ref': 'cake',
};
final uri = Uri.https(apiAuthority, getRate, params);
final response = await ProxyWrapper().post(
clearnetUri: uri,
body: json.encode(body),
headers: _headers,
);
final responseBody = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode != 200) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: Exception('Unexpected http status: ${response.statusCode}'),
stackTrace: StackTrace.current,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'body': body,
'url': uri.toString(),
},
);
throw Exception('Unexpected http status: ${response.statusCode}');
}
final data = responseBody['data'] as Map<String, dynamic>;
double rate = double.parse(data['price'].toString());
final calculatedRate = rate > 0 ? isFixedRateMode ? amount / rate : rate / amount : 0.0;
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'fetchRate',
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'body': body,
'url': uri.toString(),
},
responseData: {
'rate': rate,
'calculatedRate': calculatedRate,
'statusCode': response.statusCode,
'responseBody': responseBody,
},
);
return calculatedRate;
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: e,
stackTrace: s,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
printV("error fetching rate: ${e.toString()}");
return 0.0;
}
}
@override
Future<Trade> createTrade({
required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll,
}) async {
try {
final params = <String, dynamic>{};
var body = <String, dynamic>{
'coin_send': _normalizeCurrency(request.fromCurrency),
'coin_send_network': _networkFor(request.fromCurrency),
'coin_receive': _normalizeCurrency(request.toCurrency),
'coin_receive_network': _networkFor(request.toCurrency),
'amount_send': request.fromAmount,
'recipient': request.toAddress,
'ref': 'cake',
'markup': double.tryParse(markup.toString()) ?? 0,
'refund_address': request.refundAddress,
};
final uri = Uri.https(apiAuthority, createOrder, params);
final response = await ProxyWrapper().post(
clearnetUri: uri,
body: json.encode(body),
headers: _headers,
);
final responseBody = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode == 400 || responseBody["success"] == false) {
final List<dynamic> errorsList = responseBody['errors'] as List? ?? [];
final error = errorsList.isNotEmpty
? (errorsList[0]['msg'] as String?) ?? responseBody.toString()
: responseBody.toString();
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: TradeNotCreatedException(description, description: error),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'url': uri.toString(),
},
);
throw TradeNotCreatedException(description, description: error);
}
if (response.statusCode != 200) {
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: Exception('Unexpected http status: ${response.statusCode}'),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'url': uri.toString(),
},
);
throw Exception('Unexpected http status: ${response.statusCode}');
}
final responseData = responseBody['data'] as Map<String, dynamic>;
final receiveAmount = responseData["amount_receive"]?.toString();
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'createTrade',
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'body': body,
'url': uri.toString(),
},
responseData: {
'id': responseData["order_id"] as String,
'inputAddress': responseData["server_address"] as String,
'receiveAmount': receiveAmount,
'statusCode': response.statusCode,
'responseBody': responseBody,
},
);
return Trade(
id: responseData["order_id"] as String,
inputAddress: responseData["server_address"] as String,
amount: request.fromAmount,
receiveAmount: receiveAmount ?? request.toAmount,
from: request.fromCurrency,
to: request.toCurrency,
provider: description,
createdAt: DateTime.now(),
state: TradeState.created,
payoutAddress: request.toAddress,
isSendAll: isSendAll,
);
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: e,
stackTrace: s,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
},
);
printV("error creating trade: ${e.toString()}");
throw TradeNotCreatedException(description, description: e.toString());
}
}
@override
Future<Trade> findTradeById({required String id}) async {
try {
final params = <String, dynamic>{};
var body = <String, dynamic>{
'order_id': id,
};
final uri = Uri.https(apiAuthority, order, params);
final response = await ProxyWrapper().post(
clearnetUri: uri,
body: json.encode(body),
headers: _headers,
);
final responseBody = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode == 400 || responseBody["success"] == false) {
final error = responseBody['errors'][0]['msg'] as String;
throw TradeNotCreatedException(description, description: error);
}
if (response.statusCode != 200)
throw Exception('Unexpected http status: ${response.statusCode}');
final responseData = responseBody['data'] as Map<String, dynamic>;
final fromCurrency = responseData['coin_send'] as String;
final from = CryptoCurrency.safeParseCurrencyFromString(fromCurrency);
final toCurrency = responseData['coin_receive'] as String;
final to = CryptoCurrency.safeParseCurrencyFromString(toCurrency);
final inputAddress = responseData['server_address'] as String;
final payoutAddress = responseData['recipient'] as String;
final status = responseData['status'] as String;
final state = TradeState.deserialize(raw: status);
final response_id = responseData['order_id'] as String;
final expectedSendAmount = responseData['amount_send'] as String;
final expectedReceiveAmount = responseData['amount_receive'] as String;
final memo = responseData['memo'] as String?;
final createdAt = responseData['created_at'] as String?;
return Trade(
id: response_id,
from: from,
to: to,
provider: description,
inputAddress: inputAddress,
amount: expectedSendAmount,
payoutAddress: payoutAddress,
state: state,
receiveAmount: expectedReceiveAmount,
memo: memo,
createdAt: DateTime.tryParse(createdAt ?? ''),
);
} catch (e) {
printV("error getting trade: ${e.toString()}");
throw TradeNotFoundException(
id,
provider: description,
description: e.toString(),
);
}
}
String _normalizeCurrency(CryptoCurrency currency) {
switch (currency) {
default:
return currency.title.toUpperCase();
}
}
String _networkFor(CryptoCurrency currency) {
final network = switch (currency) {
CryptoCurrency.eth => 'ETH',
CryptoCurrency.bnb => 'BNB_BSC',
CryptoCurrency.usdterc20 => 'USDT_ERC20',
CryptoCurrency.usdttrc20 => 'TRX_USDT_S2UZ',
CryptoCurrency.usdtbsc => 'USDT_BSC',
CryptoCurrency.sol => 'SOL',
CryptoCurrency.btc => 'BTC',
CryptoCurrency.xmr => 'XMR',
CryptoCurrency.ltc => 'LTC',
CryptoCurrency.ada => 'ADA',
CryptoCurrency.bch => 'BCH',
CryptoCurrency.zec => 'ZEC',
_ => currency.title.toUpperCase(),
};
return network;
}
}
@@ -1,349 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:hash_wallet/utils/exchange_provider_logger.dart';
class ThorChainExchangeProvider extends ExchangeProvider {
ThorChainExchangeProvider();
static final isRefundAddressSupported = [CryptoCurrency.eth];
static const _baseNodeURL = 'thornode.ninerealms.com';
static const _baseURL = 'midgard.ninerealms.com';
static const _quotePath = '/thorchain/quote/swap';
static const _txInfoPath = '/thorchain/tx/status/';
static const _affiliateName = 'cakewallet';
static const _affiliateBps = '175';
static const _nameLookUpPath = 'v2/thorname/lookup/';
@override
String get title => 'THORChain';
@override
bool get isAvailable => true;
@override
bool get isEnabled => true;
@override
bool get supportsFixedRate => false;
@override
ExchangeProviderDescription get description => ExchangeProviderDescription.thorChain;
@override
Future<bool> checkIsAvailable() async => true;
@override
Future<double> fetchRate({
required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount
}) async {
try {
if (amount == 0) return 0.0;
final params = {
'from_asset': _normalizeCurrency(from),
'to_asset': _normalizeCurrency(to),
'amount': _doubleToThorChainString(amount),
'affiliate': _affiliateName,
'affiliate_bps': _affiliateBps
};
final responseJSON = await _getSwapQuote(params);
final expectedAmountOut = responseJSON['expected_amount_out'] as String? ?? '0.0';
final rate = _thorChainAmountToDouble(expectedAmountOut) / amount;
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'fetchRate',
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
'params': params,
},
responseData: {
'expectedAmountOut': expectedAmountOut,
'rate': rate,
'responseJSON': responseJSON,
},
);
return rate;
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: e,
stackTrace: s,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
printV(e.toString());
return 0.0;
}
}
@override
Future<Limits?> fetchLimits(
{required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode}) async {
final params = {
'from_asset': _normalizeCurrency(from),
'to_asset': _normalizeCurrency(to),
'amount': _doubleToThorChainString(1),
'affiliate': _affiliateName,
'affiliate_bps': _affiliateBps
};
final responseJSON = await _getSwapQuote(params);
final minAmountIn = responseJSON['recommended_min_amount_in'] as String? ?? '0.0';
return Limits(min: _thorChainAmountToDouble(minAmountIn));
}
@override
Future<Trade> createTrade({
required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll,
}) async {
final formattedFromAmount = double.parse(request.fromAmount);
final params = {
'from_asset': _normalizeCurrency(request.fromCurrency),
'to_asset': _normalizeCurrency(request.toCurrency),
'amount': _doubleToThorChainString(formattedFromAmount),
'destination': _normalizeAddress(request.toAddress),
'affiliate': _affiliateName,
'affiliate_bps': _affiliateBps,
'refund_address':
isRefundAddressSupported.contains(request.fromCurrency) ? _normalizeAddress(request.refundAddress) : '',
};
final responseJSON = await _getSwapQuote(params);
final inputAddress = responseJSON['inbound_address'] as String?;
final memo = responseJSON['memo'] as String?;
final directAmountOutResponse = responseJSON['expected_amount_out'] as String?;
String? receiveAmount;
if (directAmountOutResponse != null) {
receiveAmount = _thorChainAmountToDouble(directAmountOutResponse).toString();
}
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'createTrade',
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'params': params,
},
responseData: {
'inputAddress': inputAddress,
'memo': memo,
'directAmountOutResponse': directAmountOutResponse,
'receiveAmount': receiveAmount,
'responseJSON': responseJSON,
},
);
return Trade(
id: '',
from: request.fromCurrency,
to: request.toCurrency,
provider: description,
inputAddress: inputAddress,
createdAt: DateTime.now(),
amount: request.fromAmount,
receiveAmount: receiveAmount ?? request.toAmount,
state: TradeState.notFound,
payoutAddress: request.toAddress,
memo: memo,
isSendAll: isSendAll,
);
}
@override
Future<Trade> findTradeById({required String id}) async {
if (id.isEmpty) throw Exception('Trade id is empty');
final formattedId = id.startsWith('0x') ? id.substring(2) : id;
final uri = Uri.https(_baseNodeURL, '$_txInfoPath$formattedId');
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode == 404) {
throw Exception('Trade not found for id: $formattedId');
} else if (response.statusCode != 200) {
throw Exception('Unexpected HTTP status: ${response.statusCode}');
}
final responseJSON = json.decode(response.body);
final Map<String, dynamic> stagesJson = responseJSON['stages'] as Map<String, dynamic>;
final inboundObservedStarted = stagesJson['inbound_observed']?['started'] as bool? ?? true;
if (!inboundObservedStarted) {
throw Exception('Trade has not started for id: $formattedId');
}
final currentState = _updateStateBasedOnStages(stagesJson) ?? TradeState.notFound;
final tx = responseJSON['tx'];
final String fromAddress = tx['from_address'] as String? ?? '';
final String toAddress = tx['to_address'] as String? ?? '';
final List<dynamic> coins = tx['coins'] as List<dynamic>;
final String? memo = tx['memo'] as String?;
final parts = memo?.split(':') ?? [];
final String toChain = parts.length > 1 ? parts[1].split('.')[0] : '';
final String toAsset = parts.length > 1 && parts[1].split('.').length > 1
? parts[1].split('.')[1].split('-')[0]
: '';
final formattedToChain = CryptoCurrency.safeParseCurrencyFromString(toChain);
final toAssetWithChain = CryptoCurrency.safeParseCurrencyFromString(toAsset, walletCurrency: formattedToChain);
final plannedOutTxs = responseJSON['planned_out_txs'] as List<dynamic>?;
final isRefund = plannedOutTxs?.any((tx) => tx['refund'] == true) ?? false;
return Trade(
id: id,
from: CryptoCurrency.fromString(tx['chain'] as String? ?? ''),
to: toAssetWithChain,
provider: description,
inputAddress: fromAddress,
payoutAddress: toAddress,
amount: coins.first['amount'] as String? ?? '0.0',
state: currentState,
memo: memo,
isRefund: isRefund,
);
}
static Future<Map<String, String>?>? lookupAddressByName(String name) async {
final uri = Uri.https(_baseURL, '$_nameLookUpPath$name');
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode != 200) {
return null;
}
final body = json.decode(response.body) as Map<String, dynamic>;
final entries = body['entries'] as List<dynamic>?;
if (entries == null || entries.isEmpty) {
return null;
}
Map<String, String> chainToAddressMap = {};
for (final entry in entries) {
final chain = entry['chain'] as String;
final address = entry['address'] as String;
chainToAddressMap[chain] = address;
}
return chainToAddressMap;
}
Future<Map<String, dynamic>> _getSwapQuote(Map<String, String> params) async {
Uri uri = Uri.https(_baseNodeURL, _quotePath, params);
final response = await ProxyWrapper().get(clearnetUri: uri);
if (response.statusCode != 200) {
ExchangeProviderLogger.logError(
provider: description,
function: '_getSwapQuote',
error: Exception('Unexpected HTTP status: ${response.statusCode}'),
stackTrace: StackTrace.current,
requestData: {
'params': params,
'url': uri.toString(),
},
);
throw Exception('Unexpected HTTP status: ${response.statusCode}');
}
if (response.body.contains('error')) {
ExchangeProviderLogger.logError(
provider: description,
function: '_getSwapQuote',
error: Exception('Unexpected response: ${response.body}'),
stackTrace: StackTrace.current,
requestData: {
'params': params,
'url': uri.toString(),
},
);
throw Exception('Unexpected response: ${response.body}');
}
return json.decode(response.body) as Map<String, dynamic>;
}
String _normalizeCurrency(CryptoCurrency currency) {
final networkTitle = currency.tag == 'ETH' ? 'ETH' : currency.tag ?? currency.title;
return '$networkTitle.${currency.title}';
}
String _doubleToThorChainString(double amount) => (amount * 1e8).toInt().toString();
double _thorChainAmountToDouble(String amount) => double.parse(amount) / 1e8;
TradeState? _updateStateBasedOnStages(Map<String, dynamic> stages) {
TradeState? currentState;
if (stages['inbound_observed']['completed'] as bool? ?? false) {
currentState = TradeState.confirmation;
}
if (stages['inbound_confirmation_counted']['completed'] as bool? ?? false) {
currentState = TradeState.confirmed;
}
if (stages['inbound_finalised']['completed'] as bool? ?? false) {
currentState = TradeState.processing;
}
if (stages['swap_finalised']['completed'] as bool? ?? false) {
currentState = TradeState.traded;
}
if (stages['outbound_signed']['completed'] as bool? ?? false) {
currentState = TradeState.success;
}
return currentState;
}
String _normalizeAddress(String address) =>
address.startsWith('bitcoincash:') ? address.replaceFirst('bitcoincash:', '') : address;
}
@@ -1,565 +0,0 @@
import 'dart:convert';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_not_created_exception.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:hash_wallet/utils/package_info.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/utils/proxy_wrapper.dart';
import 'package:hash_wallet/utils/exchange_provider_logger.dart';
class XOSwapExchangeProvider extends ExchangeProvider {
XOSwapExchangeProvider() {
_addAppVersionHeader();
}
void _addAppVersionHeader() async {
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version;
_headers['App-Version'] = currentVersion;
}
static const _apiAuthority = 'exchange.exodus.io';
static const _apiPath = '/v3';
static const _pairsPath = '/pairs';
static const _ratePath = '/rates';
static const _orders = '/orders';
static const _assets = '/assets';
static final _headers = {'Content-Type': 'application/json', 'App-Name': 'cake-labs'};
final _networks = <String, String>{
'POL': 'matic',
'ETH': 'ethereum',
'BTC': 'bitcoin',
'BSC': 'bsc',
'SOL': 'solana',
'TRX': 'tronmainnet',
'ZEC': 'zcash',
'ADA': 'cardano',
'DOGE': 'dogecoin',
'XMR': 'monero',
'BCH': 'bcash',
'BSV': 'bitcoinsv',
'XRP': 'ripple',
'LTC': 'litecoin',
'EOS': 'eosio',
'XLM': 'stellar',
'BASE': 'basemainnet',
'ARB': 'arbitrum',
};
static const supportedTags = [
'POL',
'ETH',
'BTC',
'BSC',
'SOL',
'TRX',
'ZEC',
'ADA',
'DOGE',
'XMR',
'BCH',
'BSV',
'XRP',
'LTC',
'EOS',
'XLM',
'BASE',
'ARB',
];
String _normalizeXOSwapsNetwork(String string) {
final lower = string.toLowerCase();
if (lower.endsWith('matic0a883d9b')) return string.replaceFirst(RegExp(r'matic0a883d9b$', caseSensitive: false), 'POL');
if (lower.endsWith('matic86e249c1')) return string.replaceFirst(RegExp(r'matic86e249c1$', caseSensitive: false), 'POL');
if (lower.endsWith('bscddedf0f8')) return string.replaceFirst(RegExp(r'bscddedf0f8$', caseSensitive: false), 'BSC');
if (lower.endsWith('basemainnetb5a52617')) return string.replaceFirst(RegExp(r'basemainnetb5a52617$', caseSensitive: false), 'BASE');
return string;
}
@override
String get title => 'XOSwap';
@override
bool get isAvailable => true;
@override
bool get isEnabled => true;
@override
bool get supportsFixedRate => true;
@override
ExchangeProviderDescription get description => ExchangeProviderDescription.xoSwap;
@override
Future<bool> checkIsAvailable() async => true;
Future<String?> _getAssets(CryptoCurrency currency) async {
if (currency.tag == null) return currency.title;
try {
final normalizedNetwork = _networks[currency.tag];
if (normalizedNetwork == null) return null;
final uri = Uri.https(_apiAuthority, _apiPath + _assets,
{'networks': normalizedNetwork, 'query': currency.title});
final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
if (response.statusCode != 200) {
throw Exception('Failed to fetch assets for ${currency.title} on ${currency.tag}');
}
final decoded = jsonDecode(response.body);
if (decoded is! List) throw const FormatException('Unexpected response format');
final assets = decoded.map((e) => Map<String, dynamic>.from(e as Map)).toList();
final asset = assets.firstWhere(
(asset) => removeNonAlphanumeric((asset['symbol'] ?? '').toString()) == currency.title,
orElse: () => const {},
);
return asset.isEmpty ? null : asset['id'] as String;
} catch (e) {
printV(e.toString());
return null;
}
}
String removeNonAlphanumeric(String str) => str.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
Future<List<dynamic>> getRatesForPair({
required CryptoCurrency from,
required CryptoCurrency to,
}) async {
try {
final curFrom = await _getAssets(from);
final curTo = await _getAssets(to);
if (curFrom == null || curTo == null) return [];
final pairId = curFrom + '_' + curTo;
final uri = Uri.https(_apiAuthority, '$_apiPath$_pairsPath/$pairId$_ratePath');
final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
if (response.statusCode != 200) return [];
return json.decode(response.body) as List<dynamic>;
} catch (e) {
printV(e.toString());
return [];
}
}
Future<Limits?> fetchLimits({
required CryptoCurrency from,
required CryptoCurrency to,
required bool isFixedRateMode,
}) async {
try {
final rates = await getRatesForPair(from: from, to: to);
if (rates.isEmpty) throw Exception('No rates found for $from to $to');
double minLimit = double.infinity;
double maxLimit = 0;
for (var rate in rates) {
final double currentMin = double.parse(rate['min']['value'].toString());
final double currentMax = double.parse(rate['max']['value'].toString());
if (currentMin < minLimit) minLimit = currentMin;
if (currentMax > maxLimit) maxLimit = currentMax;
}
return Limits(min: minLimit, max: maxLimit);
} catch (e) {
printV(e.toString());
throw Exception('StealthEx failed to fetch limits');
}
}
@override
Future<double> fetchRate({
required CryptoCurrency from,
required CryptoCurrency to,
required double amount,
required bool isFixedRateMode,
required bool isReceiveAmount
}) async {
try {
final rates = await getRatesForPair(from: from, to: to);
if (rates.isEmpty) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: Exception('No rates found for $from to $to'),
stackTrace: StackTrace.current,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
return 0;
}
double result;
if (!isFixedRateMode) {
double bestOutput = 0.0;
for (var rate in rates) {
final double minVal = double.parse(rate['min']['value'].toString());
final double maxVal = double.parse(rate['max']['value'].toString());
if (amount >= minVal && amount <= maxVal) {
final double rateMultiplier = double.parse(rate['amount']['value'].toString());
final double minerFee = double.parse(rate['minerFee']['value'].toString());
final double outputAmount = (amount * rateMultiplier) - minerFee;
if (outputAmount > bestOutput) {
bestOutput = outputAmount;
}
}
}
result = bestOutput > 0 ? (bestOutput / amount) : 0;
} else {
double bestInput = double.infinity;
for (var rate in rates) {
final double rateMultiplier = double.parse(rate['amount']['value'].toString());
final double minerFee = double.parse(rate['minerFee']['value'].toString());
final double minVal = double.parse(rate['min']['value'].toString());
final double maxVal = double.parse(rate['max']['value'].toString());
final double requiredSend = (amount + minerFee) / rateMultiplier;
if (requiredSend >= minVal && requiredSend <= maxVal) {
if (requiredSend < bestInput) {
bestInput = requiredSend;
}
}
}
result = bestInput < double.infinity ? amount / bestInput : 0;
}
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'fetchRate',
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
responseData: {
'result': result,
'ratesCount': rates.length,
'rates': rates,
},
);
return result;
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'fetchRate',
error: e,
stackTrace: s,
requestData: {
'from': from.title,
'to': to.title,
'amount': amount,
'isFixedRateMode': isFixedRateMode,
'isReceiveAmount': isReceiveAmount,
},
);
printV(e.toString());
return 0;
}
}
@override
Future<Trade> createTrade({
required TradeRequest request,
required bool isFixedRateMode,
required bool isSendAll,
}) async {
try {
final uri = Uri.https(_apiAuthority, '$_apiPath$_orders');
final curFrom = await _getAssets(request.fromCurrency);
final curTo = await _getAssets(request.toCurrency);
if (curFrom == null || curTo == null) {
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: TradeNotCreatedException(description),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'curFrom': curFrom,
'curTo': curTo,
},
);
throw TradeNotCreatedException(description);
}
final pairId = curFrom + '_' + curTo;
final payload = {
'fromAmount': request.fromAmount,
'fromAddress': request.refundAddress,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'pairId': pairId,
};
final response = await ProxyWrapper().post(
clearnetUri: uri,
headers: _headers,
body: json.encode(payload),
);
if (response.statusCode != 201) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final error = responseJSON['error'] ?? 'Unknown error';
final message = responseJSON['message'] ?? '';
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: Exception('$error\n$message'),
stackTrace: StackTrace.current,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'payload': payload,
'url': uri.toString(),
},
);
throw Exception('$error\n$message');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final amount = responseJSON['amount'] as Map<String, dynamic>;
final toAmount = responseJSON['toAmount'] as Map<String, dynamic>;
final orderId = responseJSON['id'] as String;
final from = request.fromCurrency;
final to = request.toCurrency;
final payoutAddress = responseJSON['toAddress'] as String;
final depositAddress = responseJSON['payInAddress'] as String;
final refundAddress = responseJSON['fromAddress'] as String;
final depositAmountStr = amount['value'].toString();
final parsedAmount = double.tryParse(depositAmountStr);
if (parsedAmount == null || parsedAmount <= 0) {
throw Exception('Invalid deposit amount received from API');
}
final receiveAmount = toAmount['value'] as String;
final status = responseJSON['status'] as String;
final createdAtString = responseJSON['createdAt'] as String;
final extraId = responseJSON['payInAddressTag'] as String?;
final createdAt = DateTime.parse(createdAtString).toLocal();
ExchangeProviderLogger.logSuccess(
provider: description,
function: 'createTrade',
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
'payload': payload,
'url': uri.toString(),
},
responseData: {
'orderId': orderId,
'depositAddress': depositAddress,
'payoutAddress': payoutAddress,
'refundAddress': refundAddress,
'depositAmount': depositAmountStr,
'receiveAmount': receiveAmount,
'status': status,
'createdAt': createdAtString,
'extraId': extraId,
'statusCode': response.statusCode,
'responseJSON': responseJSON,
},
);
return Trade(
id: orderId,
from: from,
to: to,
provider: description,
inputAddress: depositAddress,
refundAddress: refundAddress,
state: TradeState.deserialize(raw: status),
createdAt: createdAt,
amount: depositAmountStr,
receiveAmount: receiveAmount.toString(),
payoutAddress: payoutAddress,
extraId: extraId,
isSendAll: isSendAll,
);
} catch (e, s) {
ExchangeProviderLogger.logError(
provider: description,
function: 'createTrade',
error: e,
stackTrace: s,
requestData: {
'from': request.fromCurrency.title,
'to': request.toCurrency.title,
'fromAmount': request.fromAmount,
'toAmount': request.toAmount,
'toAddress': request.toAddress,
'refundAddress': request.refundAddress,
'isFixedRateMode': isFixedRateMode,
'isSendAll': isSendAll,
},
);
printV(e.toString());
throw TradeNotCreatedException(description);
}
}
@override
Future<Trade> findTradeById({required String id}) async {
try {
final uri = Uri.https(_apiAuthority, '$_apiPath$_orders/$id');
final response = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
if (response.statusCode != 200) {
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
if (responseJSON.containsKey('code') && responseJSON['code'] == 'NOT_FOUND') {
throw Exception('Trade not found');
}
final error = responseJSON['error'] ?? 'Unknown error';
final message = responseJSON['message'] ?? responseJSON['details'] ?? '';
throw Exception('$error\n$message');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final pairId = responseJSON['pairId'] as String;
final pairParts = pairId.split('_');
final fromAsset = pairParts.isNotEmpty ? pairParts[0] : '';
final normalizedFromAsset = _normalizeXOSwapsNetwork(fromAsset);
String? fromAssetTag = _extractTagFromAsset(normalizedFromAsset);
String fromAssetBase = fromAssetTag != null
? normalizedFromAsset.substring(0, normalizedFromAsset.length - fromAssetTag.length)
: normalizedFromAsset;
// Special case for USDT defaulting to ETH tag
if (fromAssetBase == 'USDT' && fromAssetTag == null) {
fromAssetTag = 'ETH';
}
// Special case for BASE defaulting to BASE tag
if (fromAssetBase == 'BASE' && fromAssetTag == null) {
fromAssetTag = 'BASE';
fromAssetBase = 'ETH';
}
final toAsset = pairParts.length > 1 ? pairParts[1] : '';
final normalizedToAsset = _normalizeXOSwapsNetwork(toAsset);
String? toAssetTag = _extractTagFromAsset(normalizedToAsset);
String toAssetBase = toAssetTag != null
? normalizedToAsset.substring(0, normalizedToAsset.length - toAssetTag.length)
: normalizedToAsset;
// Special case for USDT defaulting to ETH tag
if (toAssetBase == 'USDT' && toAssetTag == null) {
toAssetTag = 'ETH';
}
// Special case for BASE defaulting to BASE tag
if (toAssetBase == 'BASE' && toAssetTag == null) {
toAssetTag = 'ETH';
toAssetBase = 'BASE';
}
final fromCurrency = CryptoCurrency.safeParseCurrencyFromString(fromAssetBase,tag: fromAssetTag);
final toCurrency = CryptoCurrency.safeParseCurrencyFromString(toAssetBase,tag: toAssetTag);
final amount = responseJSON['amount'] as Map<String, dynamic>;
final toAmount = responseJSON['toAmount'] as Map<String, dynamic>;
final orderId = responseJSON['id'] as String;
final depositAmountStr = amount['value'].toString();
final parsedAmount = double.tryParse(depositAmountStr);
if (parsedAmount == null || parsedAmount <= 0) {
throw Exception('Invalid deposit amount received from API');
}
final receiveAmount = toAmount['value'] as String;
final depositAddress = responseJSON['payInAddress'] as String;
final payoutAddress = responseJSON['toAddress'] as String;
final refundAddress = responseJSON['fromAddress'] as String;
final status = responseJSON['status'] as String;
final createdAtString = responseJSON['createdAt'] as String;
final createdAt = DateTime.parse(createdAtString).toLocal();
final extraId = responseJSON['payInAddressTag'] as String?;
return Trade(
id: orderId,
from: fromCurrency,
to: toCurrency,
provider: description,
inputAddress: depositAddress,
refundAddress: refundAddress,
state: TradeState.deserialize(raw: status),
createdAt: createdAt,
amount: depositAmountStr,
receiveAmount: receiveAmount,
payoutAddress: payoutAddress,
extraId: extraId,
);
} catch (e) {
printV(e.toString());
throw TradeNotCreatedException(description);
}
}
// ensure something remains before tag (at least 2 chars)
String? _extractTagFromAsset(String asset) {
for (final tag in supportedTags) {
if (asset.endsWith(tag)) {
final prefixLength = asset.length - tag.length;
if (prefixLength >= 2) {
return tag;
}
}
}
return null;
}
}
+1 -10
View File
@@ -5,8 +5,6 @@ import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/entities/parse_address_from_domain.dart';
import 'package:hash_wallet/entities/qr_scanner.dart';
import 'package:hash_wallet/exchange/exchange_trade_state.dart';
import 'package:hash_wallet/exchange/provider/chainflip_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/keyboard_hide_overlay.dart';
import 'package:hash_wallet/new-ui/widgets/modern_button.dart';
@@ -281,14 +279,7 @@ class _NewSwapPageState extends State<NewSwapPage> {
if (depositAmountController.text != widget.exchangeViewModel.depositAmount &&
depositAmountController.text != S.of(context).all) {
widget.exchangeViewModel.isSendAllEnabled = false;
final isThorChain = widget.exchangeViewModel.selectedProviders
.any((provider) => provider is ThorChainExchangeProvider);
final isChainflip = widget.exchangeViewModel.selectedProviders
.any((provider) => provider is ChainflipExchangeProvider);
_depositAmountDebounce = isThorChain || isChainflip
? Debounce(Duration(milliseconds: 1000))
: Debounce(Duration(milliseconds: 500));
_depositAmountDebounce = Debounce(Duration(milliseconds: 500));
_depositAmountDebounce.run(() {
widget.exchangeViewModel.calculateBestRate();
@@ -32,7 +32,7 @@ class HistoryOrderTile extends StatelessWidget {
date: date,
amount: amount,
amountFiat: amountFiat,
leadingIcon:Image.asset("assets/images/cakepay.png"),
leadingIcon: Image.asset("assets/images/card.png"),
roundedTop: roundedTop,
roundedBottom: roundedBottom,
bottomSeparator: bottomSeparator,
@@ -1,67 +0,0 @@
import 'package:hash_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:hash_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:hash_wallet/order/order.dart';
import 'package:hash_wallet/order/order_provider.dart';
import 'package:hash_wallet/order/order_provider_description.dart';
import 'package:hash_wallet/order/order_source_description.dart';
import 'package:cw_core/wallet_base.dart';
class CakePayOrderProviderAdapter implements OrderProvider {
CakePayOrderProviderAdapter({
required this.wallet,
required this.cakePayService,
});
final WalletBase wallet;
final CakePayService cakePayService;
@override
String get title => 'Cake Pay';
@override
String get trackUrl => '';
@override
Future<(Order, Object?)> findOrderById(String id) async {
final cakePayOrder = await cakePayService.findOrderById(orderId: id);
final cards = cakePayOrder.cards;
final order = Order(
id: cakePayOrder.orderId,
state: determineState(cakePayOrder.status),
transferId: '',
createdAt: DateTime.now(),
amount: '',
receiveAmount: cakePayOrder.totalReceiveAmount,
quantity: cakePayOrder.quantity.toString(),
receiveAddress: '',
walletId: wallet.id,
from: '',
to: cakePayOrder.fiatCurrencyCode,
source: OrderSourceDescription.order,
giftCardProvider: OrderProviderDescription.cakePay,
);
return (order, cards);
}
TradeState determineState(String state) {
final swapState = switch (state) {
'new' => TradeState.pending,
'expired_but_still_pending' => TradeState.overdue,
'expired' => TradeState.expired,
'failed' => TradeState.failed,
'paid' => TradeState.paid,
'paid_partial' => TradeState.underpaid,
'pending_purchase' => TradeState.processing,
'purchase_processing' => TradeState.processing,
'purchased' => TradeState.confirmed,
'pending_email' => TradeState.awaiting,
'complete' => TradeState.complete,
'pending_refund' => TradeState.refund,
'refunded' => TradeState.refunded,
_ => TradeState.notFound
};
return swapState;
}
}
-28
View File
@@ -30,7 +30,6 @@ import 'package:hash_wallet/src/screens/buy/buy_sell_options_page.dart';
import 'package:hash_wallet/src/screens/buy/buy_webview_page.dart';
import 'package:hash_wallet/src/screens/buy/payment_method_options_page.dart';
import 'package:hash_wallet/src/screens/buy/webview_page.dart';
import 'package:hash_wallet/cake_pay/cake_pay.dart';
import 'package:hash_wallet/src/screens/connect_device/connect_device_page.dart';
import 'package:hash_wallet/src/screens/connect_device/monero_hardware_wallet_options_page.dart';
import 'package:hash_wallet/src/screens/connect_device/select_device_manufacturer_page.dart';
@@ -778,37 +777,10 @@ Route<dynamic> createRoute(RouteSettings settings) {
param1: settings.arguments as QrViewData,
));
case Routes.cakePayCardsPage:
return handleRouteWithPlatformAwareness(
(context) => getIt.get<CakePayCardsPage>(),
);
case Routes.aboutPage:
final page = getIt.get<AboutPage>();
return handleRouteWithPlatformAwareness((context) => page);
case Routes.cakePayBuyCardPage:
final args = settings.arguments as List;
return handleRouteWithPlatformAwareness(
(context) => getIt.get<CakePayBuyCardPage>(param1: args),
);
case Routes.cakePayWelcomePage:
return handleRouteWithPlatformAwareness<bool>(
(context) => getIt.get<CakePayWelcomePage>(),
);
case Routes.cakePayVerifyOtpPage:
final args = settings.arguments as List;
return handleRouteWithPlatformAwareness<bool>(
(context) => getIt.get<CakePayVerifyOtpPage>(param1: args),
);
case Routes.cakePayAccountPage:
return handleRouteWithPlatformAwareness<bool>(
(context) => getIt.get<CakePayAccountPage>(),
);
case Routes.webViewPage:
final args = settings.arguments as List;
final title = args.first as String;
-6
View File
@@ -72,12 +72,6 @@ class Routes {
static const unspentCoinsDetails = '/unspent_coins_details';
static const addressPage = '/address_page';
static const fullscreenQR = '/fullscreen_qr';
static const cakePayWelcomePage = '/cake_pay_welcome_page';
static const cakePayLoginPage = '/cake_pay_login_page';
static const cakePayCardsPage = '/cake_pay_cards_page';
static const cakePayBuyCardPage = '/cake_pay_buy_card_page';
static const cakePayVerifyOtpPage = '/cake_pay_verify_otp_page';
static const cakePayAccountPage = '/cake_pay_account_page';
static const webViewPage = '/web_view_page';
static const silentPaymentsSettings = '/silent_payments_settings';
static const silentPaymentsLogs = '/silent_payments_logs';
@@ -14,7 +14,6 @@ import 'package:hash_wallet/src/widgets/vulnerable_seeds_popup.dart';
import 'package:hash_wallet/utils/device_info.dart';
import 'package:hash_wallet/utils/feature_flag.dart';
import 'package:hash_wallet/utils/version_comparator.dart';
import 'package:hash_wallet/view_model/dashboard/cake_features_view_model.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/screens/yat_emoji_id.dart';
@@ -327,7 +326,6 @@ class _DashboardPageView extends BasePage {
label: S.of(context).apps,
child: CakeFeaturesPage(
dashboardViewModel: dashboardViewModel,
cakeFeaturesViewModel: getIt.get<CakeFeaturesViewModel>(),
),
),
);
@@ -1,9 +1,7 @@
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/entities/main_actions.dart';
import 'package:hash_wallet/src/screens/dashboard/desktop_widgets/desktop_action_button.dart';
import 'package:hash_wallet/src/screens/dashboard/pages/cake_features_page.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/cake_features_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
@@ -75,7 +73,6 @@ class DesktopDashboardActions extends StatelessWidget {
Expanded(
child: CakeFeaturesPage(
dashboardViewModel: dashboardViewModel,
cakeFeaturesViewModel: getIt.get<CakeFeaturesViewModel>(),
),
),
],
@@ -7,10 +7,8 @@ import 'package:hash_wallet/src/screens/dashboard/widgets/info_card.dart';
import 'package:hash_wallet/src/widgets/alert_with_one_action.dart';
import 'package:hash_wallet/src/widgets/alert_with_two_actions.dart';
import 'package:hash_wallet/src/widgets/dashboard_card_widget.dart';
import 'package:hash_wallet/src/widgets/introducing_card.dart';
import 'package:hash_wallet/src/widgets/standard_switch.dart';
import 'package:hash_wallet/themes/core/theme_extension.dart';
import 'package:hash_wallet/utils/feature_flag.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cw_core/wallet_info.dart';
@@ -139,18 +137,6 @@ class CryptoBalanceWidget extends StatelessWidget {
),
],
)),
Observer(
builder: (_) {
if (dashboardViewModel.balanceViewModel.isShowCard && FeatureFlag.isCakePayEnabled) {
return IntroducingCard(
title: S.of(context).introducing_cake_pay,
subTitle: S.of(context).cake_pay_learn_more,
borderColor: Theme.of(context).colorScheme.outline,
closeCard: dashboardViewModel.balanceViewModel.disableIntroCakePayCard);
}
return Container();
},
),
Observer(builder: (_) {
if (!dashboardViewModel.showRepWarning) {
return const SizedBox();
@@ -1,24 +1,17 @@
import 'dart:io';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/new-ui/widgets/apps_widget.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/src/widgets/hash_image_widget.dart';
import 'package:hash_wallet/src/widgets/dashboard_card_widget.dart';
import 'package:hash_wallet/utils/feature_flag.dart';
import 'package:hash_wallet/view_model/dashboard/cake_features_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:hash_wallet/src/widgets/gradient_background.dart';
class CakeFeaturesPage extends StatelessWidget {
CakeFeaturesPage({required this.dashboardViewModel, required this.cakeFeaturesViewModel});
CakeFeaturesPage({required this.dashboardViewModel});
final DashboardViewModel dashboardViewModel;
final CakeFeaturesViewModel cakeFeaturesViewModel;
@override
Widget build(BuildContext context) {
@@ -102,23 +95,4 @@ class CakeFeaturesPage extends StatelessWidget {
);
}
void _onCakePayTap(BuildContext context) {
if (Platform.isMacOS) {
_launchUrl("buy.cakepay.com");
} else {
_navigatorToGiftCardsPage(context);
}
}
void _launchUrl(String url) {
try {
launchUrl(Uri.https(url), mode: LaunchMode.externalApplication);
} catch (e) {
printV(e);
}
}
void _navigatorToGiftCardsPage(BuildContext context) {
Navigator.pushNamed(context, Routes.cakePayCardsPage);
}
}
+1 -10
View File
@@ -1,6 +1,4 @@
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/provider/chainflip_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:hash_wallet/core/auth_service.dart';
import 'package:hash_wallet/di.dart';
import 'package:hash_wallet/src/screens/exchange/widgets/desktop_exchange_cards_section.dart';
@@ -542,14 +540,7 @@ class ExchangePage extends BasePage {
if (depositAmountController.text != exchangeViewModel.depositAmount &&
depositAmountController.text != S.of(context).all) {
exchangeViewModel.isSendAllEnabled = false;
final isThorChain = exchangeViewModel.selectedProviders
.any((provider) => provider is ThorChainExchangeProvider);
final isChainflip = exchangeViewModel.selectedProviders
.any((provider) => provider is ChainflipExchangeProvider);
_depositAmountDebounce = isThorChain || isChainflip
? Debounce(Duration(milliseconds: 1000))
: Debounce(Duration(milliseconds: 500));
_depositAmountDebounce = Debounce(Duration(milliseconds: 500));
_depositAmountDebounce.run(() {
exchangeViewModel.calculateBestRate();
@@ -1,27 +0,0 @@
import 'package:hash_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:hash_wallet/src/screens/transaction_details/standart_list_item.dart';
import 'package:flutter/material.dart';
class CakePayDetailsListCardItem extends StandartListItem {
CakePayDetailsListCardItem({
required String title,
required String value,
required this.id,
required this.createdAt,
this.price,
this.quantity,
required this.from,
required this.to,
required this.onTap,
required this.cards,
}) : super(title: title, value: value);
final String id;
final String createdAt;
final String? price;
final String? quantity;
final String from;
final String to;
final void Function(BuildContext) onTap;
final List<OrderCard> cards;
}
@@ -1,6 +1,4 @@
import 'package:hash_wallet/src/screens/order_details/cake_pay_detail_list_card_item.dart';
import 'package:hash_wallet/src/screens/trade_details/trade_details_status_item.dart';
import 'package:hash_wallet/src/widgets/cake_pay_order_list_card.dart';
import 'package:hash_wallet/src/widgets/standard_list.dart';
import 'package:hash_wallet/src/widgets/standard_list_status_row.dart';
import 'package:hash_wallet/themes/core/material_base_theme.dart';
@@ -64,21 +62,6 @@ class OrderDetailsPageBodyState extends State<OrderDetailsPageBody> {
return StandardListStatusRow(title: item.title, value: item.value);
}
if (item is CakePayDetailsListCardItem) {
final cardImagePath = (item.cards.isNotEmpty) ? item.cards.first.cardImagePath : null;
return CakePayOrderListCard(
id: item.id,
create: item.createdAt,
price: item.price,
quantity: item.quantity,
pair: '${item.from} → ${item.to}',
currentTheme: widget.currentTheme.type,
onTap: item.onTap,
backgroundImage: cardImagePath,
);
}
if (item is TrackTradeListItem) {
return GestureDetector(
onTap: item.onTap,
@@ -1,338 +0,0 @@
import 'package:hash_wallet/cake_pay/src/widgets/cake_pay_alert_modal.dart';
import 'package:hash_wallet/cake_pay/src/widgets/flip_card_widget.dart';
import 'package:hash_wallet/cake_pay/src/widgets/link_extractor.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/utils/image_utill.dart';
import 'package:hash_wallet/utils/show_pop_up.dart';
import 'package:flutter/material.dart';
import 'base_bottom_sheet_widget.dart';
class CakePayCardInfoBottomSheet extends BaseBottomSheet {
CakePayCardInfoBottomSheet({
required String titleText,
required FooterType footerType,
String? titleIconPath,
String? singleActionButtonText,
VoidCallback? onSingleActionButtonPressed,
Key? singleActionButtonKey,
String? doubleActionLeftButtonText,
String? doubleActionRightButtonText,
VoidCallback? onLeftActionButtonPressed,
VoidCallback? onRightActionButtonPressed,
Key? rightActionButtonKey,
Key? leftActionButtonKey,
required this.onUpdateBalancePressed,
required this.isReloadable,
required this.balance,
this.contentImage,
this.howToUse,
this.applyBoxShadow = false,
Key? key,
}) : super(
titleText: titleText,
maxHeight: 900,
titleIconPath: titleIconPath,
footerType: footerType,
singleActionButtonText: singleActionButtonText,
onSingleActionButtonPressed: onSingleActionButtonPressed,
singleActionButtonKey: singleActionButtonKey,
doubleActionLeftButtonText: doubleActionLeftButtonText,
doubleActionRightButtonText: doubleActionRightButtonText,
onLeftActionButtonPressed: onLeftActionButtonPressed,
onRightActionButtonPressed: onRightActionButtonPressed,
leftActionButtonKey: leftActionButtonKey,
rightActionButtonKey: rightActionButtonKey,
key: key);
final VoidCallback onUpdateBalancePressed;
final String? contentImage;
final String? howToUse;
final bool isReloadable;
final String balance;
final bool applyBoxShadow;
final _cardKey = GlobalKey<FlipCardState>();
@override
Widget contentWidget(BuildContext context) {
final itemTitleTextStyle = Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
);
final itemSubTitleTextStyle = Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
decoration: TextDecoration.none,
);
final tileBackgroundColor = Theme.of(context).colorScheme.surfaceContainer;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (contentImage != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(top: 0, left: 16, right: 16, bottom: 16),
child: AspectRatio(
aspectRatio: 1.6,
child: FlipCard(
key: _cardKey,
flipOnTouch: true,
front: _buildCardImage(context, contentImage!, applyBoxShadow),
back: _buildBarcodeSide(context,
cardNumber: '6006491979836784204', pin: '4782'),
),
),
),
Positioned(
bottom: 0,
right: 0,
child: GestureDetector(
onTap: () => _cardKey.currentState?.toggleCard(),
child: Container(
height: 43,
width: 43,
decoration: BoxDecoration(
border: Border.all(color: Colors.white, width: 1),
color: Colors.white.withAlpha(75),
shape: BoxShape.circle,
),
child: Transform.scale(
scale: .8,
child: const ImageIcon(
AssetImage('assets/images/transfer.png'),
color: Colors.white,
),
)),
),
),
],
),
),
Container(),
Text(
'Tap card to show details',
style: itemTitleTextStyle.copyWith(fontSize: 12, fontWeight: FontWeight.w700),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: [
const SizedBox(height: 34),
CakePayInfoTile(
isReloadable: isReloadable,
itemValue: balance,
itemTitleTextStyle: itemTitleTextStyle,
itemSubTitleTextStyle: itemSubTitleTextStyle,
tileBackgroundColor: tileBackgroundColor),
const SizedBox(height: 8),
_HowToUseTile(howToUse: howToUse ?? '', tileBackgroundColor: tileBackgroundColor),
const SizedBox(height: 40),
],
),
),
],
);
}
}
class _HowToUseTile extends StatelessWidget {
const _HowToUseTile({required this.howToUse, required this.tileBackgroundColor});
final String howToUse;
final Color tileBackgroundColor;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => _showHowToUseCard(context: context, howToUse: howToUse),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration:
BoxDecoration(borderRadius: BorderRadius.circular(10), color: tileBackgroundColor),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(S.of(context).how_to_use_card, style: Theme.of(context).textTheme.bodyLarge),
Icon(
Icons.chevron_right_rounded,
color: Theme.of(context).textTheme.titleLarge!.color!,
),
],
)),
);
}
}
class CakePayInfoTile extends StatelessWidget {
const CakePayInfoTile({
super.key,
required this.isReloadable,
required this.itemValue,
required this.itemTitleTextStyle,
this.itemSubTitle,
required this.itemSubTitleTextStyle,
required this.tileBackgroundColor,
});
final bool isReloadable;
final String itemValue;
final TextStyle itemTitleTextStyle;
final String? itemSubTitle;
final TextStyle itemSubTitleTextStyle;
final Color tileBackgroundColor;
@override
Widget build(BuildContext context) {
return Semantics(
container: true,
label: isReloadable ? 'Balance' : 'Total Value',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration:
BoxDecoration(borderRadius: BorderRadius.circular(10), color: tileBackgroundColor),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(isReloadable ? 'Balance' : 'Total Value', style: itemTitleTextStyle),
Text(itemValue,
style: itemTitleTextStyle.copyWith(fontSize: 18, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text(isReloadable ? 'Top Up Balance' : 'Manually update Balance',
style: itemTitleTextStyle.copyWith(fontSize: 12, fontWeight: FontWeight.w600)),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).dialogBackgroundColor)),
const SizedBox(height: 4),
],
),
),
);
}
}
void _showHowToUseCard({required BuildContext context, String? howToUse}) {
showPopUp<void>(
context: context,
builder: (BuildContext context) {
return CakePayAlertModal(
title: S.of(context).how_to_use_card,
content: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ClickableLinksText(
text: howToUse ?? '',
textStyle: Theme.of(context).textTheme.bodyMedium!,
linkStyle: TextStyle(
color: Theme.of(context).textTheme.titleLarge!.color!,
fontSize: 18,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w400,
),
),
]),
actionTitle: S.current.got_it,
);
});
}
Widget _buildCardImage(BuildContext ctx, String path, bool addShadow) {
final border = BorderRadius.circular(10);
return Container(
decoration: addShadow
? BoxDecoration(
borderRadius: border,
boxShadow: [BoxShadow(color: Colors.black.withAlpha(150), blurRadius: 5)],
)
: null,
child: ClipRRect(
borderRadius: border,
child: Stack(
fit: StackFit.expand,
children: [
Container(color: Theme.of(ctx).cardColor.withAlpha(200)),
ImageUtil.getImageFromPath(
imagePath: path,
fit: BoxFit.cover,
),
],
),
),
);
}
Widget _buildBarcodeSide(BuildContext context, {required String cardNumber, required String pin}) =>
SizedBox.expand(
child: Container(
padding: const EdgeInsets.only(top: 16, left: 24, right: 24, bottom: 34),
decoration: BoxDecoration(
color: Theme.of(context).cardColor.withAlpha(200),
borderRadius: BorderRadius.circular(10),
boxShadow: [BoxShadow(color: Colors.black.withAlpha(150), blurRadius: 5)],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Container(
color: Theme.of(context).textTheme.titleLarge!.color!.withOpacity(.1),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Gift Card Number',
style: TextStyle(
fontFamily: 'Lato',
fontSize: 10,
fontWeight: FontWeight.w500,
color: Color.fromRGBO(207, 207, 207, 1))),
const SizedBox(height: 4),
Text(cardNumber,
style: TextStyle(
fontFamily: 'Lato',
fontSize: 12,
fontWeight: FontWeight.w900,
color: Color.fromRGBO(146, 146, 146, 1))),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('PIN Number',
style: TextStyle(
fontFamily: 'Lato',
fontSize: 10,
fontWeight: FontWeight.w500,
color: Color.fromRGBO(207, 207, 207, 1))),
const SizedBox(height: 4),
Text(pin,
style: TextStyle(
fontFamily: 'Lato',
fontSize: 12,
fontWeight: FontWeight.w900,
color: Color.fromRGBO(146, 146, 146, 1))),
],
),
],
),
],
),
),
);
@@ -1,315 +0,0 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/widgets/primary_button.dart';
import 'package:hash_wallet/src/widgets/rounded_icon_button.dart';
import 'package:hash_wallet/utils/image_utill.dart';
import 'package:hash_wallet/view_model/send/output.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CakePayTransactionSentBottomSheet extends StatelessWidget {
const CakePayTransactionSentBottomSheet({
super.key,
required this.titleText,
required this.currency,
required this.amount,
required this.amountValue,
required this.fiatAmountValue,
required this.output,
required this.fee,
required this.feeValue,
required this.feeFiatAmount,
this.titleIconWidget,
required this.quantity,
required this.onClose,
required this.paymentId,
required this.paymentIdValue,
});
final String titleText;
final Widget? titleIconWidget;
final CryptoCurrency currency;
final String amount;
final String amountValue;
final String fiatAmountValue;
final Output output;
final String fee;
final String feeValue;
final String feeFiatAmount;
final String quantity;
final VoidCallback onClose;
final String paymentId;
final String paymentIdValue;
TextStyle _titleStyle(BuildContext ctx) =>
Theme
.of(ctx)
.textTheme
.bodyLarge!;
TextStyle _valueStyle(BuildContext ctx) =>
Theme
.of(ctx)
.textTheme
.titleMedium!
.copyWith(
fontSize: 18,
fontWeight: FontWeight.w600,
);
TextStyle _subStyle(BuildContext ctx) =>
Theme
.of(ctx)
.textTheme
.labelSmall!
.copyWith(
color: Theme
.of(ctx)
.colorScheme
.onSurfaceVariant,
);
Widget _buildHeader(BuildContext ctx) =>
Column(
children: [
const SizedBox(height: 12),
Container(
width: 64,
height: 5,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Theme
.of(ctx)
.colorScheme
.onSurface,
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (titleIconWidget != null) titleIconWidget!,
const SizedBox(width: 6),
Text(
titleText,
style: Theme
.of(ctx)
.textTheme
.titleLarge!
.copyWith(
fontSize: 20,
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
),
],
),
const SizedBox(height: 12),
],
);
Widget _buildBody(BuildContext context) =>
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: [
_StandardTile(
itemTitle: amount,
titleStyle: _titleStyle(context),
itemValue: '$amountValue ${currency.title}',
itemValueStyle: _valueStyle(context),
itemSubTitle: fiatAmountValue,
itemSubTitleStyle: _subStyle(context),
),
const SizedBox(height: 8),
_StandardTile(
itemTitle: fee,
titleStyle: _titleStyle(context),
itemValue: feeValue,
itemValueStyle: _valueStyle(context),
itemSubTitle: feeFiatAmount,
itemSubTitleStyle: _subStyle(context),
),
const SizedBox(height: 8),
_StandardTile(
itemTitle: output.parsedAddress.profileName,
titleStyle: _titleStyle(context),
itemValue: output.fiatAmount,
itemValueStyle: _valueStyle(context),
itemSubTitle: quantity,
itemSubTitleStyle: _subStyle(context),
imagePath: output.parsedAddress.profileImageUrl,
),
const SizedBox(height: 8),
_StandardTile(
itemTitle: paymentId + ': \n' + paymentIdValue,
titleStyle: _titleStyle(context).copyWith(
fontSize: 14
),
itemValue: '',
itemValueStyle: _valueStyle(context),
copyButton: true,
copyButtonOnPressed: () async =>
await Clipboard.setData(ClipboardData(text: paymentIdValue)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
children: [
const SizedBox(height: 18),
Image.asset('assets/images/envelope.png'),
const SizedBox(height: 18),
Text(
S
.of(context)
.cake_pay_card_email_delivered_message,
style: Theme
.of(context)
.textTheme
.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
PrimaryButton(
text: S
.of(context)
.close,
color: Theme
.of(context)
.colorScheme
.primary,
textColor: Theme
.of(context)
.colorScheme
.onPrimaryContainer,
onPressed: onClose,
),
],
),
),
],
),
);
@override
Widget build(BuildContext context) {
final maxHeight = MediaQuery
.of(context)
.size
.height * 0.9;
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
child: Material(
color: Theme
.of(context)
.colorScheme
.surface,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(context),
_buildBody(context),
],
),
),
),
),
);
}
}
class _StandardTile extends StatelessWidget {
const _StandardTile({
required this.itemTitle,
required this.titleStyle,
required this.itemValue,
required this.itemValueStyle,
this.itemSubTitle,
this.itemSubTitleStyle,
this.imagePath,
this.copyButton = false,
this.copyButtonOnPressed,
});
final String itemTitle;
final TextStyle titleStyle;
final String itemValue;
final TextStyle itemValueStyle;
final String? itemSubTitle;
final TextStyle? itemSubTitleStyle;
final String? imagePath;
final bool copyButton;
final VoidCallback? copyButtonOnPressed;
@override
Widget build(BuildContext context) {
return Semantics(
container: true,
label: itemTitle,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: Theme
.of(context)
.colorScheme
.surfaceContainerLowest
.withAlpha(80)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
mainAxisAlignment: copyButton ? MainAxisAlignment.spaceBetween : MainAxisAlignment.start,
children: [
if (imagePath != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: ImageUtil.getImageFromPath(
imagePath: imagePath!, height: 40, width: 40),
),
),
Flexible(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
itemTitle,
style: titleStyle,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: true,
)),
),
SizedBox(width: 8),
if (copyButton)
RoundedIconButton(
icon: Icons.copy_all_outlined,
onPressed: copyButtonOnPressed ?? () {},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(itemValue, style: itemValueStyle.copyWith(height: 1.0)),
if (itemSubTitle != null) Text(itemSubTitle!, style: itemSubTitleStyle),
],
),
],
),
),
);
}
}
@@ -5,14 +5,12 @@ import 'package:hash_wallet/src/widgets/rounded_icon_button.dart';
import 'package:hash_wallet/themes/core/theme_extension.dart';
import 'package:hash_wallet/utils/address_formatter.dart';
import 'package:hash_wallet/utils/image_utill.dart';
import 'package:hash_wallet/view_model/cake_pay/cake_pay_buy_card_view_model.dart';
import 'package:hash_wallet/view_model/send/output.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'base_bottom_sheet_widget.dart';
@@ -26,9 +24,6 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
bool isSlideActionEnabled = true,
String? accessibleNavigationModeSlideActionButtonText,
required this.currency,
this.paymentId,
this.paymentIdValue,
this.expirationTime,
required this.amount,
required this.amountValue,
required this.fiatAmountValue,
@@ -41,8 +36,6 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
this.change,
this.explanation,
this.isOpenCryptoPay = false,
this.cakePayBuyCardViewModel,
this.quantity,
Key? key,
}) : showScrollbar = outputs.length > 3,
super(
@@ -58,9 +51,6 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
key: key);
final CryptoCurrency currency;
final String? paymentId;
final String? paymentIdValue;
final String? expirationTime;
final String amount;
final String amountValue;
final String fiatAmountValue;
@@ -71,8 +61,6 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
final WalletType walletType;
final PendingChange? change;
final bool isOpenCryptoPay;
final CakePayBuyCardViewModel? cakePayBuyCardViewModel;
final String? quantity;
final String? explanation;
final AmountParsingProxy? amountParsingProxy;
@@ -103,26 +91,6 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
padding: EdgeInsets.fromLTRB(8, 0, showScrollbar ? 16 : 8, 8),
child: Column(
children: [
if (paymentId != null && paymentIdValue != null && cakePayBuyCardViewModel != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Observer(
builder: (_) => AddressTile(
itemTitle: paymentId!,
itemTitleTextStyle: itemTitleTextStyle,
amountTextStyle: itemSubTitleTextStyle,
walletType: walletType,
amount: expirationTime != null
? S.current.offer_expires_in +
' ${cakePayBuyCardViewModel!.formattedRemainingTime}'
: null,
address: paymentIdValue!,
itemSubTitleTextStyle: itemSubTitleTextStyle,
tileBackgroundColor: tileBackgroundColor,
applyAddressFormatting: false,
copyButton: true,
)),
),
if (explanation != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
@@ -163,12 +131,11 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
final isBatchSending = outputs.length > 1;
final item = outputs[index];
final contactName = item.parsedAddress.name;
final isCakePayName = contactName == 'Cake Pay';
final batchContactTitle =
'${index + 1}/${outputs.length} - ${contactName.isEmpty ? 'Address' : contactName}';
final _address = item.isParsedAddress ? item.extractedAddress : item.address;
final _amount = '${item.cryptoAmount.replaceAll(',', '.')} ${amountParsingProxy?.getCryptoSymbol(currency) ?? currency.title}';
return isBatchSending || (contactName.isNotEmpty && !isCakePayName)
return isBatchSending || contactName.isNotEmpty
? ExpansionAddressTile(
contactType: isOpenCryptoPay ? 'Open CryptoPay' : S.of(context).contact,
name: isBatchSending ? batchContactTitle : contactName,
@@ -181,15 +148,11 @@ class ConfirmSendingBottomSheet extends BaseBottomSheet {
tileBackgroundColor: tileBackgroundColor,
)
: AddressTile(
itemTitle: isCakePayName
? item.parsedAddress.profileName
: S.of(context).address,
imagePath: isCakePayName ? item.parsedAddress.profileImageUrl : null,
itemTitle: S.of(context).address,
itemTitleTextStyle: itemTitleTextStyle,
walletType: walletType,
amount: isCakePayName ? item.fiatAmount : _amount,
amount: _amount,
address: _address,
itemSubTitle: isCakePayName ? quantity : null,
itemSubTitleTextStyle: itemSubTitleTextStyle,
tileBackgroundColor: tileBackgroundColor,
);
@@ -1,188 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hash_wallet/themes/core/material_base_theme.dart';
class CakePayOrderListCard extends StatelessWidget {
const CakePayOrderListCard({
Key? key,
required this.id,
required this.create,
this.quantity,
this.price,
required this.pair,
required this.onTap,
required this.currentTheme,
this.backgroundImage,
this.aspectRatio = 16 / 9,
}) : super(key: key);
final String id;
final String create;
final String? quantity;
final String? price;
final String pair;
final ThemeType currentTheme;
final void Function(BuildContext) onTap;
final String? backgroundImage;
final double aspectRatio;
ImageProvider? _imageProviderFor(String? path) {
if (path == null || path.isEmpty) return null;
if (path.startsWith('http://') || path.startsWith('https://')) return NetworkImage(path);
return AssetImage(path);
}
Color _labelBackground(BuildContext context) {
final surface = Theme.of(context).colorScheme.surface;
return surface.withOpacity(0.75);
}
Color _titleLabelBackground(BuildContext context) {
final surface = Theme.of(context).colorScheme.surface;
return surface.withOpacity(0.85);
}
Widget _labelBox(
BuildContext context, {
required String text,
EdgeInsets padding = const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
double radius = 10,
Color? background,
TextStyle? style,
}) {
return Container(
padding: padding,
decoration: BoxDecoration(
color: background ?? _labelBackground(context),
borderRadius: BorderRadius.circular(radius),
),
child: Text(
text,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: style ??
Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
@override
Widget build(BuildContext context) {
final image = _imageProviderFor(backgroundImage);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => onTap(context),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: aspectRatio,
child: Stack(
fit: StackFit.expand,
children: [
if (image != null)
Image(image: image, fit: BoxFit.cover)
else
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
if (image != null)
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.6, 1.0],
colors: [Colors.transparent, Colors.black26, Colors.black54],
),
),
),
Padding(
padding: const EdgeInsets.all(14.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_labelBox(
context,
text: 'Create • $create',
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
radius: 8,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
fontSize: 10,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 4),
_labelBox(
context,
text: 'Qty • $quantity Total • $price',
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
radius: 8,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
fontSize: 10,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
_labelBox(
context,
text: pair,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
radius: 12,
background: _titleLabelBackground(context),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 16,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
const Spacer(),
_labelBox(
context,
text: 'Order • $id',
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
radius: 10,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
IgnorePointer(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(currentTheme == ThemeType.dark ? 0.08 : 0.12),
width: 1,
),
),
),
),
],
),
),
),
),
);
}
}
-4
View File
@@ -3,10 +3,6 @@ import 'package:cw_core/utils/tor/disabled.dart';
import 'package:flutter/foundation.dart';
class FeatureFlag {
static const bool isCakePayEnabled = false;
static const bool isCakePayPurchaseSimulationEnabled = true;
static const bool isCakePayRedemptionFlowEnabled = false;
static const bool isExolixEnabled = true;
// Hash Bags: Lightning disabled — running Greenlight/LSP infra is out of
// scope for now. Re-enable once we have an LSP relationship lined up.
static const bool isLightningEnabled = false;
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:hash_wallet/buy/buy_provider.dart';
import 'package:hash_wallet/buy/buy_quote.dart';
import 'package:hash_wallet/buy/onramper/onramper_buy_provider.dart';
import 'package:hash_wallet/buy/payment_method.dart';
import 'package:hash_wallet/buy/sell_buy_states.dart';
import 'package:hash_wallet/core/selectable_option.dart';
@@ -455,18 +454,6 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S
return true;
}).toList();
final List<Quote> successRateQuotes = validQuotes
.where((element) =>
element.provider is OnRamperBuyProvider &&
element.recommendations.contains(ProviderRecommendation.successRate))
.toList();
for (final quote in successRateQuotes) {
if (!uniqueProviderQuotes.contains(quote)) {
uniqueProviderQuotes.add(quote);
}
}
sortedRecommendedQuotes.addAll(uniqueProviderQuotes);
sortedQuotes = ObservableList.of(
@@ -477,12 +464,6 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S
..setIsBestRate = true;
bestRateQuote = sortedRecommendedQuotes.first;
sortedRecommendedQuotes.sort((a, b) {
if (a.provider is OnRamperBuyProvider) return -1;
if (b.provider is OnRamperBuyProvider) return 1;
return 0;
});
final Quote effectiveBestRateQuote =
sortedRecommendedQuotes.reduce((a, b) {
return isBuyAction ? a.rate < b.rate ? a : b : a.rate > b.rate ? a : b;
@@ -1,20 +0,0 @@
import 'package:hash_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:mobx/mobx.dart';
part 'cake_pay_account_view_model.g.dart';
class CakePayAccountViewModel = CakePayAccountViewModelBase with _$CakePayAccountViewModel;
abstract class CakePayAccountViewModelBase with Store {
CakePayAccountViewModelBase({required this.cakePayService}) : email = '' {
cakePayService.getUserEmail().then((email) => this.email = email ?? '');
}
final CakePayService cakePayService;
@observable
String email;
@action
Future<void> logout() async => cakePayService.logout(email);
}
@@ -1,51 +0,0 @@
import 'package:hash_wallet/cake_pay/src/cake_pay_states.dart';
import 'package:hash_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:mobx/mobx.dart';
part 'cake_pay_auth_view_model.g.dart';
class CakePayAuthViewModel = CakePayAuthViewModelBase with _$CakePayAuthViewModel;
abstract class CakePayAuthViewModelBase with Store {
CakePayAuthViewModelBase({required this.cakePayService})
: userVerificationState = CakePayUserVerificationStateInitial(),
otpState = CakePayOtpSendDisabled(),
email = '',
otp = '';
final CakePayService cakePayService;
@observable
CakePayUserVerificationState userVerificationState;
@observable
CakePayOtpState otpState;
@observable
String email;
@observable
String otp;
@action
Future<void> verifyEmail(String code) async {
try {
otpState = CakePayOtpValidating();
await cakePayService.verifyEmail(code);
otpState = CakePayOtpSuccess();
} catch (_) {
otpState = CakePayOtpFailure(error: 'Invalid OTP. Try again');
}
}
@action
Future<void> logIn(String email) async {
try {
userVerificationState = CakePayUserVerificationStateLoading();
await cakePayService.logIn(email);
userVerificationState = CakePayUserVerificationStateSuccess();
} catch (e) {
userVerificationState = CakePayUserVerificationStateFailure(error: e.toString());
}
}
}
@@ -1,267 +0,0 @@
import 'dart:async';
import 'package:hash_wallet/order/order_provider_description.dart';
import 'package:hash_wallet/order/order.dart';
import 'package:hash_wallet/order/order_source_description.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_card.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_vendor.dart';
import 'package:hash_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:hash_wallet/core/execution_state.dart';
import 'package:hash_wallet/exchange/trade_state.dart';
import 'package:hash_wallet/utils/feature_flag.dart';
import 'package:hash_wallet/view_model/send/send_view_model.dart';
import 'package:hash_wallet/view_model/send/send_view_model_state.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:hive/hive.dart';
import 'package:mobx/mobx.dart';
part 'cake_pay_buy_card_view_model.g.dart';
class CakePayBuyCardViewModel = CakePayBuyCardViewModelBase with _$CakePayBuyCardViewModel;
abstract class CakePayBuyCardViewModelBase with Store {
CakePayBuyCardViewModelBase(
{required this.vendor,
required CakePayService cakePayService,
required this.sendViewModel,
required this.orders})
: _cakePayService = cakePayService,
walletType = sendViewModel.walletType,
amount = vendor.card!.denominationItems.isNotEmpty
? vendor.card!.denominationItems.first.value
: 0,
quantity = 1,
card = vendor.card!,
min = _toDouble(vendor.card!.minValue) ?? 0,
max = _toDouble(vendor.card!.maxValue) ?? 0 {
selectedPaymentMethod = availableMethods.isNotEmpty ? availableMethods.first : null;
}
static double? _toDouble(String? value) {
if (value == null || value.isEmpty) return null;
return double.tryParse(value.replaceAll(',', '.'));
}
final CakePayVendor vendor;
final SendViewModel sendViewModel;
final CakePayService _cakePayService;
final double max;
final double min;
final CakePayCard card;
final WalletType walletType;
final Box<Order> orders;
CakePayOrder? order;
Timer? _timer;
DateTime? expirationTime;
Duration? remainingTime;
bool confirmsNoVpn = false;
bool confirmsVoidedRefund = false;
bool confirmsTermsAgreed = false;
(String, int?) selectedDenomination = ('', null);
String simulatedResponse = '';
bool get isDenominationSelected =>
card.denominationItems.isNotEmpty &&
card.denominationItems.any((item) => item.value == amount);
Future<bool> get isUserLogged async => await _cakePayService.isLogged();
@observable
double amount;
@observable
int quantity;
@observable
bool isPurchasing = false;
@observable
bool isSimulatingFlow = false;
@observable
bool isOrderExpired = false;
@observable
String formattedRemainingTime = '';
@computed
bool get isAmountSufficient =>
(amount >= min && amount <= max) || (isDenominationSelected && quantity > 0);
@observable
CakePayPaymentMethod? selectedPaymentMethod;
@computed
double get totalAmount => amount * quantity;
@computed
bool get isSimulating =>
isSimulatingFlow &&
FeatureFlag.hasDevOptions &&
FeatureFlag.isCakePayPurchaseSimulationEnabled;
@computed
List<CakePayPaymentMethod> get availableMethods {
switch (walletType) {
case WalletType.bitcoin:
return [
CakePayPaymentMethod.BTC,
if (sendViewModel.wallet.isSoftwareWallet) CakePayPaymentMethod.BTC_LN
];
case WalletType.litecoin:
return [
CakePayPaymentMethod.LTC,
if (sendViewModel.isMwebEnabled) CakePayPaymentMethod.LTC_MWEB
];
case WalletType.monero:
return [CakePayPaymentMethod.XMR];
default:
return const [];
}
}
@action
void chooseMethod(CakePayPaymentMethod method) {
selectedPaymentMethod = method;
if (walletType == WalletType.litecoin) {
sendViewModel.setAllowMwebCoins(method == CakePayPaymentMethod.LTC_MWEB);
}
}
@action
void onQuantityChanged(int? input) => quantity = input ?? 1;
@action
void onAmountChanged(String input) {
if (input.isEmpty) return;
amount = double.parse(input.replaceAll(',', '.'));
}
@action
Future<void> createOrder() async {
if (![
WalletType.bitcoin,
WalletType.monero,
WalletType.litecoin,
].contains(walletType)) {
sendViewModel.state =
FailureState('Unsupported wallet type, please use Bitcoin, Monero, or Litecoin.');
}
try {
order = await _cakePayService.createOrder(
cardId: isDenominationSelected ? selectedDenomination.$2 ?? card.id : card.id,
price: isDenominationSelected ? selectedDenomination.$1 : amount.toString(),
quantity: quantity,
confirmsNoVpn: confirmsNoVpn,
confirmsVoidedRefund: confirmsVoidedRefund,
confirmsTermsAgreed: confirmsTermsAgreed,
);
final paymentData =
CakePayOrder.getPaymentDataFor(method: selectedPaymentMethod, order: order); // TODO should hande other currencies if added
if (paymentData == null || order == null)
throw Exception('Payment data or order is not available.');
await confirmSending(paymentData);
expirationTime = order!.paymentData.expirationTime;
final orderRecord = Order(
id: order!.orderId,
state: TradeState.deserialize(raw: order!.status),
transferId: order!.externalId ?? '',
from: CakePayOrder.getCurrencyCodeFromPaymentMethod(selectedPaymentMethod!),
to: order!.fiatCurrencyCode,
createdAt: DateTime.now(),
amount: paymentData.amount,
receiveAmount: order!.totalReceiveAmount,
quantity: order!.quantity.toString(),
receiveAddress: paymentData.address,
source: OrderSourceDescription.order,
giftCardProvider: OrderProviderDescription.cakePay,
walletId: sendViewModel.wallet.id);
orders.add(orderRecord);
updateRemainingTime();
_startExpirationTimer();
} catch (e) {
sendViewModel.state = FailureState(
sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency));
}
}
@action
Future<void> confirmSending(CryptoPaymentData paymentData) async {
try {
sendViewModel.clearOutputs();
final output = sendViewModel.outputs.first;
output.address = paymentData.address;
output.setCryptoAmount(paymentData.price);
await sendViewModel.createTransaction();
} catch (e) {
throw e;
}
}
@action
Future<void> simulatePayment() async {
if (order == null) {
throw Exception('Order is not created yet.');
}
try {
simulatedResponse = await _cakePayService.simulatePayment(orderId: order!.orderId);
sendViewModel.state = TransactionCommitted();
} catch (e) {
sendViewModel.state = FailureState(
sendViewModel.translateErrorMessage(e, walletType, sendViewModel.wallet.currency));
}
}
@action
void updateRemainingTime() {
if (expirationTime == null) {
formattedRemainingTime = '';
return;
}
remainingTime = expirationTime!.difference(DateTime.now());
isOrderExpired = remainingTime!.isNegative;
if (isOrderExpired) {
disposeExpirationTimer();
sendViewModel.state = FailureState('Order has expired.');
} else {
formattedRemainingTime = formatDuration(remainingTime!);
}
}
Future<void> logout() async => await _cakePayService.logout();
void _startExpirationTimer() {
_timer?.cancel();
_timer = Timer.periodic(Duration(seconds: 1), (_) {
updateRemainingTime();
});
}
String formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
void disposeExpirationTimer() {
_timer?.cancel();
remainingTime = null;
formattedRemainingTime = '';
expirationTime = null;
}
void dispose() {
disposeExpirationTimer();
}
}
@@ -1,299 +0,0 @@
import 'dart:async';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_card.dart';
import 'package:hash_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:hash_wallet/cake_pay/src/cake_pay_states.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_vendor.dart';
import 'package:hash_wallet/entities/country.dart';
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:hash_wallet/view_model/dashboard/filter_item.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:mobx/mobx.dart';
part 'cake_pay_cards_list_view_model.g.dart';
class CakePayCardsListViewModel = CakePayCardsListViewModelBase with _$CakePayCardsListViewModel;
abstract class CakePayCardsListViewModelBase with Store {
CakePayCardsListViewModelBase({
required this.cakePayService,
required this.settingsStore,
}) : cakePayVendors = [],
userCards = [],
availableCountries = ObservableList<Country>.of(Country.allForCakePay),
page = 1,
displayPrepaidCards = true,
displayGiftCards = true,
displayDenominationsCards = true,
displayCustomValueCards = true,
scrollOffsetFromTop = 0.0,
vendorsState = InitialCakePayVendorLoadingState(),
createCardState = CakePayCreateCardState(),
userCardState = UserCakePayCardsStateInitial(),
searchString = '',
searchMyCardsString = '' {
checkAuth();
initialization();
}
static Country _getInitialCountry(FiatCurrency fiatCurrency) {
if (fiatCurrency.countryCode == 'eur') {
return Country.deu;
}
return Country.fromCode(fiatCurrency.countryCode) ?? Country.usa;
}
void initialization() async {
getVendors();
getUserCards();
}
final CakePayService cakePayService;
final SettingsStore settingsStore;
Map<String, List<FilterItem>> get createFilterItems => {
'Card Type': [
FilterItem(
value: () => displayPrepaidCards,
caption: S.current.prepaid_cards,
onChanged: togglePrepaidCards),
FilterItem(
value: () => displayGiftCards,
caption: S.current.gift_cards,
onChanged: toggleGiftCards),
],
// Uncomment if will be added to backend
// S.current.value_type: [
// FilterItem(
// value: () => displayDenominationsCards,
// caption: S.current.denominations,
// onChanged: toggleDenominationsCards),
// FilterItem(
// value: () => displayCustomValueCards,
// caption: S.current.custom_value,
// onChanged: toggleCustomValueCards),
// ],
};
String searchString;
String? username;
int page;
late Country _initialSelectedCountry;
late bool _initialDisplayPrepaidCards;
late bool _initialDisplayGiftCards;
late bool _initialDisplayDenominationsCards;
late bool _initialDisplayCustomValueCards;
@observable
List<CakePayCard> userCards;
@observable
String searchMyCardsString;
@observable
double scrollOffsetFromTop;
@observable
CakePayCreateCardState createCardState;
@observable
UserCakePayCardsState userCardState;
@observable
CakePayVendorState vendorsState;
@observable
bool hasMoreDataToFetch = true;
@observable
bool isLoadingNextPage = false;
@observable
List<CakePayVendor> cakePayVendors;
@observable
List<Country> availableCountries;
@observable
bool displayPrepaidCards;
@observable
bool displayGiftCards;
@observable
bool displayDenominationsCards;
@observable
bool displayCustomValueCards;
@observable
ObservableFuture<bool>? authFuture;
@computed
bool? get isUserAuthenticated =>
authFuture?.status == FutureStatus.fulfilled ? authFuture?.value : null;
@computed
Country get selectedCountry =>
settingsStore.selectedCakePayCountry ?? _getInitialCountry(settingsStore.fiatCurrency);
@computed
bool get shouldShowCountryPicker =>
settingsStore.selectedCakePayCountry == null && availableCountries.isNotEmpty;
@computed
List<CakePayCard> get filteredUserCards {
final query = searchMyCardsString.trim().toLowerCase();
if (query.isEmpty) return userCards;
return userCards
.where((card) => card.name.toLowerCase().contains(query))
.toList(growable: false);
}
bool get hasFiltersChanged {
return selectedCountry != _initialSelectedCountry ||
displayPrepaidCards != _initialDisplayPrepaidCards ||
displayGiftCards != _initialDisplayGiftCards ||
displayDenominationsCards != _initialDisplayDenominationsCards ||
displayCustomValueCards != _initialDisplayCustomValueCards;
}
Future<void> getUserCards() async {
//Dummy user cards // TODO: fetch from API
userCardState = UserCakePayCardsStateFetching();
try {
await Future.delayed(const Duration(seconds: 2));
final vendorsCard = cakePayVendors
.where((vendor) => vendor.card != null)
.map((vendor) => vendor.card!)
.toList();
userCards = vendorsCard.sublist(0, 10);
vendorsCard.forEach((card) {
if (card.name.toLowerCase().contains('amazon.com')) {
userCards.add(card);
}
});
userCardState = UserCakePayCardsStateSuccess();
if (userCards.isEmpty) {
userCardState = UserCakePayCardsStateNoCards();
}
} catch (e) {
userCardState = UserCakePayCardsStateFailure(error: e.toString());
}
}
@action
Future<void> checkAuth() async {
authFuture = ObservableFuture(cakePayService.isLogged());
final logged = await authFuture!;
if (logged) {
username = await cakePayService.getUserEmail();
}
}
@action
Future<void> getVendors({
String? text,
int? currentPage,
}) async {
vendorsState = CakePayVendorLoadingState();
try {
searchString = text ?? '';
var newVendors = await cakePayService.getVendors(
countryCode: selectedCountry.countryCode,
page: currentPage ?? page,
search: searchString,
giftCards: displayGiftCards,
prepaidCards: displayPrepaidCards);
cakePayVendors = newVendors.where((vendor) {
if (vendor.card == null) return false;
if (vendor.available != true) return false;
return true;
}).toList();
} catch (e) {
printV(e);
}
vendorsState = CakePayVendorLoadedState();
}
@action
Future<void> fetchNextPage() async {
if (vendorsState is CakePayVendorLoadingState || !hasMoreDataToFetch || isLoadingNextPage) {
return;
}
isLoadingNextPage = true;
page++;
try {
var newVendors = await cakePayService.getVendors(
countryCode: selectedCountry.countryCode,
page: page,
search: searchString,
giftCards: displayGiftCards,
prepaidCards: displayPrepaidCards);
cakePayVendors.addAll(newVendors.where((vendor) {
if (vendor.card == null) return false;
if (vendor.available != true) return false;
return true;
}).toList());
} catch (error) {
if (error.toString().contains('detail":"Invalid page."')) {
hasMoreDataToFetch = false;
}
} finally {
isLoadingNextPage = false;
}
}
Future<bool> isCakePayUserAuthenticated() async {
return await cakePayService.isLogged();
}
void resetLoadingNextPageState() {
hasMoreDataToFetch = true;
page = 1;
}
void storeInitialFilterStates() {
_initialSelectedCountry = selectedCountry;
_initialDisplayPrepaidCards = displayPrepaidCards;
_initialDisplayGiftCards = displayGiftCards;
_initialDisplayDenominationsCards = displayDenominationsCards;
_initialDisplayCustomValueCards = displayCustomValueCards;
}
@action
void setSelectedCountry(Country country) {
// just so it triggers the reaction even when selecting the default country
settingsStore.selectedCakePayCountry = null;
settingsStore.selectedCakePayCountry = country;
}
@action
void setMyCardsQuery(String text) => searchMyCardsString = text;
@action
void togglePrepaidCards() => displayPrepaidCards = !displayPrepaidCards;
@action
void toggleGiftCards() => displayGiftCards = !displayGiftCards;
@action
void toggleDenominationsCards() => displayDenominationsCards = !displayDenominationsCards;
@action
void toggleCustomValueCards() => displayCustomValueCards = !displayCustomValueCards;
void setScrollOffsetFromTop(double scrollOffset) {
scrollOffsetFromTop = scrollOffset;
}
}
@@ -1,16 +0,0 @@
import 'package:hash_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:mobx/mobx.dart';
part 'cake_features_view_model.g.dart';
class CakeFeaturesViewModel = CakeFeaturesViewModelBase with _$CakeFeaturesViewModel;
abstract class CakeFeaturesViewModelBase with Store {
final CakePayService _cakePayService;
CakeFeaturesViewModelBase(this._cakePayService);
Future<bool> isIoniaUserAuthenticated() async {
return await _cakePayService.isLogged();
}
}
@@ -4,19 +4,9 @@ import 'package:hash_wallet/core/amount_parsing_proxy.dart';
import 'package:hash_wallet/entities/calculate_fiat_amount.dart';
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/provider/chainflip_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/changenow_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/exolix_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/near_Intents_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/swaptrade_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/sideshift_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/simpleswap_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/stealth_ex_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:hash_wallet/exchange/provider/trocador_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/xoswap_exchange_provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/reactions/wallet_connect.dart';
@@ -51,41 +41,12 @@ abstract class ExchangeTradeViewModelBase with Store {
isSwapsXYZCanSendFromExternal = _checkIfSwapsXYZCanSendFromExternal(tradesStore.trade!, wallet),
items = ObservableList<ExchangeTradeItem>() {
setUpOutput();
// Trades from providers whose implementations were removed keep a null
// _provider; status polling is simply skipped for them.
switch (trade.provider) {
case ExchangeProviderDescription.changeNow:
_provider =
ChangeNowExchangeProvider(settingsStore: sendViewModel.balanceViewModel.settingsStore);
break;
case ExchangeProviderDescription.sideShift:
_provider = SideShiftExchangeProvider();
break;
case ExchangeProviderDescription.simpleSwap:
_provider = SimpleSwapExchangeProvider();
break;
case ExchangeProviderDescription.trocador:
_provider = TrocadorExchangeProvider();
break;
case ExchangeProviderDescription.exolix:
_provider = ExolixExchangeProvider();
break;
case ExchangeProviderDescription.swapTrade:
_provider = SwapTradeExchangeProvider();
break;
case ExchangeProviderDescription.stealthEx:
_provider = StealthExExchangeProvider();
break;
case ExchangeProviderDescription.thorChain:
_provider = ThorChainExchangeProvider();
break;
case ExchangeProviderDescription.chainflip:
_provider = ChainflipExchangeProvider();
break;
case ExchangeProviderDescription.xoSwap:
_provider = XOSwapExchangeProvider();
break;
case ExchangeProviderDescription.swapsXyz:
_provider = SwapsXyzExchangeProvider();
break;
case ExchangeProviderDescription.nearIntents:
_provider = NearIntentsExchangeProvider();
break;
@@ -195,7 +156,6 @@ abstract class ExchangeTradeViewModelBase with Store {
output = sendViewModel.outputs.first;
output.address = trade.inputAddress ?? '';
output.setCryptoAmount(trade.amount);
if (_provider is ThorChainExchangeProvider) output.memo = trade.memo ?? "";
if (trade.isSendAll == true) output.sendAll = true;
}
@@ -211,19 +171,7 @@ abstract class ExchangeTradeViewModelBase with Store {
sendViewModel.selectedCryptoCurrency = selected;
final pendingTransaction =
await sendViewModel.createTransaction(provider: _provider, trade: trade);
if (_provider is SwapsXyzExchangeProvider) {
final hash = pendingTransaction?.evmTxHashFromRawHex ?? pendingTransaction?.id ?? '';
trade.txId = hash;
await trade.save();
}
if (_provider is ThorChainExchangeProvider) {
trade.id = pendingTransaction?.id ?? '';
await trade.save();
}
await sendViewModel.createTransaction(provider: _provider, trade: trade);
}
@action
@@ -23,17 +23,8 @@ import 'package:hash_wallet/exchange/exchange_template.dart';
import 'package:hash_wallet/exchange/exchange_trade_state.dart';
import 'package:hash_wallet/exchange/limits.dart';
import 'package:hash_wallet/exchange/limits_state.dart';
import 'package:hash_wallet/exchange/provider/chainflip_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/letsexchange_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/changenow_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/exolix_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/near_Intents_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/stealth_ex_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/swaptrade_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/trocador_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/xoswap_exchange_provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/exchange/trade_request.dart';
import 'package:hash_wallet/generated/i18n.dart';
@@ -49,7 +40,6 @@ import 'package:hash_wallet/store/settings_store.dart';
import 'package:hash_wallet/store/templates/exchange_template_store.dart';
import 'package:hash_wallet/evm/evm.dart';
import 'package:hash_wallet/reactions/wallet_connect.dart';
import 'package:hash_wallet/utils/feature_flag.dart';
import 'package:hash_wallet/utils/token_utilities.dart';
import 'package:hash_wallet/view_model/contact_list/contact_list_view_model.dart';
import 'package:hash_wallet/view_model/send/fees_view_model.dart';
@@ -197,8 +187,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
final initialProvider = provider;
provider!.checkIsAvailable().then((bool isAvailable) {
if (!isAvailable && provider == initialProvider) {
provider = providerList.firstWhere((provider) => provider is ChangeNowExchangeProvider,
orElse: () => providerList.last);
provider = providerList.last;
_onPairChange();
}
});
@@ -323,20 +312,10 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
// Hash Bags: trimmed to Trocador only. Trocador's aggregator already fronts
// ChangeNow, LetsExchange, Exolix, StealthEx, FixedFloat, Quantex, etc.,
// letting us collect markup as the affiliate. Re-enable individual providers
// here only after registering Such Software affiliate keys per provider.
// letting us collect markup as the affiliate. The individual provider
// implementations were deleted; restore them from Cake Wallet upstream and
// register Such Software affiliate keys per provider before re-adding any.
List<ExchangeProvider> get _allProviders => [
// ChangeNowExchangeProvider(settingsStore: _settingsStore),
// SideShiftExchangeProvider(),
// ChainflipExchangeProvider(),
// if (FeatureFlag.isExolixEnabled) ExolixExchangeProvider(),
// SwapTradeExchangeProvider(),
// LetsExchangeExchangeProvider(),
// StealthExExchangeProvider(),
// XOSwapExchangeProvider(),
// SwapsXyzExchangeProvider(),
// JupiterExchangeProvider(),
// NearIntentsExchangeProvider(),
TrocadorExchangeProvider(
useTorOnly: _useTorOnly, providerStates: _settingsStore.trocadorProviderStates),
];
@@ -1501,16 +1480,6 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
}
void _defineIsReceiveAmountEditable() {
/*if ((provider is ChangeNowExchangeProvider)
&&(depositCurrency == CryptoCurrency.xmr)
&&(receiveCurrency == CryptoCurrency.btc)) {
isReceiveAmountEditable = true;
} else {
isReceiveAmountEditable = false;
}*/
//isReceiveAmountEditable = false;
// isReceiveAmountEditable = selectedProviders.any((provider) => provider is ChangeNowExchangeProvider);
// isReceiveAmountEditable = provider is ChangeNowExchangeProvider || provider is SimpleSwapExchangeProvider;
isReceiveAmountEditable = true;
}
+2 -38
View File
@@ -1,25 +1,16 @@
import 'dart:async';
import 'package:hash_wallet/buy/buy_provider_description.dart';
import 'package:hash_wallet/cake_pay/src/models/cake_pay_order.dart';
import 'package:hash_wallet/cake_pay/src/services/cake_pay_service.dart';
import 'package:hash_wallet/core/utilities.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/order/order.dart';
import 'package:hash_wallet/order/order_provider.dart';
import 'package:hash_wallet/order/order_provider_adapter/cake_pay_order_provider_adapter.dart';
import 'package:hash_wallet/order/order_provider_description.dart';
import 'package:hash_wallet/order/order_source_description.dart';
import 'package:hash_wallet/src/screens/order_details/cake_pay_detail_list_card_item.dart';
import 'package:hash_wallet/src/screens/trade_details/track_trade_list_item.dart';
import 'package:hash_wallet/src/screens/trade_details/trade_details_status_item.dart';
import 'package:hash_wallet/src/screens/transaction_details/standart_list_item.dart';
import 'package:hash_wallet/utils/date_formatter.dart';
import 'package:hash_wallet/utils/show_bar.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive/hive.dart';
import 'package:mobx/mobx.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -32,7 +23,6 @@ abstract class OrderDetailsViewModelBase with Store {
OrderDetailsViewModelBase({
required WalletBase wallet,
required Order orderForDetails,
required this.cakePayService,
required this.orders,
}) : items = ObservableList<StandartListItem>(),
order = orderForDetails {
@@ -44,9 +34,8 @@ abstract class OrderDetailsViewModelBase with Store {
break;
case OrderSourceDescription.order:
if (order.orderProvider == OrderProviderDescription.cakePay) {
_provider = CakePayOrderProviderAdapter(wallet: wallet, cakePayService: cakePayService);
}
// Cake Pay orders: the Cake Pay integration was removed, so stored
// orders are shown from persisted data only (_provider stays null).
break;
}
@@ -61,10 +50,8 @@ abstract class OrderDetailsViewModelBase with Store {
@observable
ObservableList<StandartListItem> items;
final CakePayService cakePayService;
final Box<Order> orders;
OrderProvider? _provider;
List<OrderCard> cards = [];
Timer? timer;
@@ -76,10 +63,6 @@ abstract class OrderDetailsViewModelBase with Store {
final updatedOrderObj = await _provider!.findOrderById(order.id);
final updatedOrder = updatedOrderObj.$1;
if (_provider is CakePayOrderProviderAdapter) {
cards = updatedOrderObj.$2 as List<OrderCard>? ?? [];
}
final existing = orders.values.firstWhereOrNull((e) => e.id == updatedOrder.id);
if (existing != null) {
existing.stateRaw = updatedOrder.stateRaw;
@@ -98,30 +81,11 @@ abstract class OrderDetailsViewModelBase with Store {
}
void _updateItems() {
final dateFormat = DateFormatter.withCurrentLocal();
items.clear();
items.add(
DetailsListStatusItem(title: S.current.trade_details_state, value: order.state.toString()));
if (_provider is CakePayOrderProviderAdapter) {
items.add(CakePayDetailsListCardItem(
title: '',
value: '',
id: order.id,
createdAt: dateFormat.format(order.createdAt),
price: order.receiveAmount,
quantity: order.quantity,
from: order.from ?? '',
to: order.to ?? '',
cards: cards,
onTap: (BuildContext context) {
Clipboard.setData(ClipboardData(text: '${order.id}'));
showBar<void>(context, S.of(context).copied_to_clipboard);
},
));
}
items.add(StandartListItem(title: 'Order provider', value: _provider?.title ?? ''));
final trackUrl = _provider?.trackUrl ?? '';
-347
View File
@@ -21,11 +21,8 @@ import 'package:hash_wallet/entities/template.dart';
import 'package:hash_wallet/entities/transaction_description.dart';
import 'package:hash_wallet/entities/wallet_contact.dart';
import 'package:hash_wallet/evm/evm.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/near_Intents_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/monero/monero.dart';
@@ -47,13 +44,11 @@ import 'package:hash_wallet/view_model/unspent_coins/unspent_coins_list_view_mod
import 'package:hash_wallet/wownero/wownero.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/erc20_token.dart';
import 'package:cw_core/exceptions.dart';
import 'package:cw_core/lnurl.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/transaction_info.dart';
import 'package:cw_core/transaction_priority.dart';
import 'package:cw_core/unspent_coin_type.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_type.dart';
@@ -131,10 +126,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
});
}
// Store trade and provider references for post-commit updates (e.g., Jupiter trade ID update)
Trade? _currentTrade;
ExchangeProvider? _currentProvider;
@observable
ExecutionState state;
@@ -596,8 +587,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
@action
Future<PendingTransaction?> createTransaction({ExchangeProvider? provider, Trade? trade}) async {
_currentTrade = trade;
_currentProvider = provider;
pendingTransaction = null;
try {
@@ -615,184 +604,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
}
}
// Swaps.xyz (EVM) path
if (isEVMWallet && trade != null && provider is SwapsXyzExchangeProvider) {
final routerTo = trade.inputAddress;
final routerData = trade.routerData;
if (routerData != null && routerData != '0x') {
final tokenContract = (trade.sourceTokenAddress ?? '').toLowerCase();
final priority = _settingsStore.getPriority(
walletType, chainId: selectedChainId);
final routerValueWei = BigInt.tryParse(trade.routerValue ?? '0') ??
BigInt.zero;
if (routerTo == null || routerTo.isEmpty) {
state = FailureState('Invalid router address');
return null;
}
try {
final selector = _decodeMethodSelector(routerData);
const transferSig = '0xa9059cbb';
const swapAndExecuteSig = '0x9be111d1';
// Direct Transfer (Simple routing, no approval needed)
if (selector == transferSig) {
pendingTransaction = await evm!.createRawCallDataTransaction(
wallet,
routerTo,
routerData,
BigInt.zero,
priority,
useBlinkProtection: canSupportBlinkProtection(selectedChainId)
? _settingsStore.useBlinkProtection
: false,
);
state = ExecutedSuccessfullyState();
return pendingTransaction;
}
// Smart Swap (Requires Approval)
if (selector == swapAndExecuteSig) {
final requiredAmount = BigInt.tryParse(
(trade.sourceTokenAmountRaw ?? '0').replaceAll('n', '')) ??
BigInt.zero;
final needsApproval = tokenContract.isNotEmpty &&
requiredAmount > BigInt.zero
? await evm!.isApprovalRequired(
wallet, tokenContract, routerTo, requiredAmount)
: false;
printV(
'[Swaps.xyz sending flow] Approval required: $needsApproval for token ${trade
.from?.title} ${trade.from?.tag ??
''} with amount $requiredAmount');
if (needsApproval) {
// USDT Approval Flow (Special Case). We must reset allowance to 0 first.
final isUSDTMainnet = selectedChainId == 1 &&
tokenContract.toLowerCase() ==
'0xdac17f958d2ee523a2206206994597c13d831ec7';
if (isUSDTMainnet) {
final currentAllowance = await evm!.getAllowance(
wallet, tokenContract, routerTo);
if (currentAllowance != null &&
currentAllowance > BigInt.zero) {
printV(
'[Swaps.xyz sending flow] currentAllowance USDT: $currentAllowance. Resetting to 0 before setting new allowance.');
final resetTx = await buildApprovalNeeded(
spender: routerTo,
tokenContract: tokenContract,
requiredAmount: BigInt.zero,
// Approve 0
sourceTokenDecimals: trade.sourceTokenDecimals,
priority: priority
);
if (resetTx != null) {
await resetTx.commit();
final resetConfirmed = await _waitForApprovalUpdate(
tokenContract: tokenContract,
spender: routerTo,
requiredAmount: BigInt.zero, // Wait until it equals 0
waitForExactMatch: true,
);
if (!resetConfirmed) {
state = FailureState(
'Failed to reset USDT allowance. Please try again.');
return null;
}
printV(
'[Swaps.xyz sending flow] USDT allowance reset to 0 confirmed on-chain.');
}
}
}
// Standard Approval Flow
final approvalTx = await buildApprovalNeeded(
spender: routerTo,
tokenContract: tokenContract,
requiredAmount: requiredAmount,
sourceTokenDecimals: trade.sourceTokenDecimals,
priority: priority
);
if (approvalTx == null) {
state = FailureState('Failed to build approval transaction');
return null;
}
pendingTransaction = null;
try {
printV(
'[Swaps.xyz sending flow] Submitting approval transaction for token ${trade
.from?.title} ${trade.from?.tag ?? ''} ');
await approvalTx.commit();
// Wait for the approval to be mined on-chain
final isApproved = await _waitForApprovalUpdate(
tokenContract: tokenContract,
spender: routerTo,
requiredAmount: requiredAmount,
);
if (!isApproved) {
state = FailureState(
'Approval transaction failed or timed out on-chain. Try again.');
return null;
}
printV(
'[Swaps.xyz sending flow] Approval transaction confirmed on-chain. Proceeding with swap execution.');
} catch (e, s) {
printV(
'[Swaps.xyz sending flow] Approval transaction error: $e\n$s');
state = FailureState(
translateErrorMessage(e, wallet.type, wallet.currency));
return null;
}
}
// Construct Final Swap Transaction
printV('[Swaps.xyz sending flow] Building swap transaction');
pendingTransaction = await evm!.createRawCallDataTransaction(
wallet,
routerTo,
routerData,
routerValueWei,
priority,
sourceTokenAddress: tokenContract,
sourceTokenAmount: requiredAmount,
useBlinkProtection: canSupportBlinkProtection(selectedChainId)
? _settingsStore.useBlinkProtection
: false,
);
state = ExecutedSuccessfullyState();
return pendingTransaction;
}
state = FailureState('Unsupported Swaps.xyz transaction type');
return null;
} catch (e, s) {
printV('Swaps.xyz transaction error: $e\n$s');
state = FailureState(
'Failed to create Swaps.xyz transaction - ${translateErrorMessage(
e, wallet.type, wallet.currency)}');
return null;
}
}
}
// END Swaps.xyz path
// Regular flow
@@ -823,16 +634,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
}
}
if (provider is ThorChainExchangeProvider) {
final outputCount = pendingTransaction?.outputCount ?? 0;
if (outputCount > 10) {
throw Exception("THORChain does not support more than 10 outputs");
}
if (_hasTaprootInput(pendingTransaction)) {
throw Exception("THORChain does not support Taproot addresses");
}
}
}
@@ -936,13 +737,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
state = TransactionCommitted();
if (_currentTrade != null) {
final provider = _currentTrade!.provider;
if (provider == ExchangeProviderDescription.swapsXyz) {
registerSwapsXyzTransaction(_currentTrade!);
}
}
// Immediate transaction update for EVM chains and Nano
if (isEVMWallet || [WalletType.bitcoin, WalletType.nano].contains(walletType)) {
Future.delayed(Duration(seconds: 4), () async {
@@ -1121,88 +915,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
return null;
}
// Helper functions for EVM transaction monitoring and approval flow
// Polls the token contract directly to see if allowance is updated.
Future<bool> _waitForApprovalUpdate({
required String tokenContract,
required String spender,
required BigInt requiredAmount,
bool waitForExactMatch = false,
}) async {
if (!isEVMWallet || evm == null) return false;
int attempts = 0;
const int maxAttempts = 30; // ~60 seconds
printV('[Swaps.xyz sending flow] Starting allowance check. Target: $requiredAmount (Exact match: $waitForExactMatch)');
while (attempts < maxAttempts) {
try {
final currentAllowance = await evm!.getAllowance(wallet, tokenContract, spender);
if (currentAllowance != null) {
printV('[Swaps.xyz sending flow] Current Allowance: $currentAllowance / Target: $requiredAmount');
if (waitForExactMatch) {
// For Reset (Target 0): We need it to be exactly 0 (or less, though it can't be negative)
if (currentAllowance <= requiredAmount) {
printV('[Swaps.xyz sending flow] Allowance reset verified!');
return true;
}
} else {
// For Approval: We need it to be at least the required amount
if (currentAllowance >= requiredAmount) {
printV('[Swaps.xyz sending flow] Allowance verified!');
return true;
}
}
}
} catch (e) {
printV('[Swaps.xyz sending flow] Allowance check error: $e');
}
await Future.delayed(const Duration(seconds: 1));
attempts++;
}
printV('[Swaps.xyz sending flow] Allowance check timed out.');
return false;
}
// Builds a token approval transaction
Future<PendingTransaction?> buildApprovalNeeded({
required String spender,
required String tokenContract,
required BigInt requiredAmount,
required TransactionPriority? priority,
int? sourceTokenDecimals,
}) async {
final erc20Token = wallet.balance.keys.whereType<Erc20Token>().firstWhere(
(t) => t.contractAddress.toLowerCase() == tokenContract.toLowerCase(),
orElse: () => Erc20Token(
name: '',
symbol: '',
contractAddress: tokenContract,
decimal: sourceTokenDecimals ?? 18,
enabled: true,
),
);
return await evm!.createTokenApproval(
wallet,
requiredAmount,
spender,
erc20Token,
priority,
useBlinkProtection:
canSupportBlinkProtection(selectedChainId) ? _settingsStore.useBlinkProtection : false,
);
}
// End EVM helper functions
String translateErrorMessage(
Object error,
WalletType walletType,
@@ -1322,63 +1034,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
return null;
}
bool _hasTaprootInput(PendingTransaction? pendingTransaction) {
if (walletType == WalletType.bitcoin && pendingTransaction != null) {
return bitcoin!.hasTaprootInput(pendingTransaction);
}
return false;
}
Future<void> registerSwapsXyzTransaction(Trade trade) async {
try {
// register only for vmId is alt-vm or bridgeId is alt-vm (trade.needToRegisterInSwapXyz)
final needToRegister = trade.needToRegisterInSwapXyz ?? false;
if (!needToRegister) return;
final vmId = (trade.providerId ?? '').toLowerCase();
if (vmId.isEmpty) {
printV('SwapsXyz: transaction register: skipped (vmId empty)');
return;
}
final txHash = pendingTransaction?.evmTxHashFromRawHex
?? pendingTransaction?.id
?? '';
if (txHash.isEmpty) {
printV('SwapsXyz: transaction register: skipped (txHash empty)');
return;
}
final chainId = int.tryParse(trade.router ?? '') ?? 0;
if (chainId <= 0) {
printV('SwapsXyz: transaction register: skipped (invalid chainId)');
return;
}
printV(
'SwapsXyz: attempting to register transaction: tradeId = ${trade.id}, txHash = $txHash, chainId = $chainId, vmId = $vmId');
final registered = await SwapsXyzExchangeProvider.registerAltVmTx(
txId: trade.id,
txHash: txHash,
chainId: chainId,
vmId: vmId,
);
if (!registered) {
printV('SwapsXyz: transaction register: failed');
} else {
printV('SwapsXyz: transaction register: success');
}
} catch (e) {
printV('registerSwapsXyzTransaction error: $e');
}
}
@computed
bool get usePayjoin => _settingsStore.usePayjoin;
@@ -1397,6 +1052,4 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
}
}
String _decodeMethodSelector(String s) =>
(s.startsWith('0x') && s.length >= 10) ? s.substring(0, 10) : '';
}
+2 -43
View File
@@ -1,20 +1,9 @@
import 'dart:async';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/provider/chainflip_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/changenow_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/exolix_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/letsexchange_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/near_Intents_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/swaptrade_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/sideshift_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/simpleswap_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/stealth_ex_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:hash_wallet/exchange/provider/trocador_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/xoswap_exchange_provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/screens/trade_details/track_trade_list_item.dart';
@@ -40,42 +29,12 @@ abstract class TradeDetailsViewModelBase with Store {
required this.appStore,
}) : items = ObservableList<StandartListItem>(),
trade = tradeForDetails {
// Trades from providers whose implementations were removed keep a null
// _provider and show the "provider unsupported" item with stored data.
switch (trade.provider) {
case ExchangeProviderDescription.changeNow:
_provider = ChangeNowExchangeProvider(settingsStore: appStore.settingsStore);
break;
case ExchangeProviderDescription.sideShift:
_provider = SideShiftExchangeProvider();
break;
case ExchangeProviderDescription.simpleSwap:
_provider = SimpleSwapExchangeProvider();
break;
case ExchangeProviderDescription.trocador:
_provider = TrocadorExchangeProvider();
break;
case ExchangeProviderDescription.exolix:
_provider = ExolixExchangeProvider();
break;
case ExchangeProviderDescription.thorChain:
_provider = ThorChainExchangeProvider();
break;
case ExchangeProviderDescription.swapTrade:
_provider = SwapTradeExchangeProvider();
case ExchangeProviderDescription.letsExchange:
_provider = LetsExchangeExchangeProvider();
break;
case ExchangeProviderDescription.stealthEx:
_provider = StealthExExchangeProvider();
break;
case ExchangeProviderDescription.chainflip:
_provider = ChainflipExchangeProvider();
break;
case ExchangeProviderDescription.xoSwap:
_provider = XOSwapExchangeProvider();
break;
case ExchangeProviderDescription.swapsXyz:
_provider = SwapsXyzExchangeProvider();
break;
case ExchangeProviderDescription.nearIntents:
_provider = NearIntentsExchangeProvider();
break;