/* * This file is part of Stack Wallet. * * Copyright (c) 2023 Cypher Stack * All Rights Reserved. * The code is distributed under GPLv3 license, see LICENSE file for details. * Generated by Cypher Stack on 2023-05-26 * */ import 'dart:async'; import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:decimal/decimal.dart'; import 'package:electrum_adapter/electrum_adapter.dart' as electrum_adapter; import 'package:electrum_adapter/electrum_adapter.dart'; import 'package:electrum_adapter/methods/specific/firo.dart'; import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_libsparkmobile/flutter_libsparkmobile.dart'; import 'package:mutex/mutex.dart'; import 'client_manager.dart'; import '../exceptions/electrumx/no_such_transaction.dart'; import '../services/event_bus/events/global/tor_connection_status_changed_event.dart'; import '../services/event_bus/events/global/tor_status_changed_event.dart'; import '../services/event_bus/global_event_bus.dart'; import '../services/tor_service.dart'; import '../utilities/logger.dart'; import '../utilities/prefs.dart'; import '../wallets/crypto_currency/crypto_currency.dart'; import 'package:stream_channel/stream_channel.dart'; class WifiOnlyException implements Exception {} class ElectrumXNode { ElectrumXNode({ required this.address, required this.port, required this.name, required this.id, required this.useSSL, }); final String address; final int port; final String name; final String id; final bool useSSL; factory ElectrumXNode.from(ElectrumXNode node) { return ElectrumXNode( address: node.address, port: node.port, name: node.name, id: node.id, useSSL: node.useSSL, ); } @override String toString() { return "ElectrumXNode: {address: $address, port: $port, name: $name, useSSL: $useSSL}"; } } class ElectrumXClient { final CryptoCurrency cryptoCurrency; String get host => _host; late String _host; int get port => _port; late int _port; bool get useSSL => _useSSL; late bool _useSSL; // StreamChannel? get electrumAdapterChannel => _electrumAdapterChannel; StreamChannel? _electrumAdapterChannel; ElectrumClient? getElectrumAdapter() => ClientManager.sharedInstance.getClient( cryptoCurrency: cryptoCurrency, ); late Prefs _prefs; late TorService _torService; List? failovers; int currentFailoverIndex = -1; final Duration connectionTimeoutForSpecialCaseJsonRPCClients; // add finalizer to cancel stream subscription when all references to an // instance of ElectrumX becomes inaccessible static final Finalizer _finalizer = Finalizer( (p0) { p0._torPreferenceListener?.cancel(); p0._torStatusListener?.cancel(); }, ); StreamSubscription? _torPreferenceListener; StreamSubscription? _torStatusListener; final Mutex _torConnectingLock = Mutex(); bool _requireMutex = false; ElectrumXClient({ required String host, required int port, required bool useSSL, required Prefs prefs, required List failovers, required this.cryptoCurrency, this.connectionTimeoutForSpecialCaseJsonRPCClients = const Duration(seconds: 60), TorService? torService, EventBus? globalEventBusForTesting, }) { _prefs = prefs; _torService = torService ?? TorService.sharedInstance; _host = host; _port = port; _useSSL = useSSL; final bus = globalEventBusForTesting ?? GlobalEventBus.instance; // Listen for tor status changes. _torStatusListener = bus.on().listen( (event) async { switch (event.newStatus) { case TorConnectionStatus.connecting: await _torConnectingLock.acquire(); _requireMutex = true; break; case TorConnectionStatus.connected: case TorConnectionStatus.disconnected: if (_torConnectingLock.isLocked) { _torConnectingLock.release(); } _requireMutex = false; break; } }, ); // Listen for tor preference changes. _torPreferenceListener = bus.on().listen( (event) async { // not sure if we need to do anything specific here // switch (event.status) { // case TorStatus.enabled: // case TorStatus.disabled: // } // setting to null should force the creation of a new json rpc client // on the next request sent through this electrumx instance _electrumAdapterChannel = null; await (await ClientManager.sharedInstance .remove(cryptoCurrency: cryptoCurrency)) ?.close(); // Also close any chain height services that are currently open. // await ChainHeightServiceManager.dispose(); }, ); } factory ElectrumXClient.from({ required ElectrumXNode node, required Prefs prefs, required List failovers, required CryptoCurrency cryptoCurrency, TorService? torService, EventBus? globalEventBusForTesting, }) { return ElectrumXClient( host: node.address, port: node.port, useSSL: node.useSSL, prefs: prefs, torService: torService, failovers: failovers, globalEventBusForTesting: globalEventBusForTesting, cryptoCurrency: cryptoCurrency, ); } Future _allow() async { if (_prefs.wifiOnly) { return (await Connectivity().checkConnectivity()) == ConnectivityResult.wifi; } return true; } Future closeAdapter() async { await getElectrumAdapter()?.close(); } Future _checkElectrumAdapter() async { ({InternetAddress host, int port})? proxyInfo; // If we're supposed to use Tor... if (_prefs.useTor) { // But Tor isn't running... if (_torService.status != TorConnectionStatus.connected) { // And the killswitch isn't set... if (!_prefs.torKillSwitch) { // Then we'll just proceed and connect to ElectrumX through // clearnet at the bottom of this function. Logging.instance.log( "Tor preference set but Tor is not enabled, killswitch not set," " connecting to Electrum adapter through clearnet", level: LogLevel.Warning, ); } else { // ... But if the killswitch is set, then we throw an exception. throw Exception( "Tor preference and killswitch set but Tor is not enabled, " "not connecting to Electrum adapter", ); // TODO [prio=low]: Try to start Tor. } } else { // Get the proxy info from the TorService. proxyInfo = _torService.getProxyInfo(); } } // If the current ElectrumAdapterClient is closed, create a new one. if (getElectrumAdapter() != null && getElectrumAdapter()!.peer.isClosed) { _electrumAdapterChannel = null; await ClientManager.sharedInstance.remove(cryptoCurrency: cryptoCurrency); } final String useHost; final int usePort; final bool useUseSSL; if (currentFailoverIndex == -1) { useHost = host; usePort = port; useUseSSL = useSSL; } else { useHost = failovers![currentFailoverIndex].address; usePort = failovers![currentFailoverIndex].port; useUseSSL = failovers![currentFailoverIndex].useSSL; } _electrumAdapterChannel ??= await electrum_adapter.connect( useHost, port: usePort, connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, aliveTimerDuration: connectionTimeoutForSpecialCaseJsonRPCClients, acceptUnverified: true, useSSL: useUseSSL, proxyInfo: proxyInfo, ); if (getElectrumAdapter() == null) { final ElectrumClient newClient; if (cryptoCurrency is Firo) { newClient = FiroElectrumClient( _electrumAdapterChannel!, useHost, usePort, useUseSSL, proxyInfo, ); } else { newClient = ElectrumClient( _electrumAdapterChannel!, useHost, usePort, useUseSSL, proxyInfo, ); } ClientManager.sharedInstance.addClient( newClient, cryptoCurrency: cryptoCurrency, ); } return; } /// Send raw rpc command Future request({ required String command, List args = const [], String? requestID, int retries = 2, Duration requestTimeout = const Duration(seconds: 60), }) async { if (!(await _allow())) { throw WifiOnlyException(); } if (_requireMutex) { await _torConnectingLock .protect(() async => await _checkElectrumAdapter()); } else { await _checkElectrumAdapter(); } try { final response = await getElectrumAdapter()!.request( command, args, ); if (response is Map && response.keys.contains("error") && response["error"] != null) { if (response["error"] .toString() .contains("No such mempool or blockchain transaction")) { throw NoSuchTransactionException( "No such mempool or blockchain transaction", args.first.toString(), ); } throw Exception( "JSONRPC response\n" " command: $command\n" " error: ${response["error"]}\n" " args: $args\n", ); } currentFailoverIndex = -1; // If the command is a ping, a good return should always be null. if (command.contains("ping")) { return true; } return response; } on WifiOnlyException { rethrow; } on SocketException { // likely timed out so then retry if (retries > 0) { return request( command: command, args: args, requestTimeout: requestTimeout, requestID: requestID, retries: retries - 1, ); } else { rethrow; } } catch (e) { if (failovers != null && currentFailoverIndex < failovers!.length - 1) { currentFailoverIndex++; return request( command: command, args: args, requestTimeout: requestTimeout, requestID: requestID, ); } else { currentFailoverIndex = -1; rethrow; } } } /// send a batch request with [command] where [args] is a /// map of /// /// returns a list of json response objects if no errors were found Future> batchRequest({ required String command, required List args, Duration requestTimeout = const Duration(seconds: 60), int retries = 2, }) async { if (!(await _allow())) { throw WifiOnlyException(); } if (_requireMutex) { await _torConnectingLock .protect(() async => await _checkElectrumAdapter()); } else { await _checkElectrumAdapter(); } try { final futures = >[]; getElectrumAdapter()!.peer.withBatch(() { for (final arg in args) { futures.add(getElectrumAdapter()!.request(command, arg)); } }); final response = await Future.wait(futures); // We cannot modify the response list as the order and length are related // to the order and length of the batched requests! // // // check for errors, format and throw if there are any // final List errors = []; // for (int i = 0; i < response.length; i++) { // var result = response[i]; // // if (result == null || (result is List && result.isEmpty)) { // continue; // // TODO [prio=extreme]: Figure out if this is actually an issue. // } // result = result[0]; // Unwrap the list. // if ((result is Map && result.keys.contains("error")) || // result == null) { // errors.add(result.toString()); // } // } // if (errors.isNotEmpty) { // String error = "[\n"; // for (int i = 0; i < errors.length; i++) { // error += "${errors[i]}\n"; // } // error += "]"; // throw Exception("JSONRPC response error: $error"); // } currentFailoverIndex = -1; return response; } on WifiOnlyException { rethrow; } on SocketException { // likely timed out so then retry if (retries > 0) { return batchRequest( command: command, args: args, requestTimeout: requestTimeout, retries: retries - 1, ); } else { rethrow; } } catch (e) { if (failovers != null && currentFailoverIndex < failovers!.length - 1) { currentFailoverIndex++; return batchRequest( command: command, args: args, requestTimeout: requestTimeout, ); } else { currentFailoverIndex = -1; rethrow; } } } /// Ping the server to ensure it is responding /// /// Returns true if ping succeeded Future ping({String? requestID, int retryCount = 1}) async { try { // This doesn't work because electrum_adapter only returns the result: // (which is always `null`). // await checkElectrumAdapter(); // final response = await electrumAdapterClient! // .ping() // .timeout(const Duration(seconds: 2)); // return (response as Map).isNotEmpty; // Because request() has been updated to use electrum_adapter, and because // electrum_adapter returns the result of the request, request() has been // updated to return a bool on a server.ping command as a special case. return await request( requestID: requestID, command: 'server.ping', requestTimeout: const Duration(seconds: 2), retries: retryCount, ).timeout(const Duration(seconds: 2)) as bool; } catch (e) { rethrow; } } /// Get most recent block header. /// /// Returns a map with keys 'height' and 'hex' corresponding to the block height /// and the binary header as a hexadecimal string. /// Ex: /// { /// "height": 520481, /// "hex": "00000020890208a0ae3a3892aa047c5468725846577cfcd9b512b50000000000000000005dc2b02f2d297a9064ee103036c14d678f9afc7e3d9409cf53fd58b82e938e8ecbeca05a2d2103188ce804c4" /// } Future> getBlockHeadTip({String? requestID}) async { try { final response = await request( requestID: requestID, command: 'blockchain.headers.subscribe', ); if (response == null) { Logging.instance.log( "getBlockHeadTip returned null response", level: LogLevel.Error, ); throw 'getBlockHeadTip returned null response'; } return Map.from(response as Map); } catch (e) { rethrow; } } /// Get server info /// /// Returns a map with server information /// Ex: /// { /// "genesis_hash": "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943", /// "hosts": {"14.3.140.101": {"tcp_port": 51001, "ssl_port": 51002}}, /// "protocol_max": "1.0", /// "protocol_min": "1.0", /// "pruning": null, /// "server_version": "ElectrumX 1.0.17", /// "hash_function": "sha256" /// } Future> getServerFeatures({String? requestID}) async { try { final response = await request( requestID: requestID, command: 'server.features', ); return Map.from(response as Map); } catch (e) { rethrow; } } /// Broadcast a transaction to the network. /// /// The transaction hash as a hexadecimal string. Future broadcastTransaction({ required String rawTx, String? requestID, }) async { try { final response = await request( requestID: requestID, command: 'blockchain.transaction.broadcast', args: [ rawTx, ], ); return response as String; } catch (e) { rethrow; } } /// Return the confirmed and unconfirmed balances for the scripthash of a given scripthash /// /// Returns a map with keys confirmed and unconfirmed. The value of each is /// the appropriate balance in minimum coin units (satoshis). /// Ex: /// { /// "confirmed": 103873966, /// "unconfirmed": 23684400 /// } Future> getBalance({ required String scripthash, String? requestID, }) async { try { final response = await request( requestID: requestID, command: 'blockchain.scripthash.get_balance', args: [ scripthash, ], ); return Map.from(response as Map); } catch (e) { rethrow; } } /// Return the confirmed and unconfirmed history for the given scripthash. /// /// Returns a list of maps that contain the tx_hash and height of the tx. /// Ex: /// [ /// { /// "height": 200004, /// "tx_hash": "acc3758bd2a26f869fcc67d48ff30b96464d476bca82c1cd6656e7d506816412" /// }, /// { /// "height": 215008, /// "tx_hash": "f3e1bf48975b8d6060a9de8884296abb80be618dc00ae3cb2f6cee3085e09403" /// } /// ] Future>> getHistory({ required String scripthash, String? requestID, }) async { try { int retryCount = 3; dynamic result; while (retryCount > 0 && result is! List) { final response = await request( requestID: requestID, command: 'blockchain.scripthash.get_history', requestTimeout: const Duration(minutes: 5), args: [ scripthash, ], ); result = response; retryCount--; } return List>.from(result as List); } catch (e) { rethrow; } } Future>>> getBatchHistory({ required List args, }) async { try { final response = await batchRequest( command: 'blockchain.scripthash.get_history', args: args, ); final List>> result = []; for (int i = 0; i < response.length; i++) { result.add(List>.from(response[i] as List)); } return result; } catch (e) { rethrow; } } /// Return an ordered list of UTXOs sent to a script hash of the given scripthash. /// /// Returns a list of maps. /// Ex: /// [ /// { /// "tx_pos": 0, /// "value": 45318048, /// "tx_hash": "9f2c45a12db0144909b5db269415f7319179105982ac70ed80d76ea79d923ebf", /// "height": 437146 /// }, /// { /// "tx_pos": 0, /// "value": 919195, /// "tx_hash": "3d2290c93436a3e964cfc2f0950174d8847b1fbe3946432c4784e168da0f019f", /// "height": 441696 /// } /// ] Future>> getUTXOs({ required String scripthash, String? requestID, }) async { try { final response = await request( requestID: requestID, command: 'blockchain.scripthash.listunspent', args: [ scripthash, ], ); return List>.from(response as List); } catch (e) { rethrow; } } Future>>> getBatchUTXOs({ required List args, }) async { try { final response = await batchRequest( command: 'blockchain.scripthash.listunspent', args: args, ); final List>> result = []; for (int i = 0; i < response.length; i++) { if ((response[i] as List).isNotEmpty) { try { final data = List>.from(response[i] as List); result.add(data); } catch (e) { // to ensure we keep same length of responses as requests/args // add empty list on error result.add([]); Logging.instance.log( "getBatchUTXOs failed to parse response=${response[i]}: $e", level: LogLevel.Error, ); } } } return result; } catch (e) { rethrow; } } /// Returns a raw transaction given the tx_hash. /// /// Returns a list of maps. /// Ex when verbose=false: /// "01000000015bb9142c960a838329694d3fe9ba08c2a6421c5158d8f7044cb7c48006c1b48" /// "4000000006a4730440220229ea5359a63c2b83a713fcc20d8c41b20d48fe639a639d2a824" /// "6a137f29d0fc02201de12de9c056912a4e581a62d12fb5f43ee6c08ed0238c32a1ee76921" /// "3ca8b8b412103bcf9a004f1f7a9a8d8acce7b51c983233d107329ff7c4fb53e44c855dbe1" /// "f6a4feffffff02c6b68200000000001976a9141041fb024bd7a1338ef1959026bbba86006" /// "4fe5f88ac50a8cf00000000001976a91445dac110239a7a3814535c15858b939211f85298" /// "88ac61ee0700" /// /// /// Ex when verbose=true: /// { /// "blockhash": "0000000000000000015a4f37ece911e5e3549f988e855548ce7494a0a08b2ad6", /// "blocktime": 1520074861, /// "confirmations": 679, /// "hash": "36a3692a41a8ac60b73f7f41ee23f5c917413e5b2fad9e44b34865bd0d601a3d", /// "hex": "01000000015bb9142c960a838329694d3fe9ba08c2a6421c5158d8f7044cb7c48006c1b484000000006a4730440220229ea5359a63c2b83a713fcc20d8c41b20d48fe639a639d2a8246a137f29d0fc02201de12de9c056912a4e581a62d12fb5f43ee6c08ed0238c32a1ee769213ca8b8b412103bcf9a004f1f7a9a8d8acce7b51c983233d107329ff7c4fb53e44c855dbe1f6a4feffffff02c6b68200000000001976a9141041fb024bd7a1338ef1959026bbba860064fe5f88ac50a8cf00000000001976a91445dac110239a7a3814535c15858b939211f8529888ac61ee0700", /// "locktime": 519777, /// "size": 225, /// "time": 1520074861, /// "txid": "36a3692a41a8ac60b73f7f41ee23f5c917413e5b2fad9e44b34865bd0d601a3d", /// "version": 1, /// "vin": [ { /// "scriptSig": { /// "asm": "30440220229ea5359a63c2b83a713fcc20d8c41b20d48fe639a639d2a8246a137f29d0fc02201de12de9c056912a4e581a62d12fb5f43ee6c08ed0238c32a1ee769213ca8b8b[ALL|FORKID] 03bcf9a004f1f7a9a8d8acce7b51c983233d107329ff7c4fb53e44c855dbe1f6a4", /// "hex": "4730440220229ea5359a63c2b83a713fcc20d8c41b20d48fe639a639d2a8246a137f29d0fc02201de12de9c056912a4e581a62d12fb5f43ee6c08ed0238c32a1ee769213ca8b8b412103bcf9a004f1f7a9a8d8acce7b51c983233d107329ff7c4fb53e44c855dbe1f6a4" /// }, /// "sequence": 4294967294, /// "txid": "84b4c10680c4b74c04f7d858511c42a6c208bae93f4d692983830a962c14b95b", /// "vout": 0}], /// "vout": [ { "n": 0, /// "scriptPubKey": { "addresses": [ "12UxrUZ6tyTLoR1rT1N4nuCgS9DDURTJgP"], /// "asm": "OP_DUP OP_HASH160 1041fb024bd7a1338ef1959026bbba860064fe5f OP_EQUALVERIFY OP_CHECKSIG", /// "hex": "76a9141041fb024bd7a1338ef1959026bbba860064fe5f88ac", /// "reqSigs": 1, /// "type": "pubkeyhash"}, /// "value": 0.0856647}, /// { "n": 1, /// "scriptPubKey": { "addresses": [ "17NMgYPrguizvpJmB1Sz62ZHeeFydBYbZJ"], /// "asm": "OP_DUP OP_HASH160 45dac110239a7a3814535c15858b939211f85298 OP_EQUALVERIFY OP_CHECKSIG", /// "hex": "76a91445dac110239a7a3814535c15858b939211f8529888ac", /// "reqSigs": 1, /// "type": "pubkeyhash"}, /// "value": 0.1360904}]} Future> getTransaction({ required String txHash, bool verbose = true, String? requestID, }) async { Logging.instance.log( "attempting to fetch blockchain.transaction.get...", level: LogLevel.Info, ); await _checkElectrumAdapter(); final dynamic response = await getElectrumAdapter()!.getTransaction(txHash); Logging.instance.log( "Fetching blockchain.transaction.get finished", level: LogLevel.Info, ); if (!verbose) { return {"rawtx": response as String}; } return Map.from(response as Map); } /// Returns the whole Lelantus anonymity set for denomination in the groupId. /// /// ex: /// { /// "blockHash": "37effb57352693f4efcb1710bf68e3a0d79ff6b8f1605529de3e0706d9ca21da", /// "setHash": "aae1a64f19f5ccce1c242dfe331d8db2883a9508d998efa3def8a64844170fe4", /// "coins": [ /// [dynamic list of length 4], /// [dynamic list of length 4], /// .... /// [dynamic list of length 4], /// [dynamic list of length 4], /// ] /// } Future> getLelantusAnonymitySet({ String groupId = "1", String blockhash = "", String? requestID, }) async { Logging.instance.log( "attempting to fetch lelantus.getanonymityset...", level: LogLevel.Info, ); await _checkElectrumAdapter(); final Map response = await (getElectrumAdapter() as FiroElectrumClient) .getLelantusAnonymitySet(groupId: groupId, blockHash: blockhash); Logging.instance.log( "Fetching lelantus.getanonymityset finished", level: LogLevel.Info, ); return response; } //TODO add example to docs /// /// /// Returns the block height and groupId of a Lelantus pubcoin. Future getLelantusMintData({ dynamic mints, String? requestID, }) async { Logging.instance.log( "attempting to fetch lelantus.getmintmetadata...", level: LogLevel.Info, ); await _checkElectrumAdapter(); final dynamic response = await (getElectrumAdapter() as FiroElectrumClient) .getLelantusMintData(mints: mints); Logging.instance.log( "Fetching lelantus.getmintmetadata finished", level: LogLevel.Info, ); return response; } //TODO add example to docs /// Returns the whole set of the used Lelantus coin serials. Future> getLelantusUsedCoinSerials({ String? requestID, required int startNumber, }) async { Logging.instance.log( "attempting to fetch lelantus.getusedcoinserials...", level: LogLevel.Info, ); await _checkElectrumAdapter(); int retryCount = 3; dynamic response; while (retryCount > 0 && response is! List) { response = await (getElectrumAdapter() as FiroElectrumClient) .getLelantusUsedCoinSerials(startNumber: startNumber); // TODO add 2 minute timeout. Logging.instance.log( "Fetching lelantus.getusedcoinserials finished", level: LogLevel.Info, ); retryCount--; } return Map.from(response as Map); } /// Returns the latest Lelantus set id /// /// ex: 1 Future getLelantusLatestCoinId({String? requestID}) async { Logging.instance.log( "attempting to fetch lelantus.getlatestcoinid...", level: LogLevel.Info, ); await _checkElectrumAdapter(); final int response = await (getElectrumAdapter() as FiroElectrumClient).getLatestCoinId(); Logging.instance.log( "Fetching lelantus.getlatestcoinid finished", level: LogLevel.Info, ); return response; } // ============== Spark ====================================================== // New Spark ElectrumX methods: // > Functions provided by ElectrumX servers // > // > /// Returns the whole Spark anonymity set for denomination in the groupId. /// /// Takes [coinGroupId] and [startBlockHash], if the last is empty it returns full set, /// otherwise returns mint after that block, we need to call this to keep our /// anonymity set data up to date. /// /// Returns blockHash (last block hash), /// setHash (hash of current set) /// and coins (the list of pairs serialized coin and tx hash) Future> getSparkAnonymitySet({ String coinGroupId = "1", String startBlockHash = "", String? requestID, }) async { try { Logging.instance.log( "attempting to fetch spark.getsparkanonymityset...", level: LogLevel.Info, ); await _checkElectrumAdapter(); final Map response = await (getElectrumAdapter() as FiroElectrumClient) .getSparkAnonymitySet( coinGroupId: coinGroupId, startBlockHash: startBlockHash, ); Logging.instance.log( "Fetching spark.getsparkanonymityset finished", level: LogLevel.Info, ); return response; } catch (e) { rethrow; } } /// Takes [startNumber], if it is 0, we get the full set, /// otherwise the used tags after that number Future> getSparkUsedCoinsTags({ String? requestID, required int startNumber, }) async { try { // Use electrum_adapter package's getSparkUsedCoinsTags method. Logging.instance.log( "attempting to fetch spark.getusedcoinstags...", level: LogLevel.Info, ); await _checkElectrumAdapter(); final Map response = await (getElectrumAdapter() as FiroElectrumClient) .getUsedCoinsTags(startNumber: startNumber); // TODO: Add 2 minute timeout. // Why 2 minutes? Logging.instance.log( "Fetching spark.getusedcoinstags finished", level: LogLevel.Info, ); final map = Map.from(response); final set = Set.from(map["tags"] as List); return await compute(_ffiHashTagsComputeWrapper, set); } catch (e) { Logging.instance.log(e, level: LogLevel.Error); rethrow; } } /// Takes a list of [sparkCoinHashes] and returns the set id and block height /// for each coin /// /// arg: /// { /// "coinHashes": [ /// "b476ed2b374bb081ea51d111f68f0136252521214e213d119b8dc67b92f5a390", /// "b476ed2b374bb081ea51d111f68f0136252521214e213d119b8dc67b92f5a390", /// ] /// } Future>> getSparkMintMetaData({ String? requestID, required List sparkCoinHashes, }) async { try { Logging.instance.log( "attempting to fetch spark.getsparkmintmetadata...", level: LogLevel.Info, ); await _checkElectrumAdapter(); final List response = await (getElectrumAdapter() as FiroElectrumClient) .getSparkMintMetaData(sparkCoinHashes: sparkCoinHashes); Logging.instance.log( "Fetching spark.getsparkmintmetadata finished", level: LogLevel.Info, ); return List>.from(response); } catch (e) { Logging.instance.log(e, level: LogLevel.Error); rethrow; } } /// Returns the latest Spark set id /// /// ex: 1 Future getSparkLatestCoinId({ String? requestID, }) async { try { Logging.instance.log( "attempting to fetch spark.getsparklatestcoinid...", level: LogLevel.Info, ); await _checkElectrumAdapter(); final int response = await (getElectrumAdapter() as FiroElectrumClient) .getSparkLatestCoinId(); Logging.instance.log( "Fetching spark.getsparklatestcoinid finished", level: LogLevel.Info, ); return response; } catch (e) { Logging.instance.log(e, level: LogLevel.Error); rethrow; } } // =========================================================================== /// Get the current fee rate. /// /// Returns a map with the kay "rate" that corresponds to the free rate in satoshis /// Ex: /// { /// "rate": 1000, /// } Future> getFeeRate({String? requestID}) async { await _checkElectrumAdapter(); return await getElectrumAdapter()!.getFeeRate(); } /// Return the estimated transaction fee per kilobyte for a transaction to be /// confirmed within a certain number of [blocks]. /// /// Returns a Decimal fee rate /// Ex: /// 0.00001000 Future estimateFee({String? requestID, required int blocks}) async { try { final response = await request( requestID: requestID, command: 'blockchain.estimatefee', args: [ blocks, ], ); try { // If the response is -1 or null, return a temporary hardcoded value for // Dogecoin. This is a temporary fix until the fee estimation is fixed. if (cryptoCurrency is Dogecoin && (response == null || response == -1 || Decimal.parse(response.toString()) == Decimal.parse("-1"))) { // Return 0.05 for slow, 0.2 for average, and 1 for fast txs. // These numbers produce tx fees in line with txs in the wild on // https://dogechain.info/ return Decimal.parse((1 / blocks).toString()); // TODO [prio=med]: Fix fee estimation. } return Decimal.parse(response.toString()); } catch (e, s) { final String msg = "Error parsing fee rate. Response: $response" "\nResult: $response\nError: $e\nStack trace: $s"; Logging.instance.log(msg, level: LogLevel.Fatal); throw Exception(msg); } } catch (e) { rethrow; } } /// Return the minimum fee a low-priority transaction must pay in order to be accepted to the daemon’s memory pool. /// /// Returns a Decimal fee rate /// Ex: /// 0.00001000 Future relayFee({String? requestID}) async { try { final response = await request( requestID: requestID, command: 'blockchain.relayfee', ); return Decimal.parse(response.toString()); } catch (e) { rethrow; } } } Set _ffiHashTagsComputeWrapper(Set base64Tags) { return LibSpark.hashTags(base64Tags: base64Tags); }