stack_wallet/lib/electrumx_rpc/electrumx.dart
2023-09-06 18:09:41 -05:00

876 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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:convert';
import 'dart:io';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:decimal/decimal.dart';
import 'package:stackwallet/electrumx_rpc/rpc.dart';
import 'package:stackwallet/exceptions/electrumx/no_such_transaction.dart';
import 'package:stackwallet/networking/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 ElectrumX {
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;
List<ElectrumXNode>? failovers;
int currentFailoverIndex = -1;
final Duration connectionTimeoutForSpecialCaseJsonRPCClients;
({String host, int port})? proxyInfo;
ElectrumX({
required String host,
required int port,
required bool useSSL,
required Prefs prefs,
required List<ElectrumXNode> failovers,
JsonRPC? client,
this.connectionTimeoutForSpecialCaseJsonRPCClients =
const Duration(seconds: 60),
({String host, int port})? proxyInfo,
}) {
_prefs = prefs;
_host = host;
_port = port;
_useSSL = useSSL;
_rpcClient = client;
}
factory ElectrumX.from({
required ElectrumXNode node,
required Prefs prefs,
required List<ElectrumXNode> failovers,
({String host, int port})? proxyInfo,
}) {
if (Prefs.instance.useTor) {
if (proxyInfo == null) {
// TODO await tor / make sure it's running
proxyInfo = (
host: InternetAddress.loopbackIPv4.address,
port: TorService.sharedInstance.port
);
Logging.instance.log("ElectrumX.from(): tor detected at $proxyInfo",
level: LogLevel.Warning);
}
return ElectrumX(
host: node.address,
port: node.port,
useSSL: node.useSSL,
prefs: prefs,
failovers: failovers,
proxyInfo: proxyInfo,
);
} else {
return ElectrumX(
host: node.address,
port: node.port,
useSSL: node.useSSL,
prefs: prefs,
failovers: failovers,
proxyInfo: null,
);
}
}
Future<bool> _allow() async {
if (_prefs.wifiOnly) {
return (await Connectivity().checkConnectivity()) ==
ConnectivityResult.wifi;
}
return true;
}
/// Send raw rpc command
Future<dynamic> request({
required String command,
List<dynamic> args = const [],
String? requestID,
int retries = 2,
Duration requestTimeout = const Duration(seconds: 60),
}) async {
if (!(await _allow())) {
throw WifiOnlyException();
}
if (Prefs.instance.useTor) {
if (proxyInfo == null) {
// TODO await tor / make sure Tor is running
proxyInfo = (
host: InternetAddress.loopbackIPv4.address,
port: TorService.sharedInstance.port
);
Logging.instance.log("ElectrumX.request(): tor detected at $proxyInfo",
level: LogLevel.Warning);
}
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,
);
}
} else {
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,
);
}
}
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!
as Object; // TODO properly check that .exception is an Object
}
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 <request id string : arguments list>
///
/// returns a list of json response objects if no errors were found
Future<List<Map<String, dynamic>>> batchRequest({
required String command,
required Map<String, List<dynamic>> args,
Duration requestTimeout = const Duration(seconds: 60),
int retries = 2,
}) async {
if (!(await _allow())) {
throw WifiOnlyException();
}
if (Prefs.instance.useTor) {
// TODO await tor / make sure Tor is initialized
if (proxyInfo == null) {
proxyInfo = (
host: InternetAddress.loopbackIPv4.address,
port: TorService.sharedInstance.port
);
Logging.instance.log(
"ElectrumX.batchRequest(): tor detected at $proxyInfo",
level: LogLevel.Warning);
}
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,
);
}
} else {
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,
);
}
}
try {
final List<String> 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!
as Object; // TODO properly check that .exception is an Object
}
final response = jsonRpcResponse.data as List;
// check for errors, format and throw if there are any
final List<String> 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<Map<String, dynamic>>.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<bool> 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<String, dynamic>;
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<Map<String, dynamic>> 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<String, dynamic>.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<Map<String, dynamic>> getServerFeatures({String? requestID}) async {
try {
final response = await request(
requestID: requestID,
command: 'server.features',
);
return Map<String, dynamic>.from(response["result"] as Map);
} catch (e) {
rethrow;
}
}
/// Broadcast a transaction to the network.
///
/// The transaction hash as a hexadecimal string.
Future<String> 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<Map<String, dynamic>> getBalance({
required String scripthash,
String? requestID,
}) async {
try {
final response = await request(
requestID: requestID,
command: 'blockchain.scripthash.get_balance',
args: [
scripthash,
],
);
return Map<String, dynamic>.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<List<Map<String, dynamic>>> getHistory({
required String scripthash,
String? requestID,
}) async {
try {
final response = await request(
requestID: requestID,
command: 'blockchain.scripthash.get_history',
requestTimeout: const Duration(minutes: 5),
args: [
scripthash,
],
);
return List<Map<String, dynamic>>.from(response["result"] as List);
} catch (e) {
rethrow;
}
}
Future<Map<String, List<Map<String, dynamic>>>> getBatchHistory(
{required Map<String, List<dynamic>> args}) async {
try {
final response = await batchRequest(
command: 'blockchain.scripthash.get_history',
args: args,
);
final Map<String, List<Map<String, dynamic>>> result = {};
for (int i = 0; i < response.length; i++) {
result[response[i]["id"] as String] =
List<Map<String, dynamic>>.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<List<Map<String, dynamic>>> getUTXOs({
required String scripthash,
String? requestID,
}) async {
try {
final response = await request(
requestID: requestID,
command: 'blockchain.scripthash.listunspent',
args: [
scripthash,
],
);
return List<Map<String, dynamic>>.from(response["result"] as List);
} catch (e) {
rethrow;
}
}
Future<Map<String, List<Map<String, dynamic>>>> getBatchUTXOs(
{required Map<String, List<dynamic>> args}) async {
try {
final response = await batchRequest(
command: 'blockchain.scripthash.listunspent',
args: args,
);
final Map<String, List<Map<String, dynamic>>> result = {};
for (int i = 0; i < response.length; i++) {
result[response[i]["id"] as String] =
List<Map<String, dynamic>>.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<Map<String, dynamic>> getTransaction({
required String txHash,
bool verbose = true,
String? requestID,
}) async {
dynamic response;
try {
response = await request(
requestID: requestID,
command: 'blockchain.transaction.get',
args: [
txHash,
verbose,
],
);
if (!verbose) {
return {"rawtx": response["result"] as String};
}
return Map<String, dynamic>.from(response["result"] as Map);
} catch (e) {
Logging.instance.log(
"getTransaction($txHash) response: $response",
level: LogLevel.Error,
);
rethrow;
}
}
/// Returns the whole 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<Map<String, dynamic>> getAnonymitySet({
String groupId = "1",
String blockhash = "",
String? requestID,
}) async {
try {
Logging.instance.log("attempting to fetch lelantus.getanonymityset...",
level: LogLevel.Info);
final response = await request(
requestID: requestID,
command: 'lelantus.getanonymityset',
args: [
groupId,
blockhash,
],
);
Logging.instance.log("Fetching lelantus.getanonymityset finished",
level: LogLevel.Info);
return Map<String, dynamic>.from(response["result"] as Map);
} catch (e) {
rethrow;
}
}
//TODO add example to docs
///
///
/// Returns the block height and groupId of pubcoin.
Future<dynamic> getMintData({dynamic mints, String? requestID}) async {
try {
final response = await request(
requestID: requestID,
command: 'lelantus.getmintmetadata',
args: [
mints,
],
);
return response["result"];
} catch (e) {
rethrow;
}
}
//TODO add example to docs
/// Returns the whole set of the used coin serials.
Future<Map<String, dynamic>> getUsedCoinSerials({
String? requestID,
required int startNumber,
}) async {
try {
final response = await request(
requestID: requestID,
command: 'lelantus.getusedcoinserials',
args: [
"$startNumber",
],
requestTimeout: const Duration(minutes: 2),
);
return Map<String, dynamic>.from(response["result"] as Map);
} catch (e) {
Logging.instance.log(e, level: LogLevel.Error);
rethrow;
}
}
/// Returns the latest set id
///
/// ex: 1
Future<int> getLatestCoinId({String? requestID}) async {
try {
final response = await request(
requestID: requestID,
command: 'lelantus.getlatestcoinid',
);
return response["result"] as int;
} catch (e) {
Logging.instance.log(e, level: LogLevel.Error);
rethrow;
}
}
// /// Returns about 13 megabytes of json data as of march 2, 2022
// Future<Map<String, dynamic>> getCoinsForRecovery(
// {dynamic setId, String requestID}) async {
// try {
// final response = await request(
// requestID: requestID,
// command: 'lelantus.getcoinsforrecovery',
// args: [
// setId ?? 1,
// ],
// );
// return response["result"];
// } catch (e) {
// Logging.instance.log(e);
// throw e;
// }
// }
/// Get the current fee rate.
///
/// Returns a map with the kay "rate" that corresponds to the free rate in satoshis
/// Ex:
/// {
// "rate": 1000,
// }
Future<Map<String, dynamic>> getFeeRate({String? requestID}) async {
try {
final response = await request(
requestID: requestID,
command: 'blockchain.getfeerate',
);
return Map<String, dynamic>.from(response["result"] as Map);
} catch (e) {
rethrow;
}
}
/// 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<Decimal> estimateFee({String? requestID, required int blocks}) async {
try {
final response = await request(
requestID: requestID,
command: 'blockchain.estimatefee',
args: [
blocks,
],
);
return Decimal.parse(response["result"].toString());
} catch (e) {
rethrow;
}
}
/// Return the minimum fee a low-priority transaction must pay in order to be accepted to the daemons memory pool.
///
/// Returns a Decimal fee rate
/// Ex:
/// 0.00001000
Future<Decimal> relayFee({String? requestID}) async {
try {
final response = await request(
requestID: requestID,
command: 'blockchain.relayfee',
);
return Decimal.parse(response["result"].toString());
} catch (e) {
rethrow;
}
}
}