/* * 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:convert'; 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/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 'package:stackwallet/electrumx_rpc/rpc.dart'; import 'package:stackwallet/exceptions/electrumx/no_such_transaction.dart'; import 'package:stackwallet/services/event_bus/events/global/tor_connection_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/events/global/tor_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/global_event_bus.dart'; import 'package:stackwallet/services/tor_service.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:uuid/uuid.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 { String get host => _host; late String _host; int get port => _port; late int _port; bool get useSSL => _useSSL; late bool _useSSL; JsonRPC? get rpcClient => _rpcClient; JsonRPC? _rpcClient; 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, JsonRPC? client, this.connectionTimeoutForSpecialCaseJsonRPCClients = const Duration(seconds: 60), TorService? torService, EventBus? globalEventBusForTesting, }) { _prefs = prefs; _torService = torService ?? TorService.sharedInstance; _host = host; _port = port; _useSSL = useSSL; _rpcClient = client; final bus = globalEventBusForTesting ?? GlobalEventBus.instance; _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; } }, ); _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: // } // might be ok to just reset/kill the current _jsonRpcClient // since disconnecting is async and we want to ensure instant change over // we will keep temp reference to current rpc client to call disconnect // on before awaiting the disconnection future final temp = _rpcClient; // setting to null should force the creation of a new json rpc client // on the next request sent through this electrumx instance _rpcClient = null; await temp?.disconnect( reason: "Tor status changed to \"${event.status}\"", ); }, ); } factory ElectrumXClient.from({ required ElectrumXNode node, required Prefs prefs, required List failovers, TorService? torService, EventBus? globalEventBusForTesting, }) { return ElectrumXClient( host: node.address, port: node.port, useSSL: node.useSSL, prefs: prefs, torService: torService, failovers: failovers, globalEventBusForTesting: globalEventBusForTesting, ); } Future _allow() async { if (_prefs.wifiOnly) { return (await Connectivity().checkConnectivity()) == ConnectivityResult.wifi; } return true; } void _checkRpcClient() { // 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 ElectrumX 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 ElectrumX"); // TODO [prio=low]: Try to start Tor. } } else { // Get the proxy info from the TorService. final proxyInfo = _torService.getProxyInfo(); if (currentFailoverIndex == -1) { _rpcClient ??= JsonRPC( host: host, port: port, useSSL: useSSL, connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, proxyInfo: proxyInfo, ); } else { _rpcClient ??= JsonRPC( host: failovers![currentFailoverIndex].address, port: failovers![currentFailoverIndex].port, useSSL: failovers![currentFailoverIndex].useSSL, connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, proxyInfo: proxyInfo, ); } if (_rpcClient!.proxyInfo != proxyInfo) { _rpcClient!.proxyInfo = proxyInfo; _rpcClient!.disconnect( reason: "Tor proxyInfo does not match current info", ); } return; } } if (currentFailoverIndex == -1) { _rpcClient ??= JsonRPC( host: host, port: port, useSSL: useSSL, connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, proxyInfo: null, ); } else { _rpcClient ??= JsonRPC( host: failovers![currentFailoverIndex].address, port: failovers![currentFailoverIndex].port, useSSL: failovers![currentFailoverIndex].useSSL, connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, proxyInfo: null, ); } } /// 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 => _checkRpcClient()); } else { _checkRpcClient(); } try { final requestId = requestID ?? const Uuid().v1(); final jsonArgs = json.encode(args); final jsonRequestString = '{"jsonrpc": "2.0", ' '"id": "$requestId",' '"method": "$command",' '"params": $jsonArgs}'; // Logging.instance.log("ElectrumX jsonRequestString: $jsonRequestString"); final response = await _rpcClient!.request( jsonRequestString, requestTimeout, ); if (response.exception != null) { throw response.exception!; } if (response.data is Map && response.data["error"] != null) { if (response.data["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.data}" " args: $args\n", ); } currentFailoverIndex = -1; return response.data; } 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 Map> args, Duration requestTimeout = const Duration(seconds: 60), int retries = 2, }) async { if (!(await _allow())) { throw WifiOnlyException(); } if (_requireMutex) { await _torConnectingLock.protect(() async => _checkRpcClient()); } else { _checkRpcClient(); } try { final List requestStrings = []; for (final entry in args.entries) { final jsonArgs = json.encode(entry.value); requestStrings.add( '{"jsonrpc": "2.0", "id": "${entry.key}","method": "$command","params": $jsonArgs}'); } // combine request strings into json array String request = "["; for (int i = 0; i < requestStrings.length - 1; i++) { request += "${requestStrings[i]},"; } request += "${requestStrings.last}]"; // Logging.instance.log("batch request: $request"); // send batch request final jsonRpcResponse = (await _rpcClient!.request(request, requestTimeout)); if (jsonRpcResponse.exception != null) { throw jsonRpcResponse.exception!; } final List response; try { if (jsonRpcResponse.data is Map) { response = [jsonRpcResponse.data]; if (requestStrings.length > 1) { Logging.instance.log( "ElectrumXClient.batchRequest: Map returned instead of a list and there are ${requestStrings.length} queued.", level: LogLevel.Error); } // Could throw error here. } else { response = jsonRpcResponse.data as List; } } catch (_) { throw Exception( "Expected json list or map but got a ${jsonRpcResponse.data.runtimeType}: ${jsonRpcResponse.data}", ); } // check for errors, format and throw if there are any final List errors = []; for (int i = 0; i < response.length; i++) { final result = response[i]; if (result["error"] != null || result["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 List>.from(response, growable: false); } 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 { final response = await request( requestID: requestID, command: 'server.ping', requestTimeout: const Duration(seconds: 2), retries: retryCount, ).timeout(const Duration(seconds: 2)) as Map; return response.keys.contains("result") && response["result"] == null; } 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["result"] == null) { Logging.instance.log( "getBlockHeadTip returned null response", level: LogLevel.Error, ); throw 'getBlockHeadTip returned null response'; } return Map.from(response["result"] 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["result"] 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["result"] 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["result"] 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["result"]; retryCount--; } return List>.from(result as List); } catch (e) { rethrow; } } Future>>> getBatchHistory( {required Map> args}) async { try { final response = await batchRequest( command: 'blockchain.scripthash.get_history', args: args, ); final Map>> result = {}; for (int i = 0; i < response.length; i++) { result[response[i]["id"] as String] = List>.from(response[i]["result"] 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["result"] as List); } catch (e) { rethrow; } } Future>>> getBatchUTXOs( {required Map> args}) async { try { final response = await batchRequest( command: 'blockchain.scripthash.listunspent', args: args, ); final Map>> result = {}; for (int i = 0; i < response.length; i++) { result[response[i]["id"] as String] = List>.from(response[i]["result"] as List); } 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); var channel = await electrum_adapter.connect(host, port: port); // TODO pass useSLL. var client = electrum_adapter.ElectrumClient(channel, host, port); dynamic response = await client.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); var channel = await electrum_adapter.connect(host, port: port); var client = electrum_adapter.FiroElectrumClient(channel); Map anonymitySet = await client.getLelantusAnonymitySet( groupId: groupId, blockHash: blockhash); Logging.instance.log("Fetching lelantus.getanonymityset finished", level: LogLevel.Info); return anonymitySet; } //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); var channel = await electrum_adapter.connect(host, port: port); var client = electrum_adapter.FiroElectrumClient(channel); dynamic mintData = await client.getLelantusMintData(mints: mints); Logging.instance.log("Fetching lelantus.getmintmetadata finished", level: LogLevel.Info); return mintData; } //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); var channel = await electrum_adapter.connect('firo.stackwallet.com'); var client = electrum_adapter.FiroElectrumClient(channel); int retryCount = 3; dynamic usedCoinSerials; while (retryCount > 0 && usedCoinSerials is! List) { usedCoinSerials = await client.getLelantusUsedCoinSerials(startNumber: startNumber); // TODO add 2 minute timeout. Logging.instance.log("Fetching lelantus.getusedcoinserials finished", level: LogLevel.Info); retryCount--; } return Map.from(usedCoinSerials 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); var channel = await electrum_adapter.connect('firo.stackwallet.com'); var client = electrum_adapter.FiroElectrumClient(channel); int latestCoinId = await client.getLatestCoinId(); Logging.instance.log("Fetching lelantus.getlatestcoinid finished", level: LogLevel.Info); return latestCoinId; } // ============== 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); var channel = await electrum_adapter.connect('firo.stackwallet.com'); var client = electrum_adapter.FiroElectrumClient(channel); Map anonymitySet = await client.getSparkAnonymitySet( coinGroupId: coinGroupId, startBlockHash: startBlockHash); Logging.instance.log("Fetching spark.getsparkanonymityset finished", level: LogLevel.Info); return anonymitySet; } 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); var channel = await electrum_adapter.connect('firo.stackwallet.com'); var client = electrum_adapter.FiroElectrumClient(channel); Map usedCoinsTags = await client.getUsedCoinsTags(startNumber: startNumber); // TODO: Add 2 minute timeout. Logging.instance.log("Fetching spark.getusedcoinstags finished", level: LogLevel.Info); final map = Map.from(usedCoinsTags); 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); var channel = await electrum_adapter.connect('firo.stackwallet.com'); var client = electrum_adapter.FiroElectrumClient(channel); List mintMetaData = await client.getSparkMintMetaData(sparkCoinHashes: sparkCoinHashes); Logging.instance.log("Fetching spark.getsparkmintmetadata finished", level: LogLevel.Info); return List>.from(mintMetaData); } 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); var channel = await electrum_adapter.connect(host, port: port); var client = electrum_adapter.FiroElectrumClient(channel); int latestCoinId = await client.getSparkLatestCoinId(); Logging.instance.log("Fetching spark.getsparklatestcoinid finished", level: LogLevel.Info); return latestCoinId; } 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 { var channel = await electrum_adapter.connect(host, port: port); var client = electrum_adapter.FiroElectrumClient(channel); return await client.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 { return Decimal.parse(response["result"].toString()); } catch (e, s) { final String msg = "Error parsing fee rate. Response: $response" "\nResult: ${response["result"]}\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["result"].toString()); } catch (e) { rethrow; } } } Set _ffiHashTagsComputeWrapper(Set base64Tags) { return LibSpark.hashTags(base64Tags: base64Tags); }