Integrate LetsExchange exchange provider (#1562)

* letsExchange provider

* add api key

* secrets affiliateId

* Update letsexchange_exchange_provider.dart

* minor fix [skip ci]

* fix network type issue

* tracking link [skip ci]

* fix data type

* normalise bch address

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
Serhii 2024-09-11 05:14:17 +03:00 committed by GitHub
parent 215e785198
commit 7d11d0461f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 400 additions and 4 deletions

View file

@ -168,6 +168,8 @@ jobs:
echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart
echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart
echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart
echo "const letsExchangeBearerToken = '${{ secrets.LETS_EXCHANGE_TOKEN }}';" >> lib/.secrets.g.dart
echo "const letsExchangeAffiliateId = '${{ secrets.LETS_EXCHANGE_AFFILIATE_ID }}';" >> lib/.secrets.g.dart
echo "const stealthExBearerToken = '${{ secrets.STEALTH_EX_BEARER_TOKEN }}';" >> lib/.secrets.g.dart
echo "const stealthExAdditionalFeePercent = '${{ secrets.STEALTH_EX_ADDITIONAL_FEE_PERCENT }}';" >> lib/.secrets.g.dart

View file

@ -154,6 +154,8 @@ jobs:
echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart
echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart
echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart
echo "const letsExchangeBearerToken = '${{ secrets.LETS_EXCHANGE_TOKEN }}';" >> lib/.secrets.g.dart
echo "const letsExchangeAffiliateId = '${{ secrets.LETS_EXCHANGE_AFFILIATE_ID }}';" >> lib/.secrets.g.dart
echo "const stealthExBearerToken = '${{ secrets.STEALTH_EX_BEARER_TOKEN }}';" >> lib/.secrets.g.dart
echo "const stealthExAdditionalFeePercent = '${{ secrets.STEALTH_EX_ADDITIONAL_FEE_PERCENT }}';" >> lib/.secrets.g.dart

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M16 1.37854C16 0.764286 16.6636 0.379192 17.1969 0.68395L23 4L29.4961 7.71208C29.8077 7.89012 30 8.22147 30 8.58032V16L23.9923 12.567C23.3774 12.2157 22.6226 12.2157 22.0077 12.567L16 16V8V1.37854ZM2 16V8.58032C2 8.22147 2.19229 7.89012 2.50386 7.71208L8.00772 4.56702C8.62259 4.21566 9.37741 4.21566 9.99228 4.56702L16 8L2 16ZM16 30.6215C16 31.2357 15.3364 31.6208 14.8031 31.3161L9 28L2.50386 24.2879C2.19229 24.1099 2 23.7785 2 23.4197V16L8.00772 19.433C8.62259 19.7843 9.37741 19.7843 9.99228 19.433L16 16V24V30.6215ZM22.0077 27.433C22.6226 27.7843 23.3774 27.7843 23.9923 27.433L29.4961 24.2879C29.8077 24.1099 30 23.7785 30 23.4197V16L16 24L22.0077 27.433Z"
fill="#159DFF"></path>
</svg>

After

Width:  |  Height:  |  Size: 846 B

View file

@ -27,8 +27,10 @@ class ExchangeProviderDescription extends EnumerableItem<int> with Serializable<
ExchangeProviderDescription(title: 'ThorChain', raw: 8, image: 'assets/images/thorchain.png');
static const quantex =
ExchangeProviderDescription(title: 'Quantex', raw: 9, image: 'assets/images/quantex.png');
static const letsExchange =
ExchangeProviderDescription(title: 'LetsExchange', raw: 10, image: 'assets/images/letsexchange_icon.svg');
static const stealthEx =
ExchangeProviderDescription(title: 'StealthEx', raw: 10, image: 'assets/images/stealthex.png');
ExchangeProviderDescription(title: 'StealthEx', raw: 11, image: 'assets/images/stealthex.png');
static ExchangeProviderDescription deserialize({required int raw}) {
switch (raw) {
@ -53,6 +55,8 @@ class ExchangeProviderDescription extends EnumerableItem<int> with Serializable<
case 9:
return quantex;
case 10:
return letsExchange;
case 11:
return stealthEx;
default:
throw Exception('Unexpected token: $raw for ExchangeProviderDescription deserialize');

View file

@ -0,0 +1,292 @@
import 'dart:convert';
import 'dart:developer';
import 'package:cake_wallet/.secrets.g.dart' as secrets;
import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
import 'package:cake_wallet/exchange/exchange_provider_description.dart';
import 'package:cake_wallet/exchange/limits.dart';
import 'package:cake_wallet/exchange/trade.dart';
import 'package:cake_wallet/exchange/trade_not_created_exception.dart';
import 'package:cake_wallet/exchange/trade_request.dart';
import 'package:cake_wallet/exchange/trade_state.dart';
import 'package:cake_wallet/exchange/utils/currency_pairs_utils.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:http/http.dart' as http;
class LetsExchangeExchangeProvider extends ExchangeProvider {
LetsExchangeExchangeProvider() : super(pairList: supportedPairs(_notSupported));
static const List<CryptoCurrency> _notSupported = [];
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
};
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
};
final responseJSON = await _getInfo(params, isFixedRateMode);
final amountToGet = double.tryParse(responseJSON['amount'] as String) ?? 0.0;
return isFixedRateMode ? amount / amountToGet : amountToGet / amount;
} catch (e) {
log(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
};
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
};
final headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': apiKey
};
final uri = Uri.https(_baseUrl,
isFixedRateMode ? _createTransactionRevertPath : _createTransactionPath, tradeParams);
final response = await http.post(uri, headers: headers);
if (response.statusCode != 200) {
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;
final createdAtString = responseJSON['created_at'] as String;
final expiredAtTimestamp = responseJSON['expired_at'] as int;
final createdAt = DateTime.parse(createdAtString);
final expiredAt = DateTime.fromMillisecondsSinceEpoch(expiredAtTimestamp * 1000);
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);
}
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,
);
} catch (e) {
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 http.get(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>;
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;
final createdAtString = responseJSON['created_at'] as String;
final expiredAtTimestamp = responseJSON['expired_at'] as int;
final createdAt = DateTime.parse(createdAtString);
final expiredAt = DateTime.fromMillisecondsSinceEpoch(expiredAtTimestamp * 1000);
return Trade(
id: id,
from: CryptoCurrency.fromString(from),
to: CryptoCurrency.fromString(to),
provider: description,
inputAddress: depositAddress,
payoutAddress: payoutAddress,
refundAddress: refundAddress,
amount: depositAmount,
receiveAmount: receiveAmount,
state: TradeState.deserialize(raw: status),
createdAt: createdAt,
expiredAt: expiredAt,
isRefund: status == 'refund',
);
}
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, params);
final response = await http.post(uri, headers: headers);
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 'POLY':
return 'MATIC';
default:
return currency.tag!;
}
}
return currency.title;
}
String _normalizeBchAddress(String address) =>
address.startsWith('bitcoincash:') ? address.substring(12) : address;
}

View file

@ -69,7 +69,7 @@ class StealthExExchangeProvider extends ExchangeProvider {
throw Exception('StealthEx fetch limits failed: ${response.body}');
}
final responseJSON = json.decode(response.body) as Map<String, dynamic>;
final min = responseJSON['min_amount'] as double?;
final min = toDouble(responseJSON['min_amount']);
final max = responseJSON['max_amount'] as double?;
return Limits(min: min, max: max);
} catch (e) {

View file

@ -106,6 +106,7 @@ class TradeState extends EnumerableItem<String> with Serializable<String> {
case 'waitingAuthorization':
return waitingAuthorization;
case 'failed':
case 'error':
return failed;
case 'completed':
return completed;
@ -125,6 +126,7 @@ class TradeState extends EnumerableItem<String> with Serializable<String> {
case 'exchanging':
return exchanging;
case 'sending':
case 'sending_confirmation':
return sending;
case 'success':
case 'done':

View file

@ -1,4 +1,5 @@
import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:flutter/material.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cake_wallet/exchange/exchange_provider_description.dart';
@ -36,7 +37,8 @@ class TradeRow extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(50),
child: Image.asset(provider.image, width: 36, height: 36)),
child: ImageUtil.getImageFromPath(
imagePath: provider.image, height: 36, width: 36)),
SizedBox(width: 12),
Expanded(
child: Column(

View file

@ -2,6 +2,7 @@ import 'package:cake_wallet/themes/extensions/cake_text_theme.dart';
import 'package:cake_wallet/exchange/exchange_provider_description.dart';
import 'package:cake_wallet/store/dashboard/trades_store.dart';
import 'package:cake_wallet/themes/extensions/exchange_page_theme.dart';
import 'package:cake_wallet/utils/image_utill.dart';
import 'package:cake_wallet/utils/show_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
@ -101,7 +102,8 @@ class ExchangeConfirmPage extends BasePage {
mainAxisAlignment: MainAxisAlignment.center,
children: [
(trade.provider.image?.isNotEmpty ?? false)
? Image.asset(trade.provider.image, height: 50)
? ImageUtil.getImageFromPath(
imagePath: trade.provider.image, width: 50)
: const SizedBox(),
if (!trade.provider.horizontalLogo)
Padding(

View file

@ -17,6 +17,7 @@ abstract class TradeFilterStoreBase with Store {
displayTrocador = true,
displayExolix = true,
displayThorChain = true,
displayLetsExchange = true,
displayStealthEx = true;
@observable
@ -43,6 +44,9 @@ abstract class TradeFilterStoreBase with Store {
@observable
bool displayThorChain;
@observable
bool displayLetsExchange;
@observable
bool displayStealthEx;
@ -54,6 +58,7 @@ abstract class TradeFilterStoreBase with Store {
displayTrocador &&
displayExolix &&
displayThorChain &&
displayLetsExchange &&
displayStealthEx;
@action
@ -83,6 +88,8 @@ abstract class TradeFilterStoreBase with Store {
case ExchangeProviderDescription.thorChain:
displayThorChain = !displayThorChain;
break;
case ExchangeProviderDescription.letsExchange:
displayLetsExchange = !displayLetsExchange;
case ExchangeProviderDescription.stealthEx:
displayStealthEx = !displayStealthEx;
break;
@ -96,6 +103,7 @@ abstract class TradeFilterStoreBase with Store {
displayTrocador = false;
displayExolix = false;
displayThorChain = false;
displayLetsExchange = false;
displayStealthEx = false;
} else {
displayChangeNow = true;
@ -106,6 +114,7 @@ abstract class TradeFilterStoreBase with Store {
displayTrocador = true;
displayExolix = true;
displayThorChain = true;
displayLetsExchange = true;
displayStealthEx = true;
}
break;
@ -134,6 +143,8 @@ abstract class TradeFilterStoreBase with Store {
(displayExolix && item.trade.provider == ExchangeProviderDescription.exolix) ||
(displayThorChain &&
item.trade.provider == ExchangeProviderDescription.thorChain) ||
(displayLetsExchange &&
item.trade.provider == ExchangeProviderDescription.letsExchange) ||
(displayStealthEx && item.trade.provider == ExchangeProviderDescription.stealthEx))
.toList()
: _trades;

View file

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
class ImageUtil {
static Widget getImageFromPath({required String imagePath, double? height, double? width}) {
final bool isNetworkImage = imagePath.startsWith('http') || imagePath.startsWith('https');
final bool isSvg = imagePath.endsWith('.svg');
final double _height = height ?? 35;
final double _width = width ?? 35;
if (isNetworkImage) {
return isSvg
? SvgPicture.network(
imagePath,
height: _height,
width: _width,
placeholderBuilder: (BuildContext context) => Container(
height: _height,
width: _width,
child: Center(
child: CircularProgressIndicator(),
),
),
)
: Image.network(
imagePath,
height: _height,
width: _width,
loadingBuilder:
(BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Container(
height: _height,
width: _width,
child: Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
),
);
},
errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) {
return Container(
height: _height,
width: _width,
);
},
);
} else {
return isSvg
? SvgPicture.asset(imagePath, height: _height, width: _width)
: Image.asset(imagePath, height: _height, width: _width);
}
}
}

View file

@ -129,6 +129,11 @@ abstract class DashboardViewModelBase with Store {
caption: ExchangeProviderDescription.thorChain.title,
onChanged: () =>
tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.thorChain)),
FilterItem(
value: () => tradeFilterStore.displayLetsExchange,
caption: ExchangeProviderDescription.letsExchange.title,
onChanged: () =>
tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.letsExchange)),
FilterItem(
value: () => tradeFilterStore.displayStealthEx,
caption: ExchangeProviderDescription.stealthEx.title,

View file

@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:cake_wallet/core/create_trade_result.dart';
import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/stealth_ex_exchange_provider.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/sync_status.dart';
@ -167,6 +168,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
ThorChainExchangeProvider(tradesStore: trades),
if (FeatureFlag.isExolixEnabled) ExolixExchangeProvider(),
QuantexExchangeProvider(),
LetsExchangeExchangeProvider(),
StealthExExchangeProvider(),
TrocadorExchangeProvider(
useTorOnly: _useTorOnly, providerStates: _settingsStore.trocadorProviderStates),

View file

@ -4,6 +4,7 @@ import 'package:cake_wallet/exchange/exchange_provider_description.dart';
import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/quantex_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart';
@ -60,6 +61,8 @@ abstract class TradeDetailsViewModelBase with Store {
break;
case ExchangeProviderDescription.quantex:
_provider = QuantexExchangeProvider();
case ExchangeProviderDescription.letsExchange:
_provider = LetsExchangeExchangeProvider();
break;
case ExchangeProviderDescription.stealthEx:
_provider = StealthExExchangeProvider();
@ -90,6 +93,8 @@ abstract class TradeDetailsViewModelBase with Store {
return 'https://track.ninerealms.com/${trade.id}';
case ExchangeProviderDescription.quantex:
return 'https://myquantex.com/send/${trade.id}';
case ExchangeProviderDescription.letsExchange:
return 'https://letsexchange.io/?transactionId=${trade.id}';
case ExchangeProviderDescription.stealthEx:
return 'https://stealthex.io/exchange/?id=${trade.id}';
}

View file

@ -43,6 +43,8 @@ class SecretKey {
SecretKey('cakePayApiKey', () => ''),
SecretKey('CSRFToken', () => ''),
SecretKey('authorization', () => ''),
SecretKey('letsExchangeBearerToken', () => ''),
SecretKey('letsExchangeAffiliateId', () => ''),
SecretKey('stealthExBearerToken', () => ''),
SecretKey('stealthExAdditionalFeePercent', () => ''),
];