2023-12-01 16:20:52 +00:00
|
|
|
# Fluffy
|
2024-02-28 17:31:45 +00:00
|
|
|
# Copyright (c) 2021-2024 Status Research & Development GmbH
|
2021-12-03 08:51:25 +00:00
|
|
|
# Licensed and distributed under either of
|
|
|
|
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
|
|
|
|
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
|
|
|
|
# at your option. This file may not be copied, modified, or distributed except according to those terms.
|
|
|
|
|
|
|
|
import
|
2022-07-20 10:46:42 +00:00
|
|
|
os,
|
2021-12-03 08:51:25 +00:00
|
|
|
std/sequtils,
|
2024-02-28 17:31:45 +00:00
|
|
|
unittest2,
|
|
|
|
testutils,
|
|
|
|
confutils,
|
|
|
|
chronos,
|
2022-07-29 12:24:07 +00:00
|
|
|
stew/byteutils,
|
2024-02-28 17:31:45 +00:00
|
|
|
eth/p2p/discoveryv5/random2,
|
|
|
|
eth/keys,
|
2024-03-13 15:58:50 +00:00
|
|
|
../common/common_types,
|
2022-02-11 13:43:10 +00:00
|
|
|
../rpc/portal_rpc_client,
|
|
|
|
../rpc/eth_rpc_client,
|
2024-02-28 17:31:45 +00:00
|
|
|
../eth_data/[history_data_seeding, history_data_json_store, history_data_ssz_e2s],
|
2024-09-23 16:56:28 +00:00
|
|
|
../network/history/[history_content, validation/historical_hashes_accumulator],
|
2024-09-24 11:07:20 +00:00
|
|
|
../tests/history_network_tests/test_history_util
|
2021-12-03 08:51:25 +00:00
|
|
|
|
|
|
|
type
|
2024-02-28 17:31:45 +00:00
|
|
|
FutureCallback[A] = proc(): Future[A] {.gcsafe, raises: [].}
|
2022-07-20 10:46:42 +00:00
|
|
|
|
2024-02-28 17:31:45 +00:00
|
|
|
CheckCallback[A] = proc(a: A): bool {.gcsafe, raises: [].}
|
2022-07-20 10:46:42 +00:00
|
|
|
|
2021-12-03 08:51:25 +00:00
|
|
|
PortalTestnetConf* = object
|
2024-02-28 17:31:45 +00:00
|
|
|
nodeCount* {.defaultValue: 17, desc: "Number of nodes to test", name: "node-count".}:
|
|
|
|
int
|
2021-12-03 08:51:25 +00:00
|
|
|
|
|
|
|
rpcAddress* {.
|
2024-02-28 17:31:45 +00:00
|
|
|
desc: "Listening address of the JSON-RPC service for all nodes",
|
|
|
|
defaultValue: "127.0.0.1",
|
|
|
|
name: "rpc-address"
|
|
|
|
.}: string
|
2021-12-03 08:51:25 +00:00
|
|
|
|
|
|
|
baseRpcPort* {.
|
2024-02-28 17:31:45 +00:00
|
|
|
defaultValue: 10000,
|
|
|
|
desc: "Port of the JSON-RPC service of the bootstrap (first) node",
|
|
|
|
name: "base-rpc-port"
|
|
|
|
.}: uint16
|
2021-12-03 08:51:25 +00:00
|
|
|
|
2024-02-28 17:31:45 +00:00
|
|
|
proc connectToRpcServers(config: PortalTestnetConf): Future[seq[RpcClient]] {.async.} =
|
2021-12-03 08:51:25 +00:00
|
|
|
var clients: seq[RpcClient]
|
2024-02-28 17:31:45 +00:00
|
|
|
for i in 0 ..< config.nodeCount:
|
2021-12-03 08:51:25 +00:00
|
|
|
let client = newRpcHttpClient()
|
2024-02-28 17:31:45 +00:00
|
|
|
await client.connect(config.rpcAddress, Port(config.baseRpcPort + uint16(i)), false)
|
2021-12-03 08:51:25 +00:00
|
|
|
clients.add(client)
|
|
|
|
|
|
|
|
return clients
|
|
|
|
|
2022-07-20 10:46:42 +00:00
|
|
|
proc withRetries[A](
|
2024-02-28 17:31:45 +00:00
|
|
|
f: FutureCallback[A],
|
|
|
|
check: CheckCallback[A],
|
|
|
|
numRetries: int,
|
|
|
|
initialWait: Duration,
|
|
|
|
checkFailMessage: string,
|
|
|
|
nodeIdx: int,
|
|
|
|
): Future[A] {.async.} =
|
2022-07-20 10:46:42 +00:00
|
|
|
## Retries given future callback until either:
|
|
|
|
## it returns successfuly and given check is true
|
|
|
|
## or
|
|
|
|
## function reaches max specified retries
|
|
|
|
|
|
|
|
var tries = 0
|
|
|
|
var currentDuration = initialWait
|
|
|
|
|
|
|
|
while true:
|
|
|
|
try:
|
|
|
|
let res = await f()
|
|
|
|
if check(res):
|
|
|
|
return res
|
2022-07-26 11:14:56 +00:00
|
|
|
else:
|
|
|
|
raise newException(ValueError, checkFailMessage)
|
2022-07-20 10:46:42 +00:00
|
|
|
except CatchableError as exc:
|
|
|
|
if tries > numRetries:
|
|
|
|
# if we reached max number of retries fail
|
2024-02-28 17:31:45 +00:00
|
|
|
let msg =
|
|
|
|
"Call failed with msg: " & exc.msg & ", for node with idx: " & $nodeIdx
|
2022-07-29 12:24:07 +00:00
|
|
|
raise newException(ValueError, msg)
|
2022-07-20 10:46:42 +00:00
|
|
|
|
2022-07-26 11:14:56 +00:00
|
|
|
inc tries
|
2022-07-20 10:46:42 +00:00
|
|
|
# wait before new retry
|
|
|
|
await sleepAsync(currentDuration)
|
|
|
|
currentDuration = currentDuration * 2
|
|
|
|
|
|
|
|
# Sometimes we need to wait till data will be propagated over the network.
|
|
|
|
# To avoid long sleeps, this combinator can be used to retry some calls until
|
|
|
|
# success or until some condition hold (or both)
|
2022-07-29 12:24:07 +00:00
|
|
|
proc retryUntil[A](
|
2024-02-28 17:31:45 +00:00
|
|
|
f: FutureCallback[A], c: CheckCallback[A], checkFailMessage: string, nodeIdx: int
|
|
|
|
): Future[A] =
|
2022-07-29 12:24:07 +00:00
|
|
|
# some reasonable limits, which will cause waits as: 1, 2, 4, 8, 16, 32 seconds
|
2024-11-04 17:02:56 +00:00
|
|
|
return withRetries(f, c, 2, seconds(1), checkFailMessage, nodeIdx)
|
2022-07-20 10:46:42 +00:00
|
|
|
|
2022-02-16 21:06:43 +00:00
|
|
|
# Note:
|
|
|
|
# When doing json-rpc requests following `RpcPostError` can occur:
|
|
|
|
# "Failed to send POST Request with JSON-RPC." when a `HttpClientRequestRef`
|
|
|
|
# POST request is send in the json-rpc http client.
|
|
|
|
# This error is raised when the httpclient hits error:
|
|
|
|
# "Could not send request headers", which in its turn is caused by the
|
|
|
|
# "Incomplete data sent or received" in `AsyncStream`, which is caused by
|
|
|
|
# `ECONNRESET` or `EPIPE` error (see `isConnResetError()`) on the TCP stream.
|
|
|
|
# This can occur when the server side closes the connection, which happens after
|
|
|
|
# a `httpHeadersTimeout` of default 10 seconds (set on `HttpServerRef.new()`).
|
|
|
|
# In order to avoid here hitting this timeout a `close()` is done after each
|
|
|
|
# json-rpc call. Because the first json-rpc call opens up the connection, and it
|
|
|
|
# remains open until a close() (or timeout). No need to do another connect
|
|
|
|
# before any new call as the proc `connectToRpcServers` doesn't actually connect
|
|
|
|
# to servers, as client.connect doesn't do that. It just sets the `httpAddress`.
|
|
|
|
# Yes, this client json rpc API couldn't be more confusing.
|
|
|
|
# Could also just retry each call on failure, which would set up a new
|
|
|
|
# connection.
|
|
|
|
|
2021-12-03 08:51:25 +00:00
|
|
|
# We are kind of abusing the unittest2 here to run json rpc tests against other
|
|
|
|
# processes. Needs to be compiled with `-d:unittest2DisableParamFiltering` or
|
|
|
|
# the confutils cli will not work.
|
|
|
|
procSuite "Portal testnet tests":
|
|
|
|
let config = PortalTestnetConf.load()
|
|
|
|
let rng = newRng()
|
|
|
|
|
2022-02-02 21:48:33 +00:00
|
|
|
asyncTest "Discv5 - Random node lookup from each node":
|
2021-12-03 08:51:25 +00:00
|
|
|
let clients = await connectToRpcServers(config)
|
|
|
|
|
2022-02-02 21:48:33 +00:00
|
|
|
var nodeInfos: seq[NodeInfo]
|
|
|
|
for client in clients:
|
|
|
|
let nodeInfo = await client.discv5_nodeInfo()
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2022-02-02 21:48:33 +00:00
|
|
|
nodeInfos.add(nodeInfo)
|
|
|
|
|
|
|
|
# Kick off the network by trying to add all records to each node.
|
|
|
|
# These nodes are also set as seen, so they get passed along on findNode
|
|
|
|
# requests.
|
|
|
|
# Note: The amount of Records added here can be less but then the
|
|
|
|
# probability that all nodes will still be reached needs to be calculated.
|
|
|
|
# Note 2: One could also ping all nodes but that is much slower and more
|
|
|
|
# error prone
|
|
|
|
for client in clients:
|
2024-02-28 17:31:45 +00:00
|
|
|
discard await client.discv5_addEnrs(
|
|
|
|
nodeInfos.map(
|
|
|
|
proc(x: NodeInfo): Record =
|
|
|
|
x.enr
|
|
|
|
)
|
|
|
|
)
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2022-02-02 21:48:33 +00:00
|
|
|
|
|
|
|
for client in clients:
|
2021-12-03 08:51:25 +00:00
|
|
|
let routingTableInfo = await client.discv5_routingTableInfo()
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2021-12-03 08:51:25 +00:00
|
|
|
var start: seq[NodeId]
|
|
|
|
let nodes = foldl(routingTableInfo.buckets, a & b, start)
|
2022-02-02 21:48:33 +00:00
|
|
|
# A node will have at least the first bucket filled. One could increase
|
|
|
|
# this based on the probability that x amount of nodes fit in the buckets.
|
|
|
|
check nodes.len >= (min(config.nodeCount - 1, 16))
|
2021-12-03 08:51:25 +00:00
|
|
|
|
2022-02-02 21:48:33 +00:00
|
|
|
# grab a random node its `NodeInfo` and lookup that node from all nodes.
|
|
|
|
let randomNodeInfo = sample(rng[], nodeInfos)
|
|
|
|
for client in clients:
|
|
|
|
var enr: Record
|
2022-02-16 21:06:43 +00:00
|
|
|
enr = await client.discv5_lookupEnr(randomNodeInfo.nodeId)
|
2022-12-13 18:22:36 +00:00
|
|
|
check enr == randomNodeInfo.enr
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2022-02-02 21:48:33 +00:00
|
|
|
|
|
|
|
asyncTest "Portal State - Random node lookup from each node":
|
2021-12-03 08:51:25 +00:00
|
|
|
let clients = await connectToRpcServers(config)
|
|
|
|
|
2022-02-02 21:48:33 +00:00
|
|
|
var nodeInfos: seq[NodeInfo]
|
2024-05-30 16:12:28 +00:00
|
|
|
|
2021-12-03 08:51:25 +00:00
|
|
|
for client in clients:
|
2024-05-30 16:12:28 +00:00
|
|
|
let nodeInfo = await client.portal_stateNodeInfo()
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2022-02-02 21:48:33 +00:00
|
|
|
nodeInfos.add(nodeInfo)
|
2021-12-03 08:51:25 +00:00
|
|
|
|
|
|
|
for client in clients:
|
2024-05-30 16:12:28 +00:00
|
|
|
discard await client.portal_stateAddEnrs(
|
2024-02-28 17:31:45 +00:00
|
|
|
nodeInfos.map(
|
|
|
|
proc(x: NodeInfo): Record =
|
|
|
|
x.enr
|
|
|
|
)
|
|
|
|
)
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2021-12-03 08:51:25 +00:00
|
|
|
|
2022-02-02 21:48:33 +00:00
|
|
|
for client in clients:
|
2024-05-30 16:12:28 +00:00
|
|
|
let routingTableInfo = await client.portal_stateRoutingTableInfo()
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2021-12-03 08:51:25 +00:00
|
|
|
var start: seq[NodeId]
|
|
|
|
let nodes = foldl(routingTableInfo.buckets, a & b, start)
|
2022-02-02 21:48:33 +00:00
|
|
|
check nodes.len >= (min(config.nodeCount - 1, 16))
|
2021-12-03 08:51:25 +00:00
|
|
|
|
2022-02-02 21:48:33 +00:00
|
|
|
# grab a random node its `NodeInfo` and lookup that node from all nodes.
|
|
|
|
let randomNodeInfo = sample(rng[], nodeInfos)
|
|
|
|
for client in clients:
|
|
|
|
var enr: Record
|
|
|
|
try:
|
2024-05-30 16:12:28 +00:00
|
|
|
enr = await client.portal_stateLookupEnr(randomNodeInfo.nodeId)
|
2022-02-02 21:48:33 +00:00
|
|
|
except CatchableError as e:
|
|
|
|
echo e.msg
|
|
|
|
# TODO: For state network this occasionally fails. It might be because the
|
|
|
|
# distance function is not used in all locations, or perhaps it just
|
|
|
|
# doesn't converge to the target always with this distance function. To be
|
|
|
|
# further investigated.
|
|
|
|
skip()
|
2022-12-13 18:22:36 +00:00
|
|
|
# check enr == randomNodeInfo.enr
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2022-02-02 21:48:33 +00:00
|
|
|
|
|
|
|
asyncTest "Portal History - Random node lookup from each node":
|
2021-12-08 08:26:31 +00:00
|
|
|
let clients = await connectToRpcServers(config)
|
|
|
|
|
2022-02-02 21:48:33 +00:00
|
|
|
var nodeInfos: seq[NodeInfo]
|
2021-12-08 08:26:31 +00:00
|
|
|
for client in clients:
|
2024-05-30 16:12:28 +00:00
|
|
|
let nodeInfo = await client.portal_historyNodeInfo()
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2022-02-02 21:48:33 +00:00
|
|
|
nodeInfos.add(nodeInfo)
|
2021-12-08 08:26:31 +00:00
|
|
|
|
|
|
|
for client in clients:
|
2024-05-30 16:12:28 +00:00
|
|
|
discard await client.portal_historyAddEnrs(
|
2024-02-28 17:31:45 +00:00
|
|
|
nodeInfos.map(
|
|
|
|
proc(x: NodeInfo): Record =
|
|
|
|
x.enr
|
|
|
|
)
|
|
|
|
)
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2021-12-08 08:26:31 +00:00
|
|
|
|
2022-02-02 21:48:33 +00:00
|
|
|
for client in clients:
|
2024-05-30 16:12:28 +00:00
|
|
|
let routingTableInfo = await client.portal_historyRoutingTableInfo()
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2021-12-03 08:51:25 +00:00
|
|
|
var start: seq[NodeId]
|
|
|
|
let nodes = foldl(routingTableInfo.buckets, a & b, start)
|
2022-02-02 21:48:33 +00:00
|
|
|
check nodes.len >= (min(config.nodeCount - 1, 16))
|
2021-12-08 08:26:31 +00:00
|
|
|
|
2022-02-02 21:48:33 +00:00
|
|
|
# grab a random node its `NodeInfo` and lookup that node from all nodes.
|
|
|
|
let randomNodeInfo = sample(rng[], nodeInfos)
|
2021-12-08 08:26:31 +00:00
|
|
|
for client in clients:
|
|
|
|
var enr: Record
|
2024-05-30 16:12:28 +00:00
|
|
|
enr = await client.portal_historyLookupEnr(randomNodeInfo.nodeId)
|
2022-02-16 21:06:43 +00:00
|
|
|
await client.close()
|
2022-12-13 18:22:36 +00:00
|
|
|
check enr == randomNodeInfo.enr
|
2022-02-11 13:43:10 +00:00
|
|
|
|
|
|
|
asyncTest "Portal History - Propagate blocks and do content lookups":
|
2022-11-04 08:27:01 +00:00
|
|
|
const
|
2024-02-28 17:31:45 +00:00
|
|
|
headerFile =
|
|
|
|
"./vendor/portal-spec-tests/tests/mainnet/history/headers/1000001-1000010.e2s"
|
|
|
|
accumulatorFile =
|
2024-07-23 09:59:59 +00:00
|
|
|
"./vendor/portal-spec-tests/tests/mainnet/history/accumulator/epoch-record-00122.ssz"
|
2022-11-04 08:27:01 +00:00
|
|
|
blockDataFile = "./fluffy/tests/blocks/mainnet_blocks_1000001_1000010.json"
|
|
|
|
|
|
|
|
let
|
|
|
|
blockHeaders = readBlockHeaders(headerFile).valueOr:
|
|
|
|
raiseAssert "Invalid header file: " & headerFile
|
2024-07-11 15:42:45 +00:00
|
|
|
epochRecord = readEpochRecordCached(accumulatorFile).valueOr:
|
2022-11-04 08:27:01 +00:00
|
|
|
raiseAssert "Invalid epoch accumulator file: " & accumulatorFile
|
2024-07-11 15:42:45 +00:00
|
|
|
blockHeadersWithProof = buildHeadersWithProof(blockHeaders, epochRecord).valueOr:
|
2024-02-28 17:31:45 +00:00
|
|
|
raiseAssert "Could not build headers with proof"
|
|
|
|
blockData = readJsonType(blockDataFile, BlockDataTable).valueOr:
|
|
|
|
raiseAssert "Invalid block data file" & blockDataFile
|
2022-11-04 08:27:01 +00:00
|
|
|
|
|
|
|
clients = await connectToRpcServers(config)
|
|
|
|
|
|
|
|
# Gossiping all block headers with proof first, as bodies and receipts
|
|
|
|
# require them for validation.
|
|
|
|
for (content, contentKey) in blockHeadersWithProof:
|
2024-02-28 17:31:45 +00:00
|
|
|
discard
|
2024-05-30 16:12:28 +00:00
|
|
|
(await clients[0].portal_historyGossip(content.toHex(), contentKey.toHex()))
|
2022-09-29 06:42:54 +00:00
|
|
|
|
2024-10-10 14:42:57 +00:00
|
|
|
# Gossiping all block bodies and receipts.
|
|
|
|
for b in blocks(blockData, false):
|
|
|
|
for i, value in b:
|
|
|
|
if i == 0:
|
|
|
|
# Note: Skipping the headers, they are handled above already
|
|
|
|
continue
|
|
|
|
# Only sending non empty data, e.g. empty receipts are not send
|
|
|
|
# TODO: Could do a similar thing for a combination of empty
|
|
|
|
# txs and empty uncles, as then the serialization is always the same.
|
|
|
|
if value[1].len() > 0:
|
|
|
|
let
|
|
|
|
contentKey = history_content.encode(value[0]).asSeq().toHex()
|
|
|
|
contentValue = value[1].toHex()
|
|
|
|
|
|
|
|
discard (await clients[0].portal_historyGossip(contentKey, contentValue))
|
|
|
|
|
2022-02-16 21:06:43 +00:00
|
|
|
await clients[0].close()
|
2022-02-11 13:43:10 +00:00
|
|
|
|
2022-07-29 12:24:07 +00:00
|
|
|
for i, client in clients:
|
2022-02-11 13:43:10 +00:00
|
|
|
# Note: Once there is the Canonical Indices Network, we don't need to
|
|
|
|
# access this file anymore here for the block hashes.
|
2022-11-04 08:27:01 +00:00
|
|
|
for hash in blockData.blockHashes():
|
2022-07-20 10:46:42 +00:00
|
|
|
# Note: More flexible approach instead of generic retries could be to
|
|
|
|
# add a json-rpc debug proc that returns whether the offer queue is empty or
|
|
|
|
# not. And then poll every node until all nodes have an empty queue.
|
2022-07-29 12:24:07 +00:00
|
|
|
let content = await retryUntil(
|
2024-03-13 15:58:50 +00:00
|
|
|
proc(): Future[Opt[BlockObject]] {.async.} =
|
2022-07-20 10:46:42 +00:00
|
|
|
try:
|
2024-10-07 18:54:48 +00:00
|
|
|
let res = await client.eth_getBlockByHash(hash, true)
|
2022-07-20 10:46:42 +00:00
|
|
|
await client.close()
|
|
|
|
return res
|
|
|
|
except CatchableError as exc:
|
|
|
|
await client.close()
|
2024-07-22 12:22:45 +00:00
|
|
|
raise exc,
|
2024-03-13 15:58:50 +00:00
|
|
|
proc(mc: Opt[BlockObject]): bool =
|
2024-07-22 12:22:45 +00:00
|
|
|
return mc.isSome(),
|
2022-07-29 12:24:07 +00:00
|
|
|
"Did not receive expected Block with hash " & hash.data.toHex(),
|
2024-02-28 17:31:45 +00:00
|
|
|
i,
|
2022-07-20 10:46:42 +00:00
|
|
|
)
|
2022-02-11 13:43:10 +00:00
|
|
|
check content.isSome()
|
2022-02-22 10:52:44 +00:00
|
|
|
let blockObj = content.get()
|
2024-10-07 18:54:48 +00:00
|
|
|
check blockObj.hash == hash
|
2022-02-22 10:52:44 +00:00
|
|
|
|
|
|
|
for tx in blockObj.transactions:
|
2023-12-08 09:35:50 +00:00
|
|
|
doAssert(tx.kind == tohTx)
|
2024-10-07 18:54:48 +00:00
|
|
|
check tx.tx.blockHash.get == hash
|
2022-02-22 10:52:44 +00:00
|
|
|
|
2024-10-07 18:54:48 +00:00
|
|
|
let filterOptions = FilterOptions(blockHash: Opt.some(hash))
|
2022-06-29 15:44:08 +00:00
|
|
|
|
2022-07-29 12:24:07 +00:00
|
|
|
let logs = await retryUntil(
|
2024-03-13 15:58:50 +00:00
|
|
|
proc(): Future[seq[LogObject]] {.async.} =
|
2022-07-20 10:46:42 +00:00
|
|
|
try:
|
|
|
|
let res = await client.eth_getLogs(filterOptions)
|
|
|
|
await client.close()
|
|
|
|
return res
|
|
|
|
except CatchableError as exc:
|
|
|
|
await client.close()
|
2024-07-22 12:22:45 +00:00
|
|
|
raise exc,
|
2024-03-13 15:58:50 +00:00
|
|
|
proc(mc: seq[LogObject]): bool =
|
2024-07-22 12:22:45 +00:00
|
|
|
return true,
|
2022-07-29 12:24:07 +00:00
|
|
|
"",
|
2024-02-28 17:31:45 +00:00
|
|
|
i,
|
2022-07-20 10:46:42 +00:00
|
|
|
)
|
2022-06-29 15:44:08 +00:00
|
|
|
|
|
|
|
for l in logs:
|
|
|
|
check:
|
2024-10-07 18:54:48 +00:00
|
|
|
l.blockHash == Opt.some(hash)
|
2022-06-29 15:44:08 +00:00
|
|
|
|
2022-02-22 10:52:44 +00:00
|
|
|
# TODO: Check ommersHash, need the headers and not just the hashes
|
|
|
|
# for uncle in blockObj.uncles:
|
|
|
|
# discard
|
2022-02-16 21:06:43 +00:00
|
|
|
|
|
|
|
await client.close()
|