From d04d63f62dc8d880d90e4cf925475ce67b550254 Mon Sep 17 00:00:00 2001 From: 4rkal <4rkal@proton.me> Date: Sat, 11 Jul 2026 12:42:58 +0300 Subject: [PATCH] Add CypherGoat exchange integration --- lib/pages/exchange_view/exchange_form.dart | 2 + .../exchange_provider_options.dart | 7 + .../exchange/cyphergoat/cyphergoat_api.dart | 224 ++++++++ .../cyphergoat/cyphergoat_exchange.dart | 514 ++++++++++++++++++ .../response_objects/cg_estimate.dart | 54 ++ .../response_objects/cg_transaction.dart | 90 +++ lib/services/exchange/exchange.dart | 3 + .../exchange_data_loading_service.dart | 26 + lib/utilities/assets.dart | 4 + scripts/prebuild.ps1 | 2 +- scripts/prebuild.sh | 2 +- 11 files changed, 926 insertions(+), 2 deletions(-) create mode 100644 lib/services/exchange/cyphergoat/cyphergoat_api.dart create mode 100644 lib/services/exchange/cyphergoat/cyphergoat_exchange.dart create mode 100644 lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart create mode 100644 lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart diff --git a/lib/pages/exchange_view/exchange_form.dart b/lib/pages/exchange_view/exchange_form.dart index fb1fa41bfc..4d4fe4e773 100644 --- a/lib/pages/exchange_view/exchange_form.dart +++ b/lib/pages/exchange_view/exchange_form.dart @@ -27,6 +27,7 @@ import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart'; import '../../providers/providers.dart'; import '../../services/exchange/change_now/change_now_exchange.dart'; +import '../../services/exchange/cyphergoat/cyphergoat_exchange.dart'; import '../../services/exchange/exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../services/exchange/exchange_response.dart'; @@ -86,6 +87,7 @@ class _ExchangeFormState extends ConsumerState { TrocadorExchange.instance, NanswapExchange.instance, WizardSwapExchange.instance, + CypherGoatExchange.instance, ]; } } diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart index b7fad4d249..e943b0016b 100644 --- a/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart +++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_options.dart @@ -14,6 +14,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../models/exchange/aggregate_currency.dart'; import '../../../providers/providers.dart'; import '../../../services/exchange/change_now/change_now_exchange.dart'; +import '../../../services/exchange/cyphergoat/cyphergoat_exchange.dart'; import '../../../services/exchange/exchange.dart'; import '../../../services/exchange/exolix/exolix_exchange.dart'; import '../../../services/exchange/nanswap/nanswap_exchange.dart'; @@ -103,6 +104,11 @@ class _ExchangeProviderOptionsState sendCurrency: sendCurrency, receiveCurrency: receivingCurrency, ); + final showCypherGoat = exchangeSupported( + exchangeName: CypherGoatExchange.exchangeName, + sendCurrency: sendCurrency, + receiveCurrency: receivingCurrency, + ); return RoundedWhiteContainer( padding: isDesktop ? const EdgeInsets.all(0) : const EdgeInsets.all(12), @@ -116,6 +122,7 @@ class _ExchangeProviderOptionsState if (showTrocador) TrocadorExchange.instance, if (showNanswap) NanswapExchange.instance, if (showWizardSwap) WizardSwapExchange.instance, + if (showCypherGoat) CypherGoatExchange.instance, ], fixedRate: widget.fixedRate, reversed: widget.reversed, diff --git a/lib/services/exchange/cyphergoat/cyphergoat_api.dart b/lib/services/exchange/cyphergoat/cyphergoat_api.dart new file mode 100644 index 0000000000..b421a6bfb7 --- /dev/null +++ b/lib/services/exchange/cyphergoat/cyphergoat_api.dart @@ -0,0 +1,224 @@ +import 'dart:convert'; + +import '../../../app_config.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; +import '../../../external_api_keys.dart'; +import '../../../networking/http.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; +import '../exchange_response.dart'; +import 'response_objects/cg_estimate.dart'; +import 'response_objects/cg_transaction.dart'; + +const kCypherGoatSource = "stackwallet"; + +abstract class CypherGoatAPI { + static const String authority = "api.cyphergoat.com"; + + static const HTTP _client = HTTP(); + + static Uri _buildUri({ + required String path, + Map? params, + }) { + return Uri.https(authority, path, params); + } + + static Future _makeGetRequest(Uri uri) async { + int code = -1; + try { + final headers = { + "Content-Type": "application/json", + "Accept": "application/json", + }; + if (kCypherGoatApiKey.isNotEmpty) { + headers["Authorization"] = "Bearer $kCypherGoatApiKey"; + } + + final response = await _client.get( + url: uri, + headers: headers, + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + code = response.code; + + final json = jsonDecode(response.body); + + if (code != 200) { + final errMsg = (json is Map ? json["error"] : null) as String?; + throw Exception(errMsg ?? "HTTP $code: ${response.body}"); + } + + return json; + } catch (e, s) { + Logging.instance.e( + "CypherGoatAPI GET $uri HTTP:$code threw:", + error: e, + stackTrace: s, + ); + rethrow; + } + } + + /// GET /estimate + /// Returns all exchange provider estimates for the given pair and amount. + static Future> + getEstimate({ + required String coin1, + required String network1, + required String coin2, + required String network2, + required String amount, + }) async { + final params = { + "coin1": coin1.toLowerCase(), + "network1": network1.toLowerCase(), + "coin2": coin2.toLowerCase(), + "network2": network2.toLowerCase(), + "amount": amount, + "best": "false", + }; + + if (kCypherGoatApiKey.isNotEmpty) { + params["api_key"] = kCypherGoatApiKey; + } + + final uri = _buildUri(path: "/estimate", params: params); + + try { + final json = await _makeGetRequest(uri); + final map = Map.from(json as Map); + + final ratesMap = map["rates"] as Map?; + if (ratesMap == null) { + throw Exception("Missing 'rates' in estimate response"); + } + + final rates = CgEstimatesResponse.fromMap( + Map.from(ratesMap), + ); + final min = (map["min"] as num?)?.toDouble() ?? rates.min; + + return ExchangeResponse(value: (rates: rates, min: min)); + } catch (e, s) { + Logging.instance.e( + "CypherGoatAPI.getEstimate() exception:", + error: e, + stackTrace: s, + ); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + /// GET /swap + /// Creates a swap with the specified exchange partner. + static Future> createSwap({ + required String coin1, + required String network1, + required String coin2, + required String network2, + required String amount, + required String partner, + required String address, + String? estimateId, + }) async { + final params = { + "coin1": coin1.toLowerCase(), + "network1": network1.toLowerCase(), + "coin2": coin2.toLowerCase(), + "network2": network2.toLowerCase(), + "amount": amount, + "partner": partner, + "address": address, + "source": kCypherGoatSource, + }; + + if (kCypherGoatAffiliate.isNotEmpty) { + params["affiliate"] = kCypherGoatAffiliate; + } + if (estimateId != null && estimateId.isNotEmpty) { + params["estimateid"] = estimateId; + } + if (kCypherGoatApiKey.isNotEmpty) { + params["api_key"] = kCypherGoatApiKey; + } + + final uri = _buildUri(path: "/swap", params: params); + + try { + final json = await _makeGetRequest(uri); + final map = Map.from(json as Map); + + final txMap = map["transaction"] as Map?; + if (txMap == null) { + throw Exception("Missing 'transaction' in swap response"); + } + + return ExchangeResponse( + value: CgTransaction.fromMap(Map.from(txMap)), + ); + } catch (e, s) { + Logging.instance.e( + "CypherGoatAPI.createSwap() exception:", + error: e, + stackTrace: s, + ); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + /// GET /transaction + /// Fetches transaction details by CGID. + static Future> getTransaction({ + required String cgid, + }) async { + final params = {"id": cgid}; + if (kCypherGoatApiKey.isNotEmpty) { + params["api_key"] = kCypherGoatApiKey; + } + + final uri = _buildUri(path: "/transaction", params: params); + + try { + final json = await _makeGetRequest(uri); + final map = Map.from(json as Map); + + final txMap = map["transaction"] as Map?; + if (txMap == null) { + throw Exception("Missing 'transaction' in response"); + } + + return ExchangeResponse( + value: CgTransaction.fromMap(Map.from(txMap)), + ); + } catch (e, s) { + Logging.instance.e( + "CypherGoatAPI.getTransaction($cgid) exception:", + error: e, + stackTrace: s, + ); + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } +} diff --git a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart new file mode 100644 index 0000000000..0764c2c5d7 --- /dev/null +++ b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart @@ -0,0 +1,514 @@ +import 'package:decimal/decimal.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../app_config.dart'; +import '../../../exceptions/exchange/exchange_exception.dart'; +import '../../../models/exchange/response_objects/estimate.dart'; +import '../../../models/exchange/response_objects/range.dart'; +import '../../../models/exchange/response_objects/trade.dart'; +import '../../../models/isar/exchange_cache/currency.dart'; +import '../../../models/isar/exchange_cache/pair.dart'; +import '../exchange.dart'; +import '../exchange_response.dart'; +import 'cyphergoat_api.dart'; + +class _CgCoin { + final String ticker; + final String name; + final String network; + final double? min; + + const _CgCoin({ + required this.ticker, + required this.name, + required this.network, + this.min, + }); +} + +// Static coin list derived from CypherGoat's coins.json. +const List<_CgCoin> _kCgCoins = [ + _CgCoin(ticker: 'btc', name: 'Bitcoin', network: 'btc', min: 4.449e-05), + _CgCoin(ticker: 'btc', name: 'Bitcoin (Lightning)', network: 'lightning', min: 4.449e-05), + _CgCoin(ticker: 'eth', name: 'Ethereum', network: 'eth', min: 0.001114), + _CgCoin(ticker: 'xmr', name: 'Monero', network: 'xmr', min: 0.01886), + _CgCoin(ticker: 'ltc', name: 'Litecoin', network: 'ltc', min: 0.04444), + _CgCoin(ticker: 'bch', name: 'Bitcoin Cash', network: 'bch'), + _CgCoin(ticker: 'doge', name: 'Dogecoin', network: 'doge', min: 22.59), + _CgCoin(ticker: 'bnb', name: 'Binance Coin', network: 'bnb', min: 0.005711), + _CgCoin(ticker: 'sol', name: 'Solana', network: 'sol', min: 0.0238), + _CgCoin(ticker: 'xtz', name: 'Tezos', network: 'xtz', min: 6.336), + _CgCoin(ticker: 'ada', name: 'Cardano', network: 'ada', min: 5.868), + _CgCoin(ticker: 'xrp', name: 'Ripple', network: 'xrp', min: 1.678), + _CgCoin(ticker: 'trx', name: 'Tron', network: 'trx', min: 14.58), + _CgCoin(ticker: 'link', name: 'Chainlink', network: 'link', min: 0.2014), + _CgCoin(ticker: 'usdc', name: 'USDC (Ethereum)', network: 'usdc'), + _CgCoin(ticker: 'xno', name: 'Nano', network: 'xno', min: 5.393), + _CgCoin(ticker: 'usdc', name: 'USDC (Polygon)', network: 'poly'), + _CgCoin(ticker: 'usdc', name: 'USDC (Solana)', network: 'sol'), + _CgCoin(ticker: 'usdc', name: 'USDC (Algorand)', network: 'algo'), + _CgCoin(ticker: 'usdc', name: 'USDC (BSC)', network: 'bsc'), + _CgCoin(ticker: 'usdc', name: 'USDC (Optimism)', network: 'op'), + _CgCoin(ticker: 'usdc', name: 'USDC (Base)', network: 'base'), + _CgCoin(ticker: 'usdc', name: 'USDC (Tron)', network: 'tron'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (Ethereum)', network: 'eth'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (Tron)', network: 'tron'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (Polygon)', network: 'poly'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (BSC)', network: 'bsc'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (Solana)', network: 'sol'), + _CgCoin(ticker: 'usdt', name: 'Tether USD (Algorand)', network: 'algo'), + _CgCoin(ticker: 'busd', name: 'Binance USD (BSC)', network: 'bsc'), + _CgCoin(ticker: 'busd', name: 'Binance USD (Ethereum)', network: 'eth'), + _CgCoin(ticker: 'dai', name: 'Dai (Ethereum)', network: 'eth'), + _CgCoin(ticker: 'dai', name: 'Dai (BSC)', network: 'bsc'), + _CgCoin(ticker: 'dai', name: 'Dai (Polygon)', network: 'poly'), + _CgCoin(ticker: 'dai', name: 'Dai (Optimism)', network: 'op'), + _CgCoin(ticker: 'tusd', name: 'True USD', network: 'tusd'), + _CgCoin(ticker: 'tusd', name: 'True USD (Tron)', network: 'tron'), + _CgCoin(ticker: 'shib', name: 'Shiba Inu', network: 'shib', min: 10000), + _CgCoin(ticker: 'dot', name: 'Polkadot', network: 'dot', min: 1.272), + _CgCoin(ticker: 'etc', name: 'Ethereum Classic', network: 'etc', min: 0.2318), + _CgCoin(ticker: 'zec', name: 'Zcash', network: 'zec', min: 0.2), + _CgCoin(ticker: 'hive', name: 'Hive', network: 'hive', min: 24.06), + _CgCoin(ticker: 'bdx', name: 'Beldex', network: 'bdx', min: 65.93), + _CgCoin(ticker: 'wow', name: 'Wownero', network: 'wow', min: 163.8), + _CgCoin(ticker: 'ban', name: 'Banano', network: 'banano', min: 2614.0), + _CgCoin(ticker: 'arrr', name: 'Pirate Chain', network: 'arrr', min: 4.8), + _CgCoin(ticker: 'arrrbsc', name: 'Pirate Chain (BSC)', network: 'arrrbsc', min: 4.8), + _CgCoin(ticker: 'dcr', name: 'Decred', network: 'dcr', min: 0.3045), + _CgCoin(ticker: 'aave', name: 'Aave', network: 'aave', min: 0.01574), + _CgCoin(ticker: 'avax', name: 'Avalanche', network: 'avax', min: 0.4263), + _CgCoin(ticker: 'bat', name: 'Basic Attention Token', network: 'bat', min: 32.09), + _CgCoin(ticker: 'link', name: 'Chainlink (BSC)', network: 'bsc', min: 0.2014), + _CgCoin(ticker: 'gusd', name: 'Gemini Dollar', network: 'gusd'), + _CgCoin(ticker: 'paxg', name: 'Paxos Gold', network: 'paxg', min: 0.002), + _CgCoin(ticker: 'hbar', name: 'Hedera', network: 'hbar', min: 12), + _CgCoin(ticker: 'ark', name: 'Ark', network: 'ark', min: 10.96), + _CgCoin(ticker: 'firo', name: 'Firo', network: 'firo', min: 14.24), + _CgCoin(ticker: 'wbtc', name: 'Wrapped Bitcoin', network: 'wbtc', min: 4.444e-05), + _CgCoin(ticker: '1inch', name: '1inch', network: '1inch', min: 19.87), + _CgCoin(ticker: 'dash', name: 'Dash', network: 'dash', min: 0.2152), + _CgCoin(ticker: 'zano', name: 'Zano', network: 'zano', min: 0.3358), + _CgCoin(ticker: 'tel', name: 'Telcoin', network: 'tel', min: 1001.0), + _CgCoin(ticker: 'leo', name: 'Leo Token', network: 'leo', min: 2), + _CgCoin(ticker: 'fusd', name: 'Freedom Dollar', network: 'fusd', min: 25), + _CgCoin(ticker: 'apt', name: 'Aptos', network: 'apt', min: 1.134), + _CgCoin(ticker: 'sui', name: 'Sui', network: 'sui', min: 1.449), + _CgCoin(ticker: 'nvdax', name: 'NVIDIA xStock', network: 'nvdax', min: 0.8), + _CgCoin(ticker: 'spyx', name: 'SP500 xStock', network: 'spyx', min: 0.3), + _CgCoin(ticker: 'tslax', name: 'TSLA xStock', network: 'tslax', min: 0.4), + _CgCoin(ticker: 'qqqx', name: 'Nasdaq xStock', network: 'qqqx', min: 0.3), + _CgCoin(ticker: 'crclx', name: 'Circle xStock', network: 'crclx', min: 1.3), + _CgCoin(ticker: 'mstrx', name: 'MicroStrategy xStock', network: 'mstrx', min: 0.4), + _CgCoin(ticker: 'aaplx', name: 'Apple xStock', network: 'aaplx', min: 0.6), + _CgCoin(ticker: 'coinx', name: 'Coinbase xStock', network: 'coinx', min: 0.5), + _CgCoin(ticker: 'googlx', name: 'Alphabet xStock', network: 'googlx', min: 0.7), + _CgCoin(ticker: 'amznx', name: 'Amazon xStock', network: 'amznx', min: 0.6), + _CgCoin(ticker: 'metax', name: 'Meta xStock', network: 'metax', min: 0.2), + _CgCoin(ticker: 'hoodx', name: 'Robinhood xStock', network: 'hoodx', min: 1.3), + _CgCoin(ticker: 'gmex', name: 'Gamestop xStock', network: 'gmex', min: 5), +]; + +class CypherGoatExchange extends Exchange { + CypherGoatExchange._(); + + static CypherGoatExchange? _instance; + static CypherGoatExchange get instance => + _instance ??= CypherGoatExchange._(); + + static const exchangeName = "CypherGoat"; + + @override + String get name => exchangeName; + + @override + bool get supportsRefundAddress => false; + + @override + Future>> getAllCurrencies( + bool fixedRate, + ) async { + try { + if (fixedRate) { + return ExchangeResponse( + exception: ExchangeException( + "CypherGoat does not support fixed rate", + ExchangeExceptionType.generic, + ), + ); + } + + final currencies = _kCgCoins + .map( + (c) => Currency( + exchangeName: exchangeName, + ticker: c.ticker, + name: c.name, + network: c.network, + image: "", + isFiat: false, + rateType: SupportedRateType.estimated, + isStackCoin: AppConfig.isStackCoin(c.ticker), + tokenContract: null, + isAvailable: true, + ), + ) + .toList(); + + return ExchangeResponse(value: currencies); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getRange( + String from, + String? fromNetwork, + String to, + String? toNetwork, + bool fixedRate, + ) async { + try { + if (fixedRate) { + return ExchangeResponse( + exception: ExchangeException( + "CypherGoat does not support fixed rate", + ExchangeExceptionType.generic, + ), + ); + } + + // Use the min from the static coin list as a quick offline fallback. + final coin = _kCgCoins.where( + (c) => + c.ticker.toLowerCase() == from.toLowerCase() && + (fromNetwork == null || + c.network.toLowerCase() == fromNetwork.toLowerCase()), + ); + + Decimal? min; + if (coin.isNotEmpty && coin.first.min != null) { + min = Decimal.parse(coin.first.min.toString()); + } + + // Fetch live min from the API. + final response = await CypherGoatAPI.getEstimate( + coin1: from, + network1: fromNetwork ?? from, + coin2: to, + network2: toNetwork ?? to, + amount: (min ?? Decimal.one).toString(), + ); + + if (response.value != null) { + final liveMin = response.value!.min; + if (liveMin > 0) { + min = Decimal.parse(liveMin.toString()); + } + } + + return ExchangeResponse(value: Range(min: min, max: null)); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getEstimates( + String from, + String? fromNetwork, + String to, + String? toNetwork, + Decimal amount, + bool fixedRate, + bool reversed, + ) async { + try { + if (fixedRate) { + return ExchangeResponse( + exception: ExchangeException( + "CypherGoat does not support fixed rate", + ExchangeExceptionType.generic, + ), + ); + } + if (reversed) { + return ExchangeResponse( + exception: ExchangeException( + "CypherGoat does not support reversed estimates", + ExchangeExceptionType.generic, + ), + ); + } + + final response = await CypherGoatAPI.getEstimate( + coin1: from, + network1: fromNetwork ?? from, + coin2: to, + network2: toNetwork ?? to, + amount: amount.toString(), + ); + + if (response.value == null) { + return ExchangeResponse(exception: response.exception); + } + + final data = response.value!; + final estimateIdStr = data.rates.estimateId.toString(); + + final estimates = data.rates.results + .where((r) => r.amount > 0) + .map( + (r) => Estimate( + estimatedAmount: Decimal.parse(r.amount.toString()), + fixedRate: false, + reversed: false, + exchangeProvider: r.exchange, + rateId: estimateIdStr, + ), + ) + .toList(); + + estimates.sort( + (a, b) => b.estimatedAmount.compareTo(a.estimatedAmount), + ); + + if (estimates.isEmpty) { + return ExchangeResponse( + exception: ExchangeException( + "No rates available for this pair", + ExchangeExceptionType.orderNotFound, + ), + ); + } + + return ExchangeResponse(value: estimates); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> createTrade({ + required String from, + required String to, + required String? fromNetwork, + required String? toNetwork, + required bool fixedRate, + required Decimal amount, + required String addressTo, + String? extraId, + required String addressRefund, + required String refundExtraId, + Estimate? estimate, + required bool reversed, + }) async { + try { + if (fixedRate) { + throw ExchangeException( + "CypherGoat does not support fixed rate", + ExchangeExceptionType.generic, + ); + } + if (reversed) { + throw ExchangeException( + "CypherGoat does not support reversed trades", + ExchangeExceptionType.generic, + ); + } + if (estimate == null) { + throw ExchangeException( + "An estimate is required to create a CypherGoat trade", + ExchangeExceptionType.generic, + ); + } + + final response = await CypherGoatAPI.createSwap( + coin1: from, + network1: fromNetwork ?? from, + coin2: to, + network2: toNetwork ?? to, + amount: amount.toString(), + partner: estimate.exchangeProvider, + address: addressTo, + estimateId: estimate.rateId, + ); + + if (response.value == null) { + return ExchangeResponse(exception: response.exception); + } + + final tx = response.value!; + + return ExchangeResponse( + value: Trade( + uuid: const Uuid().v1(), + tradeId: tx.cgid.isNotEmpty ? tx.cgid : tx.id, + rateType: "estimated", + direction: "direct", + timestamp: tx.createdAt, + updatedAt: tx.createdAt, + payInCurrency: tx.coin1.toUpperCase(), + payInAmount: tx.sendAmount.toString(), + payInAddress: tx.address, + payInNetwork: tx.network1, + payInExtraId: tx.memo, + payInTxid: "", + payOutCurrency: tx.coin2.toUpperCase(), + payOutAmount: tx.estimateAmount.toString(), + payOutAddress: tx.destinationAddress, + payOutNetwork: tx.network2, + payOutExtraId: "", + payOutTxid: "", + refundAddress: "", + refundExtraId: "", + status: tx.status, + exchangeName: exchangeName, + ), + ); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future> getTrade(String tradeId) async { + try { + final response = await CypherGoatAPI.getTransaction(cgid: tradeId); + + if (response.value == null) { + return ExchangeResponse(exception: response.exception); + } + + final tx = response.value!; + + return ExchangeResponse( + value: Trade( + uuid: const Uuid().v1(), + tradeId: tx.cgid.isNotEmpty ? tx.cgid : tradeId, + rateType: "estimated", + direction: "direct", + timestamp: tx.createdAt, + updatedAt: DateTime.now(), + payInCurrency: tx.coin1.toUpperCase(), + payInAmount: tx.sendAmount.toString(), + payInAddress: tx.address, + payInNetwork: tx.network1, + payInExtraId: tx.memo, + payInTxid: "", + payOutCurrency: tx.coin2.toUpperCase(), + payOutAmount: tx.estimateAmount.toString(), + payOutAddress: tx.destinationAddress, + payOutNetwork: tx.network2, + payOutExtraId: "", + payOutTxid: "", + refundAddress: "", + refundExtraId: "", + status: tx.status, + exchangeName: exchangeName, + ), + ); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } + + @override + Future>> getTrades() async { + throw UnimplementedError( + "CypherGoat does not provide a trade history endpoint", + ); + } + + @override + Future> updateTrade(Trade trade) async { + try { + final response = await CypherGoatAPI.getTransaction(cgid: trade.tradeId); + + if (response.value == null) { + return ExchangeResponse(exception: response.exception); + } + + final tx = response.value!; + + return ExchangeResponse( + value: Trade( + uuid: trade.uuid, + tradeId: trade.tradeId, + rateType: trade.rateType, + direction: trade.direction, + timestamp: trade.timestamp, + updatedAt: DateTime.now(), + payInCurrency: tx.coin1.isNotEmpty + ? tx.coin1.toUpperCase() + : trade.payInCurrency, + payInAmount: tx.sendAmount > 0 + ? tx.sendAmount.toString() + : trade.payInAmount, + payInAddress: + tx.address.isNotEmpty ? tx.address : trade.payInAddress, + payInNetwork: trade.payInNetwork, + payInExtraId: tx.memo.isNotEmpty ? tx.memo : trade.payInExtraId, + payInTxid: trade.payInTxid, + payOutCurrency: tx.coin2.isNotEmpty + ? tx.coin2.toUpperCase() + : trade.payOutCurrency, + payOutAmount: tx.estimateAmount > 0 + ? tx.estimateAmount.toString() + : trade.payOutAmount, + payOutAddress: tx.destinationAddress.isNotEmpty + ? tx.destinationAddress + : trade.payOutAddress, + payOutNetwork: trade.payOutNetwork, + payOutExtraId: trade.payOutExtraId, + payOutTxid: trade.payOutTxid, + refundAddress: trade.refundAddress, + refundExtraId: trade.refundExtraId, + status: tx.status.isNotEmpty ? tx.status : trade.status, + exchangeName: exchangeName, + ), + ); + } on ExchangeException catch (e) { + return ExchangeResponse(exception: e); + } catch (e) { + return ExchangeResponse( + exception: ExchangeException( + e.toString(), + ExchangeExceptionType.generic, + ), + ); + } + } +} diff --git a/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart b/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart new file mode 100644 index 0000000000..6eedb6b7cd --- /dev/null +++ b/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart @@ -0,0 +1,54 @@ +class CgEstimateResult { + final String exchange; + final double amount; + final int kycScore; + final bool safeRouteOk; + final double safeRouteScore; + + CgEstimateResult({ + required this.exchange, + required this.amount, + required this.kycScore, + required this.safeRouteOk, + required this.safeRouteScore, + }); + + factory CgEstimateResult.fromMap(Map map) { + return CgEstimateResult( + exchange: map["Exchange"] as String? ?? "", + amount: (map["Amount"] as num?)?.toDouble() ?? 0.0, + kycScore: (map["KYCScore"] as num?)?.toInt() ?? 0, + safeRouteOk: map["SafeRouteOK"] as bool? ?? false, + safeRouteScore: (map["SafeRouteScore"] as num?)?.toDouble() ?? 0.0, + ); + } +} + +class CgEstimatesResponse { + final List results; + final double min; + final double tradeValueFiat; + final double tradeValueBtc; + final int estimateId; + + CgEstimatesResponse({ + required this.results, + required this.min, + required this.tradeValueFiat, + required this.tradeValueBtc, + required this.estimateId, + }); + + factory CgEstimatesResponse.fromMap(Map map) { + final resultsRaw = map["Results"] as List? ?? []; + return CgEstimatesResponse( + results: resultsRaw + .map((e) => CgEstimateResult.fromMap(Map.from(e as Map))) + .toList(), + min: (map["Min"] as num?)?.toDouble() ?? 0.0, + tradeValueFiat: (map["TradeValue_fiat"] as num?)?.toDouble() ?? 0.0, + tradeValueBtc: (map["TradeValue_btc"] as num?)?.toDouble() ?? 0.0, + estimateId: (map["EstimateId"] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart b/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart new file mode 100644 index 0000000000..e82817d27b --- /dev/null +++ b/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart @@ -0,0 +1,90 @@ +class CgTransaction { + final String coin1; + final String coin2; + final String network1; + final String network2; + final String address; + final double estimateAmount; + final String provider; + final String id; + final double sendAmount; + final String track; + final String status; + final String kyc; + final String token; + final bool done; + final String cgid; + final DateTime createdAt; + final String affiliate; + final String memo; + final String source; + final String destinationAddress; + final bool payment; + final DateTime? completedAt; + final int estimateId; + + CgTransaction({ + required this.coin1, + required this.coin2, + required this.network1, + required this.network2, + required this.address, + required this.estimateAmount, + required this.provider, + required this.id, + required this.sendAmount, + required this.track, + required this.status, + required this.kyc, + required this.token, + required this.done, + required this.cgid, + required this.createdAt, + required this.affiliate, + required this.memo, + required this.source, + required this.destinationAddress, + required this.payment, + required this.completedAt, + required this.estimateId, + }); + + // Go's zero time ("0001-01-01T00:00:00Z") is returned when the field isn't + // set yet; treat it as now rather than storing year 1. + static DateTime _parseDate(String? s) { + if (s == null) return DateTime.now(); + final dt = DateTime.tryParse(s); + if (dt == null || dt.year <= 1) return DateTime.now(); + return dt; + } + + factory CgTransaction.fromMap(Map map) { + return CgTransaction( + coin1: map["Coin1"] as String? ?? "", + coin2: map["Coin2"] as String? ?? "", + network1: map["Network1"] as String? ?? "", + network2: map["Network2"] as String? ?? "", + address: map["Address"] as String? ?? "", + estimateAmount: (map["EstimateAmount"] as num?)?.toDouble() ?? 0.0, + provider: map["Provider"] as String? ?? "", + id: map["Id"] as String? ?? "", + sendAmount: (map["SendAmount"] as num?)?.toDouble() ?? 0.0, + track: map["Track"] as String? ?? "", + status: map["Status"] as String? ?? "waiting", + kyc: map["KYC"] as String? ?? "", + token: map["Token"] as String? ?? "", + done: map["Done"] as bool? ?? false, + cgid: map["CGID"] as String? ?? "", + createdAt: _parseDate(map["CreatedAt"] as String?), + affiliate: map["Affiliate"] as String? ?? "", + memo: map["Memo"] as String? ?? "", + source: map["Source"] as String? ?? "", + destinationAddress: map["DestinationAddress"] as String? ?? "", + payment: map["Payment"] as bool? ?? false, + completedAt: map["CompletedAt"] != null + ? DateTime.tryParse(map["CompletedAt"] as String) + : null, + estimateId: (map["EstimateId"] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart index 85a3f8f522..18a12619d3 100644 --- a/lib/services/exchange/exchange.dart +++ b/lib/services/exchange/exchange.dart @@ -15,6 +15,7 @@ import '../../models/exchange/response_objects/range.dart'; import '../../models/exchange/response_objects/trade.dart'; import '../../models/isar/exchange_cache/currency.dart'; import 'change_now/change_now_exchange.dart'; +import 'cyphergoat/cyphergoat_exchange.dart'; import 'exchange_response.dart'; import 'exolix/exolix_exchange.dart'; import 'nanswap/nanswap_exchange.dart'; @@ -41,6 +42,8 @@ abstract class Exchange { return WizardSwapExchange.instance; case ExolixExchange.exchangeName: return ExolixExchange.instance; + case CypherGoatExchange.exchangeName: + return CypherGoatExchange.instance; default: final split = name.split(" "); if (split.length >= 2) { diff --git a/lib/services/exchange/exchange_data_loading_service.dart b/lib/services/exchange/exchange_data_loading_service.dart index 4f067f8cf1..7410a01a6d 100644 --- a/lib/services/exchange/exchange_data_loading_service.dart +++ b/lib/services/exchange/exchange_data_loading_service.dart @@ -25,6 +25,7 @@ import '../../utilities/logger.dart'; import '../../utilities/prefs.dart'; import '../../utilities/stack_file_system.dart'; import 'change_now/change_now_exchange.dart'; +import 'cyphergoat/cyphergoat_exchange.dart'; import 'exolix/exolix_exchange.dart'; import 'nanswap/nanswap_exchange.dart'; import 'trocador/trocador_exchange.dart'; @@ -211,6 +212,7 @@ class ExchangeDataLoadingService { loadNanswapCurrencies(), loadWizardSwapCurrencies(), loadExolixCurrencies(), + loadCypherGoatCurrencies(), ]; // If using Tor, don't load data for exchanges which don't support Tor. @@ -372,6 +374,30 @@ class ExchangeDataLoadingService { // } // } + Future loadCypherGoatCurrencies() async { + if (_isar == null) { + await initDB(); + } + final responseCurrencies = await CypherGoatExchange.instance + .getAllCurrencies(false); + + if (responseCurrencies.value != null) { + await (await isar).writeTxn(() async { + final idsToDelete = await (await isar).currencies + .where() + .exchangeNameEqualTo(CypherGoatExchange.exchangeName) + .idProperty() + .findAll(); + await (await isar).currencies.deleteAll(idsToDelete); + await (await isar).currencies.putAll(responseCurrencies.value!); + }); + } else { + Logging.instance.w( + "loadCypherGoatCurrencies: $responseCurrencies", + ); + } + } + // Future loadMajesticBankCurrencies() async { // if (_isar == null) { // await initDB(); diff --git a/lib/utilities/assets.dart b/lib/utilities/assets.dart index 9d322d3853..0719d54187 100644 --- a/lib/utilities/assets.dart +++ b/lib/utilities/assets.dart @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; import '../services/exchange/change_now/change_now_exchange.dart'; +import '../services/exchange/cyphergoat/cyphergoat_exchange.dart'; import '../services/exchange/exolix/exolix_exchange.dart'; import '../services/exchange/nanswap/nanswap_exchange.dart'; import '../services/exchange/simpleswap/simpleswap_exchange.dart'; @@ -52,6 +53,7 @@ class _EXCHANGE { String get wizard => "${_path}wizard.svg"; String get exolix => "${_path}exolix.png"; + String get cypherGoat => "${_path}cyphergoat.svg"; String getIconFor({required String exchangeName}) { switch (exchangeName) { @@ -69,6 +71,8 @@ class _EXCHANGE { return wizard; case ExolixExchange.exchangeName: return exolix; + case CypherGoatExchange.exchangeName: + return cypherGoat; default: throw ArgumentError( "Invalid exchange name passed to " diff --git a/scripts/prebuild.ps1 b/scripts/prebuild.ps1 index b9ff36aa9a..d1e5be57ba 100644 --- a/scripts/prebuild.ps1 +++ b/scripts/prebuild.ps1 @@ -2,7 +2,7 @@ $KEYS = "..\lib\external_api_keys.dart" if (-not (Test-Path $KEYS)) { Write-Host "prebuild.ps1: creating template lib/external_api_keys.dart file" - "const kChangeNowApiKey = '';" + "`nconst kSimpleSwapApiKey = '';" + "`nconst kNanswapApiKey = '';" + "`nconst kNanoSwapRpcApiKey = '';" + "`nconst kWizSwapApiKey = '';" + "`nconst kShopInBitAccessKey = '';" + "`nconst kShopInBitPartnerSecret = '';" + "`nconst kCakePayApiToken = '';" + "`nconst kExolixApiKey = '';" | Out-File $KEYS -Encoding UTF8 + "const kChangeNowApiKey = '';" + "`nconst kSimpleSwapApiKey = '';" + "`nconst kNanswapApiKey = '';" + "`nconst kNanoSwapRpcApiKey = '';" + "`nconst kWizSwapApiKey = '';" + "`nconst kShopInBitAccessKey = '';" + "`nconst kShopInBitPartnerSecret = '';" + "`nconst kCakePayApiToken = '';" + "`nconst kExolixApiKey = '';" + "`nconst kCypherGoatApiKey = '';" + "`nconst kCypherGoatAffiliate = '';" | Out-File $KEYS -Encoding UTF8 } # Create template wallet test parameter files if they don't already exist diff --git a/scripts/prebuild.sh b/scripts/prebuild.sh index 6aeaca63b6..c6babf2a04 100755 --- a/scripts/prebuild.sh +++ b/scripts/prebuild.sh @@ -4,7 +4,7 @@ KEYS=../lib/external_api_keys.dart if ! test -f "$KEYS"; then echo 'prebuild.sh: creating template lib/external_api_keys.dart file' - printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\nconst kShopInBitAccessKey = "";\nconst kShopInBitPartnerSecret = "";\nconst kCakePayApiToken = "";\nconst kExolixApiKey = "";\n' > $KEYS + printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\nconst kShopInBitAccessKey = "";\nconst kShopInBitPartnerSecret = "";\nconst kCakePayApiToken = "";\nconst kExolixApiKey = "";\nconst kCypherGoatApiKey = "";\nconst kCypherGoatAffiliate = "";\n' > $KEYS fi # Create template wallet test parameter files if they don't already exist