diff --git a/README.md b/README.md index be29e2a7..542f06fd 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,54 @@ obtained from the connected node when not explicitly specified. If you only need the signed transaction but don't intend to send it, you can use `client.signTransaction`. +## Request with multiple querys + +As of JSON-RPC specification, one can make several queries in one http request. +For using this feature, instead of using the common Web3Client, you have to instantiate +a `MultiQueryWeb3Client`, which has the same functionality as ao Web3Client, but adds the +`client.multiQueryCall` method. +For usability purposes, some helper classes have been implemented to ask for different required queries inside the same request. + +The `multiqueryCall` method requires a list of `ETHRpcQuery` instances +You can easily construct them thanks to these useful custom constructors. For instance, to ask for the balance, you can use `ETHRpcQuery.getBalance`. + +All queries inside this list must satisfy a condition: either all of them or none of them should have an rpc query id assigned (to manage the responses id from the server). + +For any other not specified rpc method, you can construct it yourself using the normal `ETHRpcQuery` constructor. The trick here would be correctly parsing the result as desired with the `decodeFn` parameter. + +Example usage: + +```dart +final client = MultiQueryWeb3Client(apiUrl, Client()); +final queries = [ + EthRPCQuery.getBalance( + id: 2, + address: EthereumAddress.fromHex( + '0x81bEdCC7314baf7606b665909CeCDB4c68b180d6', + ), + ), + EthRPCQuery.callContract( + id: 1, + contractCallParams: EthContractCallParams( + contract: contract, + function: contract.function('balanceOf'), + params: [ + EthereumAddress.fromHex( + '0x81bEdCC7314baf7606b665909CeCDB4c68b180d6', + ), + ], + ), + ), + EthRPCQuery.getBlockInformation( + block: BlockNum.exact(8302276), + id: 3, + ) + ]; + + final responses = await client.multiQueryCall(queries); +``` + + ### Smart contracts The library can parse the abi of a smart contract and send data to it. It can also diff --git a/lib/src/core/client.dart b/lib/src/core/client.dart index e0a71e56..81c0525b 100644 --- a/lib/src/core/client.dart +++ b/lib/src/core/client.dart @@ -1,4 +1,4 @@ -part of 'package:web3dart/web3dart.dart'; +part of '../../web3dart.dart'; /// Signature for a function that opens a socket on which json-rpc operations /// can be performed. diff --git a/lib/src/core/eth_rpc_query/eth_rpc_query.dart b/lib/src/core/eth_rpc_query/eth_rpc_query.dart new file mode 100644 index 00000000..05b92657 --- /dev/null +++ b/lib/src/core/eth_rpc_query/eth_rpc_query.dart @@ -0,0 +1,255 @@ +part of '../../../web3dart.dart'; + +/// D stands for decoded result +/// R stands for raw result +/// The idea is to maintain a stable typing when expecting raw results +/// and when using functions to parsing them. +/// Sadly Dart is not flexible with generic constructors nor factories, +/// so all "factories" are static methods (view factories.dart file) +typedef DecodableFunction = D Function(R); + +class EthQueryResult { + EthQueryResult(this.result, this.id); + + final T result; + final int id; + + @override + String toString() { + return '{"id": $id , "result": $result}'; + } +} + +class EthRPCQuery extends RPCQuery { + EthRPCQuery._({ + required String function, + List params = const [], + int? id, + required DecodableFunction decodeFn, + }) : _decodeFunction = decodeFn, + super(function, params, id); + + final DecodableFunction _decodeFunction; + + EthQueryResult decodeResult(R rawResult) => + EthQueryResult(_decodeFunction(rawResult), id!); + + EthRPCQuery copyWithId( + int id, + ) => + EthRPCQuery._( + id: id, + function: function, + params: this.params ?? [], + decodeFn: _decodeFunction, + ); + + /// Returns balance in Ether wei units of the address. (hex) + static EthRPCQuery getBalance({ + required EthereumAddress address, + BlockNum atBlock = const BlockNum.current(), + int? id, + }) => + EthRPCQuery._( + function: 'eth_getBalance', + params: [ + address.with0x, + atBlock.toBlockParam(), + ], + id: id, + decodeFn: (r) => hexToInt(r), + ); + + /// Returns the amount of Ether in wei typically needed to pay for + /// one unit of gas. (hex) + static EthRPCQuery getGasPrice(int? id) => EthRPCQuery._( + function: 'eth_gasPrice', + id: id, + decodeFn: (r) => EtherAmount.fromHex(r), + ); + + static EthRPCQuery estimateGas( + int? id, + ) => + EthRPCQuery._( + function: 'eth_estimateGas', + id: id, + decodeFn: (r) => EtherAmount.fromHex(r), + ); + + /// Returns the result of calling a contract as a List of returned results + static EthRPCQuery callContract({ + required EthContractCallParams contractCallParams, + BlockNum block = const BlockNum.current(), + int? id, + }) => + EthRPCQuery, String>._( + function: 'eth_call', + params: [ + { + 'to': contractCallParams.contract.address.with0x, + 'data': bytesToHex( + contractCallParams.function.encodeCall( + contractCallParams.params, + ), + include0x: true, + padToEvenLength: true, + ), + if (contractCallParams.sender != null) + 'from': contractCallParams.sender!.with0x, + }, + block.toBlockParam(), + ], + id: id, + decodeFn: (r) { + return contractCallParams.function.decodeReturnValues(r); + }, + ); + + /// Returns metadata of a certain block. [returnTransactionObjects] + /// parameter defines if txs details should be returned in this call, + /// or only the tx hashes. (map) + static EthRPCQuery getBlockInformation({ + required BlockNum block, + bool returnTransactionObjects = false, + int? id, + }) => + EthRPCQuery>._( + function: 'eth_getBlockByNumber', + params: [ + block.toBlockParam(), + returnTransactionObjects, + ], + id: id, + decodeFn: (r) => BlockInformation.fromJson(r), + ); + + static EthRPCQuery getTransactionCount({ + required EthereumAddress address, + BlockNum blockNum = const BlockNum.current(), + int? id, + }) => + EthRPCQuery._( + function: 'eth_getTransactionCount', + id: id, + decodeFn: (r) => hexToDartInt(r), + ); + + static EthRPCQuery sendRawTransaction( + Uint8List signedTransaction, + int? id, + ) => + EthRPCQuery._( + function: 'eth_sendRawTransaction', + params: [ + bytesToHex( + signedTransaction, + include0x: true, + padToEvenLength: true, + ), + ], + id: id, + decodeFn: (r) => r, + ); + + /// Returns the information of a transaction + static EthRPCQuery getTransactionByHash( + String hash, + int? id, + ) => + EthRPCQuery?>._( + function: 'eth_getTransactionByHash', + params: [hash], + id: id, + decodeFn: (r) => r != null ? TransactionInformation.fromMap(r) : null, + ); + + /// Returns a receipt of a transaction + static EthRPCQuery getTransactionReceipt( + String hash, + int? id, + ) => + EthRPCQuery?>._( + function: 'eth_getTransactionReceipt', + params: [hash], + id: id, + decodeFn: (r) => r != null ? TransactionReceipt.fromMap(r) : null, + ); + + // Returns version of the client (String) + static EthRPCQuery getClientVersion(int? id) => EthRPCQuery._( + function: 'web3_clientVersion', + id: id, + decodeFn: (r) => r, + ); + + /// Returns network id (int) + static EthRPCQuery getNetworkId(int? id) => EthRPCQuery._( + function: 'net_version', + id: id, + decodeFn: (r) => int.parse(r), + ); + + /// Returns chain id (hex) + /// https://chainid.network/chains.json + static EthRPCQuery getChainId(int? id) => EthRPCQuery._( + function: 'eth_chainId', + id: id, + decodeFn: (r) => hexToInt(r), + ); + + /// Returns the version of the Ethereum-protocol (hex) + static EthRPCQuery getEthProtocolVersion(int? id) => + EthRPCQuery._( + function: 'eth_protocolVersion', + id: id, + decodeFn: (r) => hexToDartInt(r), + ); + + /// Returns the coinbase address (hex) + static EthRPCQuery coinbaseAddress(int? id) => + EthRPCQuery._( + function: 'eth_coinbase', + id: id, + decodeFn: (r) => EthereumAddress.fromHex(r), + ); + + /// Returns if the client is currently mining (bool) + static EthRPCQuery isMining(int? id) => EthRPCQuery._( + function: 'eth_mining', + id: id, + decodeFn: (r) => r, + ); + + /// Returns the amount of hashes per second the connected node is + /// mining with. (int) + static EthRPCQuery getMiningHashrate(int? id) => EthRPCQuery._( + function: 'eth_hashrate', + id: id, + decodeFn: (r) => hexToDartInt(r), + ); + + /// Returns the number of the most recent mined block on the chain. + /// (int) + static EthRPCQuery getBlockNumber(int? id) => EthRPCQuery._( + function: 'eth_blockNumber', + id: id, + decodeFn: (r) => hexToDartInt(r), + ); + + /// Return the code at a specific address (hex) + static EthRPCQuery getCode({ + required EthereumAddress address, + BlockNum block = const BlockNum.current(), + int? id, + }) => + EthRPCQuery._( + function: 'eth_getCode', + params: [ + address.with0x, + block.toBlockParam(), + ], + id: id, + decodeFn: (r) => hexToBytes(r), + ); +} diff --git a/lib/src/core/eth_rpc_query/params_classes.dart b/lib/src/core/eth_rpc_query/params_classes.dart new file mode 100644 index 00000000..1ad0f6a8 --- /dev/null +++ b/lib/src/core/eth_rpc_query/params_classes.dart @@ -0,0 +1,43 @@ +part of '../../../web3dart.dart'; + +class EthContractCallParams { + EthContractCallParams({ + this.sender, + required this.contract, + required this.function, + required this.params, + this.atBlock = const BlockNum.current(), + this.rpcId, + }); + + final EthereumAddress? sender; + final DeployedContract contract; + final ContractFunction function; + final List params; + final BlockNum? atBlock; + final String? rpcId; +} + +class EthEstimateGasParams { + EthEstimateGasParams({ + this.sender, + this.to, + this.value, + this.amountOfGas, + this.gasPrice, + this.maxPriorityFeePerGas, + this.maxFeePerGas, + this.data, + this.rpcId, + }); + + final EthereumAddress? sender; + final EthereumAddress? to; + final EtherAmount? value; + final BigInt? amountOfGas; + final EtherAmount? gasPrice; + final EtherAmount? maxPriorityFeePerGas; + final EtherAmount? maxFeePerGas; + final Uint8List? data; + final String? rpcId; +} diff --git a/lib/src/core/filters.dart b/lib/src/core/filters.dart index a97dcec0..8786a4dd 100644 --- a/lib/src/core/filters.dart +++ b/lib/src/core/filters.dart @@ -1,4 +1,4 @@ -part of 'package:web3dart/web3dart.dart'; +part of '../../web3dart.dart'; class _FilterCreationParams { _FilterCreationParams(this.method, this.params); diff --git a/lib/src/core/multiquery_client.dart b/lib/src/core/multiquery_client.dart new file mode 100644 index 00000000..60a2d486 --- /dev/null +++ b/lib/src/core/multiquery_client.dart @@ -0,0 +1,79 @@ +part of '../../web3dart.dart'; + +class MultiQueryWeb3Client extends Web3Client { + MultiQueryWeb3Client( + String url, + Client httpClient, { + SocketConnector? socketConnector, + }) : super.custom( + JsonRPCMultiQuery(url, httpClient), + socketConnector: socketConnector, + ); + + /// Method used to make several contract calls and/or various rpc queries, using only + /// one request to the eth client. + /// The resulting list of responses will a mix of [RPCError] instance/s and/or + /// [EthRPCQuery] instance/s with the returned value + Future> multiQueryCall( + List queries, + ) async { + // Each instance of contract call is mapped to an id (the index). + // This is intended to later find out how to decode the returned values. + + late final allQueriesWithId = queries.every((c) => c.id != null); + late final allQueriesWithNoId = queries.every((c) => c.id == null); + // Queries should be passed: all with id or all without id + if (!allQueriesWithId && !allQueriesWithNoId) { + throw ArgumentError( + 'Some but not all querys have been provided with an RPC id.' + 'You must assign an id to each call or leave all calls without any assigned id'); + } + + final Map preparedQueries = {}; + int lastId = 0; + for (var q in queries) { + final id = q.id ?? lastId++; + preparedQueries[id] = q.copyWithId(id); + } + + final responses = await (_jsonRpc as MultiQueryRpcService) + .callMultiQuery(preparedQueries.values.toList()); + if (responses.length != queries.length) { + throw Error.throwWithStackTrace( + 'Eth node client did not respond correctly to all the queries', + StackTrace.current, + ); + } + // The decoded responses will be either [RPCError] instance/s or + // [EthRPCQuery] instance/s with the returned value + final decodedResponses = []; + for (final res in responses) { + // each response can be either an error or a correct value returned + if (res is RPCResponse) { + final correspondingQuery = preparedQueries[res.id]!; + final decodedResult = correspondingQuery.decodeResult(res.result); + + decodedResponses.add(decodedResult); + } else if (res is RPCError) { + decodedResponses.add(res); + } + } + + // sorting responses by querys order (not id order) + final sortedResponsesList = []; + + for (var k = 0; k > preparedQueries.keys.length; k++) { + final sameIdResponse = decodedResponses.firstWhere((dynamic r) { + if (r is RPCError) { + return r.id == preparedQueries[k]!.id; + } else if (r is RPCResponse) { + return r.id == preparedQueries[k]!.id; + } + return false; + }); + sortedResponsesList[k] = sameIdResponse; + } + + return decodedResponses; + } +} diff --git a/lib/src/core/transaction.dart b/lib/src/core/transaction.dart index de7a59d7..e4b561af 100644 --- a/lib/src/core/transaction.dart +++ b/lib/src/core/transaction.dart @@ -1,4 +1,4 @@ -part of 'package:web3dart/web3dart.dart'; +part of '../../web3dart.dart'; class Transaction { Transaction({ diff --git a/lib/src/core/transaction_information.dart b/lib/src/core/transaction_information.dart index f5dcb20d..29af6fb8 100644 --- a/lib/src/core/transaction_information.dart +++ b/lib/src/core/transaction_information.dart @@ -1,4 +1,4 @@ -part of 'package:web3dart/web3dart.dart'; +part of '../../web3dart.dart'; class TransactionInformation { TransactionInformation.fromMap(Map map) diff --git a/lib/json_rpc.dart b/lib/src/rpc/json_rpc.dart similarity index 93% rename from lib/json_rpc.dart rename to lib/src/rpc/json_rpc.dart index ac68da3c..6cbdb48f 100644 --- a/lib/json_rpc.dart +++ b/lib/src/rpc/json_rpc.dart @@ -1,11 +1,4 @@ -library json_rpc; - -import 'dart:async'; -import 'dart:convert'; - -import 'package:http/http.dart'; - -// ignore: one_member_abstracts +part of '../../web3dart.dart'; /// RPC Service base class. abstract class RpcService { @@ -93,7 +86,10 @@ class RPCResponse { /// Exception thrown when an the server returns an error code to an rpc request. class RPCError implements Exception { /// Constructor. - const RPCError(this.errorCode, this.message, this.data); + const RPCError(this.errorCode, this.message, this.data, [this.id]); + + /// Id. + final int? id; /// Error code. final int errorCode; diff --git a/lib/src/rpc/json_rpc_multiquery.dart b/lib/src/rpc/json_rpc_multiquery.dart new file mode 100644 index 00000000..a2d53c0a --- /dev/null +++ b/lib/src/rpc/json_rpc_multiquery.dart @@ -0,0 +1,72 @@ +part of '../../web3dart.dart'; + +abstract class MultiQueryRpcService { + /// Performs a single RPC request, asking the server to execute several queries + /// using the functions with associated parameters for each one, the parameters + /// need to be encodable with the [json] class of dart:convert. + /// + /// When the request is successful, a list is returned, containing each query + /// response. This responses can be either an [RPCResponse] on success, or an + /// [RPCError] on failure. + /// No [RPCError] instances will be thrown, they will only be part of the list. + /// Other errors might be thrown if an IO-Error occurs. + Future> callMultiQuery(List queries); +} + +class JsonRPCMultiQuery extends JsonRPC implements MultiQueryRpcService { + JsonRPCMultiQuery(String url, Client client) : super(url, client); + + int _currentRequestId = 0; + + @override + Future> callMultiQuery(List queries) async { + final payloadList = >[]; + for (final query in queries) { + payloadList.add( + { + 'jsonrpc': '2.0', + 'method': query.function, + 'params': query.params ?? [], + 'id': query.id ?? _currentRequestId++, + }, + ); + } + final response = await client.post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: json.encode(payloadList), + ); + + // responses list will be RPCResponse and/or RPCError instances + final responses = []; + + final dataList = json.decode(response.body) as List; + final castedList = dataList.cast>(); + + for (final data in castedList) { + if (data.containsKey('error')) { + final id = data['id'] as int; + final error = data['error']; + + final code = error['code'] as int; + final message = error['message'] as String; + final errorData = error['data']; + + responses.add(RPCError(code, message, errorData, id)); + } + + final id = data['id'] as int; + final result = data['result']; + responses.add(RPCResponse(id, result)); + } + return responses; + } +} + +class RPCQuery { + RPCQuery(this.function, [this.params, this.id]); + + final String function; + final int? id; + final List? params; +} diff --git a/lib/src/utils/length_tracking_byte_sink.dart b/lib/src/utils/length_tracking_byte_sink.dart index a3789b6b..661421aa 100644 --- a/lib/src/utils/length_tracking_byte_sink.dart +++ b/lib/src/utils/length_tracking_byte_sink.dart @@ -1,4 +1,4 @@ -part of 'package:web3dart/web3dart.dart'; +part of '../../web3dart.dart'; class LengthTrackingByteSink extends ByteConversionSinkBase { final Uint8Buffer _buffer = Uint8Buffer(); diff --git a/lib/web3dart.dart b/lib/web3dart.dart index 6efea329..16d2e76c 100644 --- a/lib/web3dart.dart +++ b/lib/web3dart.dart @@ -1,9 +1,9 @@ library web3dart; import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'dart:math'; -import 'dart:convert'; import 'package:convert/convert.dart'; import 'package:pointycastle/export.dart'; import 'package:sec/sec.dart'; @@ -21,7 +21,6 @@ import 'package:pointycastle/key_derivators/scrypt.dart' as scrypt; import 'package:pointycastle/src/utils.dart' as p_utils; import 'package:web3dart/web3dart.dart' as secp256k1; -import 'json_rpc.dart'; import 'src/core/block_number.dart'; import 'src/core/sync_information.dart'; @@ -34,11 +33,15 @@ export 'src/core/sync_information.dart'; export 'src/utils/rlp.dart'; export 'src/utils/typed_data.dart'; +part 'src/core/eth_rpc_query/eth_rpc_query.dart'; +part 'src/core/eth_rpc_query/params_classes.dart'; +part 'src/core/multiquery_client.dart'; part 'src/core/client.dart'; part 'src/core/filters.dart'; part 'src/core/transaction.dart'; part 'src/core/transaction_information.dart'; part 'src/core/transaction_signer.dart'; + part 'src/utils/length_tracking_byte_sink.dart'; part 'src/credentials/credentials.dart'; @@ -57,3 +60,6 @@ part 'src/crypto/formatting.dart'; part 'src/crypto/keccak.dart'; part 'src/crypto/random_bridge.dart'; part 'src/crypto/secp256k1.dart'; + +part 'src/rpc/json_rpc.dart'; +part 'src/rpc/json_rpc_multiquery.dart'; diff --git a/test/json_rpc_multiquery_test.dart b/test/json_rpc_multiquery_test.dart new file mode 100644 index 00000000..f0302404 --- /dev/null +++ b/test/json_rpc_multiquery_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:test/test.dart'; +import 'package:web3dart/web3dart.dart'; + +void main() { + late MockClient client; + + setUp(() { + client = MockClient(); + }); + + test('encodes and sends requests', () async { + final queries = [ + RPCQuery('eth_gasPrice'), + RPCQuery( + 'eth_getBalance', + ['0x95222290dd7278aa32dd189cc1e1d165cc4bafe5'], + ), + ]; + await JsonRPCMultiQuery('url', client).callMultiQuery(queries); + + final request = client.request!; + + expect( + request.headers, + containsPair('Content-Type', startsWith('application/json')), + ); + + expect( + request, + isA(), + ); + + final expectedBody = + '[{"jsonrpc":"2.0","method":"eth_gasPrice","params":[],"id":1},{"jsonrpc":"2.0","method":"eth_getBalance","params":["0x95222290dd7278aa32dd189cc1e1d165cc4bafe5"],"id":2}]'; + expect((request as Request).body, equals(expectedBody)); + }); + + test('automatically increments request id when none provided', () async { + final rpc = JsonRPCMultiQuery('url', client); + final queries = [ + RPCQuery('eth_gasPrice'), + RPCQuery( + 'eth_getBalance', + ['0x95222290dd7278aa32dd189cc1e1d165cc4bafe5'], + ), + ]; + await rpc.callMultiQuery(queries); + + final lastRequest = client.request!; + expect( + lastRequest.finalize().bytesToString(), + completion(contains('"id":2')), + ); + }); + + test('returns errors', () { + final rpc = JsonRPCMultiQuery('url', client); + client.nextResponse = StreamedResponse( + Stream.value( + utf8.encode( + '[' + '{"id": 1, "jsonrpc": "2.0", ' + '"error": {"code": 1, "message": "Message", "data": "data"}}, ' + '{"id": 2, "jsonrpc": "2.0", ' + '"error": {"code": 1, "message": "Message", "data": "data"}}' + ']', + ), + ), + 200, + ); + + expect( + rpc.callMultiQuery( + [RPCQuery('eth_gasPrice')], + ), + completion(anyElement(isA())), + ); + }); +} + +class MockClient extends BaseClient { + StreamedResponse? nextResponse; + BaseRequest? request; + + @override + Future send(BaseRequest request) { + this.request = request; + return Future.value( + nextResponse ?? + StreamedResponse( + Stream.value( + utf8.encode( + '[{"id": 1, "jsonrpc": "2.0", "result": "0x1"},' + '{"id": 2, "jsonrpc": "2.0", "result": "0x1"}]', + ), + ), + 200, + ), + ); + } +} diff --git a/test/json_rpc_test.dart b/test/json_rpc_test.dart index aeb78461..e301e0bb 100644 --- a/test/json_rpc_test.dart +++ b/test/json_rpc_test.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:test/test.dart'; -import 'package:web3dart/json_rpc.dart'; +import 'package:web3dart/web3dart.dart'; final uri = Uri.parse('url'); diff --git a/test/multiquery_client_integration_test.dart b/test/multiquery_client_integration_test.dart new file mode 100644 index 00000000..e4fab279 --- /dev/null +++ b/test/multiquery_client_integration_test.dart @@ -0,0 +1,201 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:test/test.dart'; +import 'package:wallet/wallet.dart'; +import 'package:web3dart/web3dart.dart'; + +const infuraProjectId = String.fromEnvironment('INFURA_ID'); + +void main() { + final contract = DeployedContract( + ContractAbi.fromJson(erc20TestTokenAbi, 'Link ERC20'), + EthereumAddress.fromHex( + '0x326C977E6efc84E512bB9C30f76E30c160eD06FB', + ), + ); + group('integration', () { + late final MultiQueryWeb3Client client; + + setUpAll(() { + client = MultiQueryWeb3Client( + // public rpc https://chainlist.org/chain/5 + 'https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161', + Client(), + ); + }); + + // ignore: unnecessary_lambdas, https://github.com/dart-lang/linter/issues/2670 + tearDownAll(() => client.dispose()); + + test('Multiquery request success', () async { + final queries = [ + EthRPCQuery.getBalance( + id: 2, + address: EthereumAddress.fromHex( + '0x81bEdCC7314baf7606b665909CeCDB4c68b180d6', + ), + ), + EthRPCQuery.callContract( + id: 1, + contractCallParams: EthContractCallParams( + contract: contract, + function: contract.function('balanceOf'), + params: [ + EthereumAddress.fromHex( + '0x81bEdCC7314baf7606b665909CeCDB4c68b180d6', + ), + ], + ), + ), + EthRPCQuery.getBlockInformation( + block: BlockNum.exact(8302276), + id: 3, + ), + ]; + + final responses = await client.multiQueryCall(queries); + + expect(responses, everyElement(isA())); + + final balanceResult = (responses[0] as EthQueryResult); + expect( + balanceResult.id, + equals(queries[0].id), + ); + expect(balanceResult.result, greaterThan(BigInt.zero)); + + final erc20BalanceResult = (responses[1] as EthQueryResult); + expect( + erc20BalanceResult.id, + equals(queries[1].id), + ); + // contract result azlways come in a list + expect(erc20BalanceResult.result[0], greaterThan(BigInt.zero)); + + final blockInfoResult = (responses[2] as EthQueryResult); + expect( + blockInfoResult.id, + equals(queries[2].id), + ); + expect(blockInfoResult.result, isA()); + }); + }); + + group( + 'Query id assignment', + () { + late final MultiQueryWeb3Client client; + + setUpAll(() { + client = MultiQueryWeb3Client( + 'mock url', + MockClient(), + ); + }); + test('Multiquery request arguments - ids assigned OK', () async { + final queries = [ + EthRPCQuery.getBalance( + address: EthereumAddress.fromHex( + '0x81bEdCC7314baf7606b665909CeCDB4c68b180d6', + ), + id: 0, + ), + EthRPCQuery.getBalance( + address: EthereumAddress.fromHex( + '0x81bEdCC7314baf7606b665909CeCDB4c68b180d6', + ), + id: 1, + ), + ]; + + expect( + () { + client.multiQueryCall(queries); + }, + returnsNormally, + ); + }); + test('Multiquery request arguments - no ids assigned', () async { + final queries = [ + EthRPCQuery.getBalance( + address: EthereumAddress.fromHex( + '0x81bEdCC7314baf7606b665909CeCDB4c68b180d6', + ), + ), + EthRPCQuery.getBalance( + address: EthereumAddress.fromHex( + '0x81bEdCC7314baf7606b665909CeCDB4c68b180d6', + ), + ), + ]; + + expect( + () { + client.multiQueryCall(queries); + }, + returnsNormally, + ); + }); + test('Multiquery request arguments - failure, bad rpc ids assignment', + () async { + final queries = [ + EthRPCQuery.getBalance( + address: EthereumAddress.fromHex( + '0x81bEdCC7314baf7606b665909CeCDB4c68b180d6', + ), + id: 4, + ), + EthRPCQuery.callContract( + contractCallParams: EthContractCallParams( + contract: contract, + function: contract.function('balanceOf'), + params: [ + EthereumAddress.fromHex( + '0x81bEdCC7314baf7606b665909CeCDB4c68b180d6', + ), + ], + ), + id: 1, + ), + EthRPCQuery.getBlockInformation( + block: BlockNum.exact(8302276), + // here we avoid specifying an id to make it throw + ), + ]; + + expect( + client.multiQueryCall(queries), + throwsArgumentError, + reason: + 'As a bad assignment in querys id, calling this method should throw', + ); + }); + }, + ); +} + +class MockClient extends BaseClient { + StreamedResponse? nextResponse; + BaseRequest? request; + + @override + Future send(BaseRequest request) { + this.request = request; + return Future.value( + nextResponse ?? + StreamedResponse( + Stream.value( + utf8.encode( + '[{"id": "0", "jsonrpc": "2.0", "result": "0x1"},' + '{"id": "1", "jsonrpc": "2.0", "result": "0x1"}]', + ), + ), + 200, + ), + ); + } +} + +const erc20TestTokenAbi = + '[{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_value","type":"uint256"}],"name":"approve","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_from","type":"address"},{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],"name":"transferFrom","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_to","type":"address"},{"name":"_value","type":"uint256"},{"name":"_data","type":"bytes"}],"name":"transferAndCall","outputs":[{"name":"success","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_subtractedValue","type":"uint256"}],"name":"decreaseApproval","outputs":[{"name":"success","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"_owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_to","type":"address"},{"name":"_value","type":"uint256"}],"name":"transfer","outputs":[{"name":"success","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"_spender","type":"address"},{"name":"_addedValue","type":"uint256"}],"name":"increaseApproval","outputs":[{"name":"success","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"_owner","type":"address"},{"name":"_spender","type":"address"}],"name":"allowance","outputs":[{"name":"remaining","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"name":"from","type":"address"},{"indexed":true,"name":"to","type":"address"},{"indexed":false,"name":"value","type":"uint256"},{"indexed":false,"name":"data","type":"bytes"}],"name":"Transfer","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"owner","type":"address"},{"indexed":true,"name":"spender","type":"address"},{"indexed":false,"name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"from","type":"address"},{"indexed":true,"name":"to","type":"address"},{"indexed":false,"name":"value","type":"uint256"}],"name":"Transfer","type":"event"}]';