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

Godwin Asuquo committed Apr 13, 2022 at 14:28 UTC 6378d052ac18ddfa858690277b425c872c8a708c
10 files changed +322 -2
assets/images/sideshift.png
Binary files /dev/null and b/assets/images/sideshift.png differ
lib/exchange/exchange_provider_description.dart
+5
@@ -11,6 +11,9 @@ class ExchangeProviderDescription extends EnumerableItem<int>
11 static const morphToken =
12 ExchangeProviderDescription(title: 'MorphToken', raw: 2);
13
14 + static const sideShift =
15 + ExchangeProviderDescription(title: 'SideShift', raw: 3);
16 +
17 static ExchangeProviderDescription deserialize({int raw}) {
18 switch (raw) {
19 case 0:
@@ -19,6 +22,8 @@ class ExchangeProviderDescription extends EnumerableItem<int>
22 return changeNow;
23 case 2:
24 return morphToken;
25 + case 3:
26 + return sideShift;
27 default:
28 return null;
29 }
lib/exchange/sideshift/sideshift_exchange_provider.dart new
+265
@@ -0,0 +1,265 @@
1 +import 'dart:convert';
2 +
3 +import 'package:cake_wallet/exchange/exchange_pair.dart';
4 +import 'package:cake_wallet/exchange/exchange_provider.dart';
5 +import 'package:cake_wallet/exchange/exchange_provider_description.dart';
6 +import 'package:cake_wallet/exchange/sideshift/sideshift_request.dart';
7 +import 'package:cake_wallet/exchange/trade_not_created_exeption.dart';
8 +import 'package:cake_wallet/exchange/trade_not_found_exeption.dart';
9 +import 'package:cake_wallet/exchange/trade_state.dart';
10 +import 'package:cake_wallet/.secrets.g.dart' as secrets;
11 +import 'package:cw_core/crypto_currency.dart';
12 +import 'package:cake_wallet/exchange/trade_request.dart';
13 +import 'package:cake_wallet/exchange/trade.dart';
14 +import 'package:cake_wallet/exchange/limits.dart';
15 +import 'package:flutter/foundation.dart';
16 +import 'package:http/http.dart';
17 +
18 +class SideShiftExchangeProvider extends ExchangeProvider {
19 + SideShiftExchangeProvider()
20 + : super(
21 + pairList: CryptoCurrency.all
22 + .map((i) => CryptoCurrency.all
23 + .map((k) => ExchangePair(from: i, to: k, reverse: true))
24 + .where((c) => c != null))
25 + .expand((i) => i)
26 + .toList());
27 +
28 + static const apiKey = secrets.sideShiftApiKey;
29 + static const affiliateId = secrets.sideShiftAffiliateId;
30 + static const apiBaseUrl = 'https://sideshift.ai/api';
31 + static const rangePath = '/v1/pairs';
32 + static const orderPath = '/v1/orders';
33 + static const quotePath = '/v1/quotes';
34 + static const permissionPath = '/v1/permissions';
35 + static const apiHeaderKey = 'x-sideshift-secret';
36 +
37 + @override
38 + ExchangeProviderDescription get description =>
39 + ExchangeProviderDescription.sideShift;
40 +
41 + @override
42 + Future<double> calculateAmount(
43 + {CryptoCurrency from,
44 + CryptoCurrency to,
45 + double amount,
46 + bool isFixedRateMode,
47 + bool isReceiveAmount}) async {
48 + try {
49 + if (amount == 0) {
50 + return 0.0;
51 + }
52 + final fromCurrency = normalizeCryptoCurrency(from);
53 + final toCurrency = normalizeCryptoCurrency(to);
54 + final url =
55 + apiBaseUrl + rangePath + '/' + fromCurrency + '/' + toCurrency;
56 + final response = await get(url);
57 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
58 + final rate = double.parse(responseJSON['rate'] as String);
59 + final max = double.parse(responseJSON['max'] as String);
60 +
61 + if (amount > max) return 0.00;
62 +
63 + final estimatedAmount = rate * amount;
64 +
65 + return estimatedAmount;
66 + } catch (_) {
67 + return 0.00;
68 + }
69 + }
70 +
71 + @override
72 + Future<bool> checkIsAvailable() async {
73 + const url = apiBaseUrl + permissionPath;
74 + final response = await get(url);
75 +
76 + if (response.statusCode == 500) {
77 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
78 + final error = responseJSON['error']['message'] as String;
79 +
80 + throw Exception('$error');
81 + }
82 +
83 + if (response.statusCode != 200) {
84 + return false;
85 + }
86 +
87 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
88 + final canCreateOrder = responseJSON['createOrder'] as bool;
89 + final canCreateQuote = responseJSON['createQuote'] as bool;
90 + return canCreateOrder && canCreateQuote;
91 + }
92 +
93 + @override
94 + Future<Trade> createTrade(
95 + {TradeRequest request, bool isFixedRateMode}) async {
96 + final _request = request as SideShiftRequest;
97 + final quoteId = await _createQuote(_request);
98 + final url = apiBaseUrl + orderPath;
99 + final headers = {apiHeaderKey: apiKey, 'Content-Type': 'application/json'};
100 + final body = {
101 + 'type': 'fixed',
102 + 'quoteId': quoteId,
103 + 'affiliateId': affiliateId,
104 + 'settleAddress': _request.settleAddress,
105 + 'refundAddress': _request.refundAddress
106 + };
107 + final response = await post(url, headers: headers, body: json.encode(body));
108 +
109 + if (response.statusCode != 201) {
110 + if (response.statusCode == 400) {
111 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
112 + final error = responseJSON['error']['message'] as String;
113 +
114 + throw TradeNotCreatedException(description, description: error);
115 + }
116 +
117 + throw TradeNotCreatedException(description);
118 + }
119 +
120 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
121 + final id = responseJSON['id'] as String;
122 + final inputAddress = responseJSON['depositAddress']['address'] as String;
123 + final settleAddress = responseJSON['settleAddress']['address'] as String;
124 +
125 + return Trade(
126 + id: id,
127 + provider: description,
128 + from: _request.depositMethod,
129 + to: _request.settleMethod,
130 + inputAddress: inputAddress,
131 + refundAddress: settleAddress,
132 + state: TradeState.created,
133 + amount: _request.depositAmount,
134 + createdAt: DateTime.now(),
135 + );
136 + }
137 +
138 + Future<String> _createQuote(SideShiftRequest request) async {
139 + final url = apiBaseUrl + quotePath;
140 + final headers = {apiHeaderKey: apiKey, 'Content-Type': 'application/json'};
141 + final depositMethod = normalizeCryptoCurrency(request.depositMethod);
142 + final settleMethod = normalizeCryptoCurrency(request.settleMethod);
143 + final body = {
144 + 'depositMethod': depositMethod,
145 + 'settleMethod': settleMethod,
146 + 'affiliateId': affiliateId,
147 + 'depositAmount': request.depositAmount,
148 + };
149 + final response = await post(url, headers: headers, body: json.encode(body));
150 +
151 + if (response.statusCode != 201) {
152 + if (response.statusCode == 400) {
153 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
154 + final error = responseJSON['error']['message'] as String;
155 +
156 + throw TradeNotCreatedException(description, description: error);
157 + }
158 +
159 + throw TradeNotCreatedException(description);
160 + }
161 +
162 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
163 + final quoteId = responseJSON['id'] as String;
164 +
165 + return quoteId;
166 + }
167 +
168 + @override
169 + Future<Limits> fetchLimits(
170 + {CryptoCurrency from, CryptoCurrency to, bool isFixedRateMode}) async {
171 + final fromCurrency = normalizeCryptoCurrency(from);
172 + final toCurrency = normalizeCryptoCurrency(to);
173 + final url = apiBaseUrl + rangePath + '/' + fromCurrency + '/' + toCurrency;
174 + final response = await get(url);
175 +
176 + if (response.statusCode == 500) {
177 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
178 + final error = responseJSON['error']['message'] as String;
179 +
180 + throw Exception('$error');
181 + }
182 +
183 + if (response.statusCode != 200) {
184 + return null;
185 + }
186 +
187 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
188 + final min = double.parse(responseJSON['min'] as String);
189 + final max = double.parse(responseJSON['max'] as String);
190 +
191 + return Limits(min: min, max: max);
192 + }
193 +
194 + @override
195 + Future<Trade> findTradeById({@required String id}) async {
196 + final url = apiBaseUrl + orderPath + '/' + id;
197 + final response = await get(url);
198 +
199 + if (response.statusCode == 404) {
200 + throw TradeNotFoundException(id, provider: description);
201 + }
202 +
203 + if (response.statusCode == 400) {
204 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
205 + final error = responseJSON['error']['message'] as String;
206 +
207 + throw TradeNotFoundException(id,
208 + provider: description, description: error);
209 + }
210 +
211 + if (response.statusCode != 200) {
212 + return null;
213 + }
214 +
215 + final responseJSON = json.decode(response.body) as Map<String, dynamic>;
216 + final fromCurrency = responseJSON['depositMethodId'] as String;
217 + final from = CryptoCurrency.fromString(fromCurrency);
218 + final toCurrency = responseJSON['settleMethodId'] as String;
219 + final to = CryptoCurrency.fromString(toCurrency);
220 + final inputAddress = responseJSON['depositAddress']['address'] as String;
221 + final expectedSendAmount = responseJSON['depositAmount'].toString();
222 + final deposits = responseJSON['deposits'] as List;
223 + TradeState state;
224 +
225 + if (deposits != null && deposits.isNotEmpty) {
226 + final status = deposits[0]['status'] as String;
227 + state = TradeState.deserialize(raw: status);
228 + }
229 +
230 + final expiredAtRaw = responseJSON['expiresAtISO'] as String;
231 + final expiredAt =
232 + expiredAtRaw != null ? DateTime.parse(expiredAtRaw).toLocal() : null;
233 +
234 + return Trade(
235 + id: id,
236 + from: from,
237 + to: to,
238 + provider: description,
239 + inputAddress: inputAddress,
240 + amount: expectedSendAmount,
241 + state: state,
242 + expiredAt: expiredAt,
243 + );
244 + }
245 +
246 + @override
247 + bool get isAvailable => true;
248 +
249 + @override
250 + String get title => 'SideShift';
251 +
252 + static String normalizeCryptoCurrency(CryptoCurrency currency) {
253 + const bnbTitle = 'bsc';
254 + const usdterc20 = 'usdtErc20';
255 +
256 + switch (currency) {
257 + case CryptoCurrency.bnb:
258 + return bnbTitle;
259 + case CryptoCurrency.usdterc20:
260 + return usdterc20;
261 + default:
262 + return currency.title.toLowerCase();
263 + }
264 + }
265 +}
lib/exchange/sideshift/sideshift_request.dart new
+17
@@ -0,0 +1,17 @@
1 +import 'package:cake_wallet/exchange/trade_request.dart';
2 +import 'package:cw_core/crypto_currency.dart';
3 +
4 +class SideShiftRequest extends TradeRequest {
5 + final CryptoCurrency depositMethod;
6 + final CryptoCurrency settleMethod;
7 + final String depositAmount;
8 + final String settleAddress;
9 + final String refundAddress;
10 +
11 + SideShiftRequest(
12 + {this.depositMethod,
13 + this.settleMethod,
14 + this.depositAmount,
15 + this.settleAddress,
16 + this.refundAddress,});
17 +}
lib/exchange/trade_state.dart
+2 -1
@@ -33,7 +33,8 @@ class TradeState extends EnumerableItem<String> with Serializable<String> {
33 TradeState(raw: 'waitingAuthorization', title: 'Waiting authorization');
34 static const failed = TradeState(raw: 'failed', title: 'Failed');
35 static const completed = TradeState(raw: 'completed', title: 'Completed');
36 -
36 + static const settling = TradeState(raw: 'settling', title: 'Settlement in progress');
37 + static const settled = TradeState(raw: 'settled', title: 'Settlement completed');
38 static TradeState deserialize({String raw}) {
39 switch (raw) {
40 case 'pending':
lib/src/screens/dashboard/widgets/trade_row.dart
+3
@@ -87,6 +87,9 @@ class TradeRow extends StatelessWidget {
87 case ExchangeProviderDescription.morphToken:
88 image = Image.asset('assets/images/morph.png', height: 36, width: 36);
89 break;
90 + case ExchangeProviderDescription.sideShift:
91 + image = Image.asset('assets/images/sideshift.png', width: 36, height: 36);
92 + break;
93 default:
94 image = null;
95 }
lib/src/screens/exchange/widgets/present_provider_picker.dart
+3
@@ -71,6 +71,9 @@ class PresentProviderPicker extends StatelessWidget {
71 case ExchangeProviderDescription.morphToken:
72 images.add(Image.asset('assets/images/morph_icon.png'));
73 break;
74 + case ExchangeProviderDescription.sideShift:
75 + images.add(Image.asset('assets/images/sideshift.png', width: 20));
76 + break;
77 }
78 }
79
lib/view_model/exchange/exchange_view_model.dart
+15 -1
@@ -1,3 +1,5 @@
1 +import 'package:cake_wallet/exchange/sideshift/sideshift_exchange_provider.dart';
2 +import 'package:cake_wallet/exchange/sideshift/sideshift_request.dart';
3 import 'package:cw_core/wallet_base.dart';
4 import 'package:cw_core/crypto_currency.dart';
5 import 'package:cw_core/sync_status.dart';
@@ -33,7 +35,7 @@ abstract class ExchangeViewModelBase with Store {
35 this.tradesStore, this._settingsStore) {
36 const excludeDepositCurrencies = [CryptoCurrency.xhv];
37 const excludeReceiveCurrencies = [CryptoCurrency.xlm, CryptoCurrency.xrp, CryptoCurrency.bnb, CryptoCurrency.xhv];
36 - providerList = [ChangeNowExchangeProvider()];
38 + providerList = [ChangeNowExchangeProvider(), SideShiftExchangeProvider()];
39 _initialPairBasedOnWallet();
40 isDepositAddressEnabled = !(depositCurrency == wallet.currency);
41 isReceiveAddressEnabled = !(receiveCurrency == wallet.currency);
@@ -253,6 +255,18 @@ abstract class ExchangeViewModelBase with Store {
255 String amount;
256 CryptoCurrency currency;
257
258 + if (provider is SideShiftExchangeProvider) {
259 + request = SideShiftRequest(
260 + depositMethod: depositCurrency,
261 + settleMethod: receiveCurrency,
262 + depositAmount: depositAmount?.replaceAll(',', '.'),
263 + settleAddress: receiveAddress,
264 + refundAddress: depositAddress,
265 + );
266 + amount = depositAmount;
267 + currency = depositCurrency;
268 + }
269 +
270 if (provider is XMRTOExchangeProvider) {
271 request = XMRTOTradeRequest(
272 from: depositCurrency,
lib/view_model/trade_details_view_model.dart
+10
@@ -3,6 +3,7 @@ import 'package:cake_wallet/exchange/changenow/changenow_exchange_provider.dart'
3 import 'package:cake_wallet/exchange/exchange_provider.dart';
4 import 'package:cake_wallet/exchange/exchange_provider_description.dart';
5 import 'package:cake_wallet/exchange/morphtoken/morphtoken_exchange_provider.dart';
6 +import 'package:cake_wallet/exchange/sideshift/sideshift_exchange_provider.dart';
7 import 'package:cake_wallet/exchange/trade.dart';
8 import 'package:cake_wallet/exchange/xmrto/xmrto_exchange_provider.dart';
9 import 'package:cake_wallet/utils/date_formatter.dart';
@@ -31,6 +32,9 @@ abstract class TradeDetailsViewModelBase with Store {
32 case ExchangeProviderDescription.morphToken:
33 _provider = MorphTokenExchangeProvider(trades: trades);
34 break;
35 + case ExchangeProviderDescription.sideShift:
36 + _provider = SideShiftExchangeProvider();
37 + break;
38 }
39
40 items = ObservableList<StandartListItem>();
@@ -102,6 +106,12 @@ abstract class TradeDetailsViewModelBase with Store {
106 }));
107 }
108
109 + if (trade.provider == ExchangeProviderDescription.sideShift) {
110 + final buildURL = 'https://sideshift.ai/orders/${trade.id.toString()}';
111 + items.add(TrackTradeListItem(
112 + title: 'Track', value: buildURL, onTap: () => launch(buildURL)));
113 + }
114 +
115 if (trade.createdAt != null) {
116 items.add(StandartListItem(
117 title: S.current.trade_details_created_at,
tool/utils/secret_key.dart
+2
@@ -23,6 +23,8 @@ class SecretKey {
23 SecretKey('wyreAccountId', () => ''),
24 SecretKey('moonPayApiKey', () => ''),
25 SecretKey('moonPaySecretKey', () => ''),
26 + SecretKey('sideShiftAffiliateId', () => ''),
27 + SecretKey('sideShiftApiKey', () => ''),
28 ];
29
30 final String name;