From 6378d052ac18ddfa858690277b425c872c8a708c Mon Sep 17 00:00:00 2001 From: Godwin Asuquo <41484542+godilite@users.noreply.github.com> Date: Wed, 13 Apr 2022 14:28:21 +0100 Subject: [PATCH] Cw 72 implement sideshift exchange (#332) * add sideshift exchange provider * add secret key * Fix issues * Fix issues * refactor code * add permission checks to side shift * fix formatting issues --- assets/images/sideshift.png | Bin 0 -> 4073 bytes .../exchange_provider_description.dart | 5 + .../sideshift_exchange_provider.dart | 265 ++++++++++++++++++ lib/exchange/sideshift/sideshift_request.dart | 17 ++ lib/exchange/trade_state.dart | 3 +- .../screens/dashboard/widgets/trade_row.dart | 3 + .../widgets/present_provider_picker.dart | 3 + .../exchange/exchange_view_model.dart | 16 +- lib/view_model/trade_details_view_model.dart | 10 + tool/utils/secret_key.dart | 2 + 10 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 assets/images/sideshift.png create mode 100644 lib/exchange/sideshift/sideshift_exchange_provider.dart create mode 100644 lib/exchange/sideshift/sideshift_request.dart diff --git a/assets/images/sideshift.png b/assets/images/sideshift.png new file mode 100644 index 0000000000000000000000000000000000000000..9d2c109708a2701ac71a1d6dbc4e986d7cec50ed GIT binary patch literal 4073 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 G@qYmQ$Stq{ literal 0 HcmV?d00001 diff --git a/lib/exchange/exchange_provider_description.dart b/lib/exchange/exchange_provider_description.dart index cae5a5683..ded72a2f9 100644 --- a/lib/exchange/exchange_provider_description.dart +++ b/lib/exchange/exchange_provider_description.dart @@ -11,6 +11,9 @@ class ExchangeProviderDescription extends EnumerableItem static const morphToken = ExchangeProviderDescription(title: 'MorphToken', raw: 2); + static const sideShift = + ExchangeProviderDescription(title: 'SideShift', raw: 3); + static ExchangeProviderDescription deserialize({int raw}) { switch (raw) { case 0: @@ -19,6 +22,8 @@ class ExchangeProviderDescription extends EnumerableItem return changeNow; case 2: return morphToken; + case 3: + return sideShift; default: return null; } diff --git a/lib/exchange/sideshift/sideshift_exchange_provider.dart b/lib/exchange/sideshift/sideshift_exchange_provider.dart new file mode 100644 index 000000000..d4db3dcae --- /dev/null +++ b/lib/exchange/sideshift/sideshift_exchange_provider.dart @@ -0,0 +1,265 @@ +import 'dart:convert'; + +import 'package:cake_wallet/exchange/exchange_pair.dart'; +import 'package:cake_wallet/exchange/exchange_provider.dart'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; +import 'package:cake_wallet/exchange/sideshift/sideshift_request.dart'; +import 'package:cake_wallet/exchange/trade_not_created_exeption.dart'; +import 'package:cake_wallet/exchange/trade_not_found_exeption.dart'; +import 'package:cake_wallet/exchange/trade_state.dart'; +import 'package:cake_wallet/.secrets.g.dart' as secrets; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cake_wallet/exchange/trade_request.dart'; +import 'package:cake_wallet/exchange/trade.dart'; +import 'package:cake_wallet/exchange/limits.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart'; + +class SideShiftExchangeProvider extends ExchangeProvider { + SideShiftExchangeProvider() + : super( + pairList: CryptoCurrency.all + .map((i) => CryptoCurrency.all + .map((k) => ExchangePair(from: i, to: k, reverse: true)) + .where((c) => c != null)) + .expand((i) => i) + .toList()); + + static const apiKey = secrets.sideShiftApiKey; + static const affiliateId = secrets.sideShiftAffiliateId; + static const apiBaseUrl = 'https://sideshift.ai/api'; + static const rangePath = '/v1/pairs'; + static const orderPath = '/v1/orders'; + static const quotePath = '/v1/quotes'; + static const permissionPath = '/v1/permissions'; + static const apiHeaderKey = 'x-sideshift-secret'; + + @override + ExchangeProviderDescription get description => + ExchangeProviderDescription.sideShift; + + @override + Future calculateAmount( + {CryptoCurrency from, + CryptoCurrency to, + double amount, + bool isFixedRateMode, + bool isReceiveAmount}) async { + try { + if (amount == 0) { + return 0.0; + } + final fromCurrency = normalizeCryptoCurrency(from); + final toCurrency = normalizeCryptoCurrency(to); + final url = + apiBaseUrl + rangePath + '/' + fromCurrency + '/' + toCurrency; + final response = await get(url); + final responseJSON = json.decode(response.body) as Map; + final rate = double.parse(responseJSON['rate'] as String); + final max = double.parse(responseJSON['max'] as String); + + if (amount > max) return 0.00; + + final estimatedAmount = rate * amount; + + return estimatedAmount; + } catch (_) { + return 0.00; + } + } + + @override + Future checkIsAvailable() async { + const url = apiBaseUrl + permissionPath; + final response = await get(url); + + if (response.statusCode == 500) { + final responseJSON = json.decode(response.body) as Map; + final error = responseJSON['error']['message'] as String; + + throw Exception('$error'); + } + + if (response.statusCode != 200) { + return false; + } + + final responseJSON = json.decode(response.body) as Map; + final canCreateOrder = responseJSON['createOrder'] as bool; + final canCreateQuote = responseJSON['createQuote'] as bool; + return canCreateOrder && canCreateQuote; + } + + @override + Future createTrade( + {TradeRequest request, bool isFixedRateMode}) async { + final _request = request as SideShiftRequest; + final quoteId = await _createQuote(_request); + final url = apiBaseUrl + orderPath; + final headers = {apiHeaderKey: apiKey, 'Content-Type': 'application/json'}; + final body = { + 'type': 'fixed', + 'quoteId': quoteId, + 'affiliateId': affiliateId, + 'settleAddress': _request.settleAddress, + 'refundAddress': _request.refundAddress + }; + final response = await post(url, headers: headers, body: json.encode(body)); + + if (response.statusCode != 201) { + if (response.statusCode == 400) { + final responseJSON = json.decode(response.body) as Map; + final error = responseJSON['error']['message'] as String; + + throw TradeNotCreatedException(description, description: error); + } + + throw TradeNotCreatedException(description); + } + + final responseJSON = json.decode(response.body) as Map; + final id = responseJSON['id'] as String; + final inputAddress = responseJSON['depositAddress']['address'] as String; + final settleAddress = responseJSON['settleAddress']['address'] as String; + + return Trade( + id: id, + provider: description, + from: _request.depositMethod, + to: _request.settleMethod, + inputAddress: inputAddress, + refundAddress: settleAddress, + state: TradeState.created, + amount: _request.depositAmount, + createdAt: DateTime.now(), + ); + } + + Future _createQuote(SideShiftRequest request) async { + final url = apiBaseUrl + quotePath; + final headers = {apiHeaderKey: apiKey, 'Content-Type': 'application/json'}; + final depositMethod = normalizeCryptoCurrency(request.depositMethod); + final settleMethod = normalizeCryptoCurrency(request.settleMethod); + final body = { + 'depositMethod': depositMethod, + 'settleMethod': settleMethod, + 'affiliateId': affiliateId, + 'depositAmount': request.depositAmount, + }; + final response = await post(url, headers: headers, body: json.encode(body)); + + if (response.statusCode != 201) { + if (response.statusCode == 400) { + final responseJSON = json.decode(response.body) as Map; + final error = responseJSON['error']['message'] as String; + + throw TradeNotCreatedException(description, description: error); + } + + throw TradeNotCreatedException(description); + } + + final responseJSON = json.decode(response.body) as Map; + final quoteId = responseJSON['id'] as String; + + return quoteId; + } + + @override + Future fetchLimits( + {CryptoCurrency from, CryptoCurrency to, bool isFixedRateMode}) async { + final fromCurrency = normalizeCryptoCurrency(from); + final toCurrency = normalizeCryptoCurrency(to); + final url = apiBaseUrl + rangePath + '/' + fromCurrency + '/' + toCurrency; + final response = await get(url); + + if (response.statusCode == 500) { + final responseJSON = json.decode(response.body) as Map; + final error = responseJSON['error']['message'] as String; + + throw Exception('$error'); + } + + if (response.statusCode != 200) { + return null; + } + + final responseJSON = json.decode(response.body) as Map; + final min = double.parse(responseJSON['min'] as String); + final max = double.parse(responseJSON['max'] as String); + + return Limits(min: min, max: max); + } + + @override + Future findTradeById({@required String id}) async { + final url = apiBaseUrl + orderPath + '/' + id; + final response = await get(url); + + if (response.statusCode == 404) { + throw TradeNotFoundException(id, provider: description); + } + + if (response.statusCode == 400) { + final responseJSON = json.decode(response.body) as Map; + final error = responseJSON['error']['message'] as String; + + throw TradeNotFoundException(id, + provider: description, description: error); + } + + if (response.statusCode != 200) { + return null; + } + + final responseJSON = json.decode(response.body) as Map; + final fromCurrency = responseJSON['depositMethodId'] as String; + final from = CryptoCurrency.fromString(fromCurrency); + final toCurrency = responseJSON['settleMethodId'] as String; + final to = CryptoCurrency.fromString(toCurrency); + final inputAddress = responseJSON['depositAddress']['address'] as String; + final expectedSendAmount = responseJSON['depositAmount'].toString(); + final deposits = responseJSON['deposits'] as List; + TradeState state; + + if (deposits != null && deposits.isNotEmpty) { + final status = deposits[0]['status'] as String; + state = TradeState.deserialize(raw: status); + } + + final expiredAtRaw = responseJSON['expiresAtISO'] as String; + final expiredAt = + expiredAtRaw != null ? DateTime.parse(expiredAtRaw).toLocal() : null; + + return Trade( + id: id, + from: from, + to: to, + provider: description, + inputAddress: inputAddress, + amount: expectedSendAmount, + state: state, + expiredAt: expiredAt, + ); + } + + @override + bool get isAvailable => true; + + @override + String get title => 'SideShift'; + + static String normalizeCryptoCurrency(CryptoCurrency currency) { + const bnbTitle = 'bsc'; + const usdterc20 = 'usdtErc20'; + + switch (currency) { + case CryptoCurrency.bnb: + return bnbTitle; + case CryptoCurrency.usdterc20: + return usdterc20; + default: + return currency.title.toLowerCase(); + } + } +} diff --git a/lib/exchange/sideshift/sideshift_request.dart b/lib/exchange/sideshift/sideshift_request.dart new file mode 100644 index 000000000..04deea8a5 --- /dev/null +++ b/lib/exchange/sideshift/sideshift_request.dart @@ -0,0 +1,17 @@ +import 'package:cake_wallet/exchange/trade_request.dart'; +import 'package:cw_core/crypto_currency.dart'; + +class SideShiftRequest extends TradeRequest { + final CryptoCurrency depositMethod; + final CryptoCurrency settleMethod; + final String depositAmount; + final String settleAddress; + final String refundAddress; + + SideShiftRequest( + {this.depositMethod, + this.settleMethod, + this.depositAmount, + this.settleAddress, + this.refundAddress,}); +} diff --git a/lib/exchange/trade_state.dart b/lib/exchange/trade_state.dart index 4ddce2e02..46472bdd9 100644 --- a/lib/exchange/trade_state.dart +++ b/lib/exchange/trade_state.dart @@ -33,7 +33,8 @@ class TradeState extends EnumerableItem with Serializable { TradeState(raw: 'waitingAuthorization', title: 'Waiting authorization'); static const failed = TradeState(raw: 'failed', title: 'Failed'); static const completed = TradeState(raw: 'completed', title: 'Completed'); - + static const settling = TradeState(raw: 'settling', title: 'Settlement in progress'); + static const settled = TradeState(raw: 'settled', title: 'Settlement completed'); static TradeState deserialize({String raw}) { switch (raw) { case 'pending': diff --git a/lib/src/screens/dashboard/widgets/trade_row.dart b/lib/src/screens/dashboard/widgets/trade_row.dart index 88745aa2c..faaac9edb 100644 --- a/lib/src/screens/dashboard/widgets/trade_row.dart +++ b/lib/src/screens/dashboard/widgets/trade_row.dart @@ -87,6 +87,9 @@ class TradeRow extends StatelessWidget { case ExchangeProviderDescription.morphToken: image = Image.asset('assets/images/morph.png', height: 36, width: 36); break; + case ExchangeProviderDescription.sideShift: + image = Image.asset('assets/images/sideshift.png', width: 36, height: 36); + break; default: image = null; } diff --git a/lib/src/screens/exchange/widgets/present_provider_picker.dart b/lib/src/screens/exchange/widgets/present_provider_picker.dart index 4656ba74e..16b35d7bb 100644 --- a/lib/src/screens/exchange/widgets/present_provider_picker.dart +++ b/lib/src/screens/exchange/widgets/present_provider_picker.dart @@ -71,6 +71,9 @@ class PresentProviderPicker extends StatelessWidget { case ExchangeProviderDescription.morphToken: images.add(Image.asset('assets/images/morph_icon.png')); break; + case ExchangeProviderDescription.sideShift: + images.add(Image.asset('assets/images/sideshift.png', width: 20)); + break; } } diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index e485a5129..3eed44011 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -1,3 +1,5 @@ +import 'package:cake_wallet/exchange/sideshift/sideshift_exchange_provider.dart'; +import 'package:cake_wallet/exchange/sideshift/sideshift_request.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/sync_status.dart'; @@ -33,7 +35,7 @@ abstract class ExchangeViewModelBase with Store { this.tradesStore, this._settingsStore) { const excludeDepositCurrencies = [CryptoCurrency.xhv]; const excludeReceiveCurrencies = [CryptoCurrency.xlm, CryptoCurrency.xrp, CryptoCurrency.bnb, CryptoCurrency.xhv]; - providerList = [ChangeNowExchangeProvider()]; + providerList = [ChangeNowExchangeProvider(), SideShiftExchangeProvider()]; _initialPairBasedOnWallet(); isDepositAddressEnabled = !(depositCurrency == wallet.currency); isReceiveAddressEnabled = !(receiveCurrency == wallet.currency); @@ -253,6 +255,18 @@ abstract class ExchangeViewModelBase with Store { String amount; CryptoCurrency currency; + if (provider is SideShiftExchangeProvider) { + request = SideShiftRequest( + depositMethod: depositCurrency, + settleMethod: receiveCurrency, + depositAmount: depositAmount?.replaceAll(',', '.'), + settleAddress: receiveAddress, + refundAddress: depositAddress, + ); + amount = depositAmount; + currency = depositCurrency; + } + if (provider is XMRTOExchangeProvider) { request = XMRTOTradeRequest( from: depositCurrency, diff --git a/lib/view_model/trade_details_view_model.dart b/lib/view_model/trade_details_view_model.dart index a367c17f4..043028c90 100644 --- a/lib/view_model/trade_details_view_model.dart +++ b/lib/view_model/trade_details_view_model.dart @@ -3,6 +3,7 @@ import 'package:cake_wallet/exchange/changenow/changenow_exchange_provider.dart' import 'package:cake_wallet/exchange/exchange_provider.dart'; import 'package:cake_wallet/exchange/exchange_provider_description.dart'; import 'package:cake_wallet/exchange/morphtoken/morphtoken_exchange_provider.dart'; +import 'package:cake_wallet/exchange/sideshift/sideshift_exchange_provider.dart'; import 'package:cake_wallet/exchange/trade.dart'; import 'package:cake_wallet/exchange/xmrto/xmrto_exchange_provider.dart'; import 'package:cake_wallet/utils/date_formatter.dart'; @@ -31,6 +32,9 @@ abstract class TradeDetailsViewModelBase with Store { case ExchangeProviderDescription.morphToken: _provider = MorphTokenExchangeProvider(trades: trades); break; + case ExchangeProviderDescription.sideShift: + _provider = SideShiftExchangeProvider(); + break; } items = ObservableList(); @@ -102,6 +106,12 @@ abstract class TradeDetailsViewModelBase with Store { })); } + if (trade.provider == ExchangeProviderDescription.sideShift) { + final buildURL = 'https://sideshift.ai/orders/${trade.id.toString()}'; + items.add(TrackTradeListItem( + title: 'Track', value: buildURL, onTap: () => launch(buildURL))); + } + if (trade.createdAt != null) { items.add(StandartListItem( title: S.current.trade_details_created_at, diff --git a/tool/utils/secret_key.dart b/tool/utils/secret_key.dart index e9bcfe253..aa9928c8c 100644 --- a/tool/utils/secret_key.dart +++ b/tool/utils/secret_key.dart @@ -23,6 +23,8 @@ class SecretKey { SecretKey('wyreAccountId', () => ''), SecretKey('moonPayApiKey', () => ''), SecretKey('moonPaySecretKey', () => ''), + SecretKey('sideShiftAffiliateId', () => ''), + SecretKey('sideShiftApiKey', () => ''), ]; final String name;