From af35395ace568ab2bd8bd1b6b18e8945facc3799 Mon Sep 17 00:00:00 2001 From: Eric <5089238+emizzle@users.noreply.github.com> Date: Tue, 27 May 2025 18:03:19 +1000 Subject: [PATCH] Convert retryable RPC/HTTP errors to RpcNetworkError type in ethers Converts specific errors to RpcNetworkError, which can be bubbled to applications at a higher level and retried on the network (eg with exponential backoff) until resolved or timed out. --- ethers/contracts/syntax.nim | 2 +- ethers/contracts/transactions.nim | 15 ++- ethers/errors.nim | 8 ++ ethers/provider.nim | 38 +++--- ethers/providers/jsonrpc.nim | 73 +++++------ ethers/providers/jsonrpc/errors.nim | 44 ++++++- ethers/providers/jsonrpc/subscriptions.nim | 2 +- ethers/signer.nim | 47 +++---- ethers/signers/wallet.nim | 4 +- ethers/signers/wallet/error.nim | 7 -- testmodule/mocks.nim | 4 +- .../jsonrpc/mocks/mockHttpServer.nim | 80 ++++++++++++ .../jsonrpc/mocks/mockRpcHttpServer.nim | 31 +++++ .../mocks/mockRpcHttpServerSubscriptions.nim | 53 ++++++++ testmodule/providers/jsonrpc/rpc_mock.nim | 56 --------- testmodule/providers/jsonrpc/testErrors.nim | 115 +++++++++++++++++- .../jsonrpc/testJsonRpcSubscriptions.nim | 10 +- 17 files changed, 417 insertions(+), 172 deletions(-) create mode 100644 testmodule/providers/jsonrpc/mocks/mockHttpServer.nim create mode 100644 testmodule/providers/jsonrpc/mocks/mockRpcHttpServer.nim create mode 100644 testmodule/providers/jsonrpc/mocks/mockRpcHttpServerSubscriptions.nim delete mode 100644 testmodule/providers/jsonrpc/rpc_mock.nim diff --git a/ethers/contracts/syntax.nim b/ethers/contracts/syntax.nim index 1af1dd2..816dd57 100644 --- a/ethers/contracts/syntax.nim +++ b/ethers/contracts/syntax.nim @@ -69,7 +69,7 @@ func addOverridesParameter*(procedure: var NimNode) = func addAsyncPragma*(procedure: var NimNode) = procedure.addPragma nnkExprColonExpr.newTree( quote do: async, - quote do: (raises: [CancelledError, ProviderError, EthersError]) + quote do: (raises: [CancelledError, ProviderError, EthersError, RpcNetworkError]) ) func addUsedPragma*(procedure: var NimNode) = diff --git a/ethers/contracts/transactions.nim b/ethers/contracts/transactions.nim index c538973..af1c984 100644 --- a/ethers/contracts/transactions.nim +++ b/ethers/contracts/transactions.nim @@ -34,13 +34,15 @@ proc decodeResponse(T: type, bytes: seq[byte]): T {.raises: [ContractError].} = proc call( provider: Provider, transaction: Transaction, overrides: TransactionOverrides -): Future[seq[byte]] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[seq[byte]] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = if overrides of CallOverrides and blockTag =? CallOverrides(overrides).blockTag: await provider.call(transaction, blockTag) else: await provider.call(transaction) -proc callTransaction*(call: ContractCall) {.async: (raises: [ProviderError, SignerError, CancelledError]).} = +proc callTransaction*( + call: ContractCall +) {.async: (raises: [ProviderError, SignerError, CancelledError, RpcNetworkError]).} = var transaction = createTransaction(call) if signer =? call.contract.signer and transaction.sender.isNone: @@ -48,7 +50,10 @@ proc callTransaction*(call: ContractCall) {.async: (raises: [ProviderError, Sign discard await call.contract.provider.call(transaction, call.overrides) -proc callTransaction*(call: ContractCall, ReturnType: type): Future[ReturnType] {.async: (raises: [ProviderError, SignerError, ContractError, CancelledError]).} = +proc callTransaction*( + call: ContractCall, + ReturnType: type +): Future[ReturnType] {.async: (raises: [ProviderError, SignerError, ContractError, CancelledError, RpcNetworkError]).} = var transaction = createTransaction(call) if signer =? call.contract.signer and transaction.sender.isNone: @@ -57,7 +62,9 @@ proc callTransaction*(call: ContractCall, ReturnType: type): Future[ReturnType] let response = await call.contract.provider.call(transaction, call.overrides) return decodeResponse(ReturnType, response) -proc sendTransaction*(call: ContractCall): Future[?TransactionResponse] {.async: (raises: [SignerError, ProviderError, CancelledError]).} = +proc sendTransaction*( + call: ContractCall +): Future[?TransactionResponse] {.async: (raises: [SignerError, ProviderError, CancelledError, RpcNetworkError]).} = if signer =? call.contract.signer: withLock(signer): let transaction = createTransaction(call) diff --git a/ethers/errors.nim b/ethers/errors.nim index 5fece97..962f8da 100644 --- a/ethers/errors.nim +++ b/ethers/errors.nim @@ -7,6 +7,10 @@ type SubscriptionError* = object of EthersError ProviderError* = object of EthersError data*: ?seq[byte] + RpcNetworkError* = object of EthersError + RpcHttpErrorResponse* = object of RpcNetworkError + RequestLimitError* = object of RpcHttpErrorResponse + RequestTimeoutError* = object of RpcHttpErrorResponse {.push raises:[].} @@ -16,3 +20,7 @@ proc toErr*[E1: ref CatchableError, E2: EthersError]( msg: string = e1.msg): ref E2 = return newException(E2, msg, e1) + +proc raiseNetworkError*( + error: ref CatchableError) {.raises: [RpcNetworkError].} = + raise newException(RpcNetworkError, error.msg, error) diff --git a/ethers/provider.nim b/ethers/provider.nim index 5a2153e..4516de9 100644 --- a/ethers/provider.nim +++ b/ethers/provider.nim @@ -103,84 +103,86 @@ func toTransaction*(past: PastTransaction): Transaction = method getBlockNumber*( provider: Provider -): Future[UInt256] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[UInt256] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method getBlock*( provider: Provider, tag: BlockTag -): Future[?Block] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[?Block] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method call*( provider: Provider, tx: Transaction, blockTag = BlockTag.latest -): Future[seq[byte]] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[seq[byte]] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method getGasPrice*( provider: Provider -): Future[UInt256] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[UInt256] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method getTransactionCount*( provider: Provider, address: Address, blockTag = BlockTag.latest -): Future[UInt256] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[UInt256] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method getTransaction*( provider: Provider, txHash: TransactionHash -): Future[?PastTransaction] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[?PastTransaction] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method getTransactionReceipt*( provider: Provider, txHash: TransactionHash ): Future[?TransactionReceipt] {. - base, async: (raises: [ProviderError, CancelledError]) + base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]) .} = doAssert false, "not implemented" method sendTransaction*( provider: Provider, rawTransaction: seq[byte] ): Future[TransactionResponse] {. - base, async: (raises: [ProviderError, CancelledError]) + base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]) .} = doAssert false, "not implemented" method getLogs*( provider: Provider, filter: EventFilter -): Future[seq[Log]] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[seq[Log]] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method estimateGas*( provider: Provider, transaction: Transaction, blockTag = BlockTag.latest -): Future[UInt256] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[UInt256] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method getChainId*( provider: Provider -): Future[UInt256] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[UInt256] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method subscribe*( provider: Provider, filter: EventFilter, callback: LogHandler -): Future[Subscription] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[Subscription] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method subscribe*( provider: Provider, callback: BlockHandler -): Future[Subscription] {.base, async: (raises: [ProviderError, CancelledError]).} = +): Future[Subscription] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method unsubscribe*( subscription: Subscription -) {.base, async: (raises: [ProviderError, CancelledError]).} = +) {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" -method isSyncing*(provider: Provider): Future[bool] {.base, async: (raises: [ProviderError, CancelledError]).} = +method isSyncing*( + provider: Provider +): Future[bool] {.base, async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" proc replay*( provider: Provider, tx: Transaction, blockNumber: UInt256 -) {.async: (raises: [ProviderError, CancelledError]).} = +) {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = # Replay transaction at block. Useful for fetching revert reasons, which will # be present in the raised error message. The replayed block number should # include the state of the chain in the block previous to the block in which @@ -193,7 +195,7 @@ proc replay*( proc ensureSuccess( provider: Provider, receipt: TransactionReceipt -) {.async: (raises: [ProviderError, CancelledError]).} = +) {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = ## If the receipt.status is Failed, the tx is replayed to obtain a revert ## reason, after which a ProviderError with the revert reason is raised. ## If no revert reason was obtained @@ -241,7 +243,7 @@ proc confirm*( if number > blockNumber: blockNumber = number blockEvent.fire() - except ProviderError, CancelledError: + except ProviderError, CancelledError, RpcNetworkError: # there's nothing we can do here discard diff --git a/ethers/providers/jsonrpc.nim b/ethers/providers/jsonrpc.nim index 636193c..cfd55a9 100644 --- a/ethers/providers/jsonrpc.nim +++ b/ethers/providers/jsonrpc.nim @@ -56,7 +56,7 @@ proc new*( var client: RpcClient var subscriptions: JsonRpcSubscriptions - proc initialize() {.async: (raises: [JsonRpcProviderError, CancelledError]).} = + proc initialize() {.async: (raises: [JsonRpcProviderError, CancelledError, RpcNetworkError]).} = convertError: case parseUri(url).scheme of "ws", "wss": @@ -65,7 +65,7 @@ proc new*( client = websocket subscriptions = JsonRpcSubscriptions.new(websocket) else: - let http = newRpcHttpClient(getHeaders = jsonHeaders) + let http = newRpcHttpClient() await http.connect(url) client = http subscriptions = JsonRpcSubscriptions.new(http, @@ -73,14 +73,14 @@ proc new*( subscriptions.start() proc awaitClient(): Future[RpcClient] {. - async: (raises: [JsonRpcProviderError, CancelledError]) + async: (raises: [JsonRpcProviderError, CancelledError, RpcNetworkError]) .} = convertError: await initialized return client proc awaitSubscriptions(): Future[JsonRpcSubscriptions] {. - async: (raises: [JsonRpcProviderError, CancelledError]) + async: (raises: [JsonRpcProviderError, CancelledError, RpcNetworkError]) .} = convertError: await initialized @@ -91,28 +91,30 @@ proc new*( proc callImpl( client: RpcClient, call: string, args: JsonNode -): Future[JsonNode] {.async: (raises: [JsonRpcProviderError, CancelledError]).} = +): Future[JsonNode] {.async: (raises: [JsonRpcProviderError, CancelledError, JsonRpcError]).} = try: let response = await client.call(call, %args) without json =? JsonNode.fromJson(response.string), error: - raiseJsonRpcProviderError "Failed to parse response '" & response.string & "': " & + raiseJsonRpcProviderError error, "Failed to parse response '" & response.string & "': " & error.msg return json except CancelledError as error: raise error + except JsonRpcError as error: + raise error except CatchableError as error: - raiseJsonRpcProviderError error.msg + raiseJsonRpcProviderError error proc send*( provider: JsonRpcProvider, call: string, arguments: seq[JsonNode] = @[] -): Future[JsonNode] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[JsonNode] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client return await client.callImpl(call, %arguments) proc listAccounts*( provider: JsonRpcProvider -): Future[seq[Address]] {.async: (raises: [JsonRpcProviderError, CancelledError]).} = +): Future[seq[Address]] {.async: (raises: [JsonRpcProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client return await client.eth_accounts() @@ -125,56 +127,56 @@ proc getSigner*(provider: JsonRpcProvider, address: Address): JsonRpcSigner = method getBlockNumber*( provider: JsonRpcProvider -): Future[UInt256] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[UInt256] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client return await client.eth_blockNumber() method getBlock*( provider: JsonRpcProvider, tag: BlockTag -): Future[?Block] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[?Block] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client return await client.eth_getBlockByNumber(tag, false) method call*( provider: JsonRpcProvider, tx: Transaction, blockTag = BlockTag.latest -): Future[seq[byte]] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[seq[byte]] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client return await client.eth_call(tx, blockTag) method getGasPrice*( provider: JsonRpcProvider -): Future[UInt256] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[UInt256] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client return await client.eth_gasPrice() method getTransactionCount*( provider: JsonRpcProvider, address: Address, blockTag = BlockTag.latest -): Future[UInt256] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[UInt256] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client return await client.eth_getTransactionCount(address, blockTag) method getTransaction*( provider: JsonRpcProvider, txHash: TransactionHash -): Future[?PastTransaction] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[?PastTransaction] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client return await client.eth_getTransactionByHash(txHash) method getTransactionReceipt*( provider: JsonRpcProvider, txHash: TransactionHash -): Future[?TransactionReceipt] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[?TransactionReceipt] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client return await client.eth_getTransactionReceipt(txHash) method getLogs*( provider: JsonRpcProvider, filter: EventFilter -): Future[seq[Log]] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[seq[Log]] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client let logsJson = @@ -196,7 +198,7 @@ method estimateGas*( provider: JsonRpcProvider, transaction: Transaction, blockTag = BlockTag.latest, -): Future[UInt256] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[UInt256] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = try: convertError: let client = await provider.client @@ -211,7 +213,7 @@ method estimateGas*( method getChainId*( provider: JsonRpcProvider -): Future[UInt256] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[UInt256] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client try: @@ -223,7 +225,7 @@ method getChainId*( method sendTransaction*( provider: JsonRpcProvider, rawTransaction: seq[byte] -): Future[TransactionResponse] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[TransactionResponse] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client @@ -233,7 +235,7 @@ method sendTransaction*( method subscribe*( provider: JsonRpcProvider, filter: EventFilter, onLog: LogHandler -): Future[Subscription] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[Subscription] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let subscriptions = await provider.subscriptions let id = await subscriptions.subscribeLogs(filter, onLog) @@ -241,7 +243,7 @@ method subscribe*( method subscribe*( provider: JsonRpcProvider, onBlock: BlockHandler -): Future[Subscription] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[Subscription] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let subscriptions = await provider.subscriptions let id = await subscriptions.subscribeBlocks(onBlock) @@ -249,7 +251,7 @@ method subscribe*( method unsubscribe*( subscription: JsonRpcSubscription -) {.async: (raises: [ProviderError, CancelledError]).} = +) {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let subscriptions = subscription.subscriptions let id = subscription.id @@ -257,7 +259,7 @@ method unsubscribe*( method isSyncing*( provider: JsonRpcProvider -): Future[bool] {.async: (raises: [ProviderError, CancelledError]).} = +): Future[bool] {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = let response = await provider.send("eth_syncing") if response.kind == JsonNodeKind.JObject: return true @@ -265,7 +267,7 @@ method isSyncing*( method close*( provider: JsonRpcProvider -) {.async: (raises: [ProviderError, CancelledError]).} = +) {.async: (raises: [ProviderError, CancelledError, RpcNetworkError]).} = convertError: let client = await provider.client let subscriptions = await provider.subscriptions @@ -274,24 +276,13 @@ method close*( # Signer -proc raiseJsonRpcSignerError( - message: string) {.raises: [JsonRpcSignerError].} = - - var message = message - if json =? JsonNode.fromJson(message): - if "message" in json: - message = json{"message"}.getStr - raise newException(JsonRpcSignerError, message) - template convertSignerError(body) = try: body except CancelledError as error: raise error - except JsonRpcError as error: - raiseJsonRpcSignerError(error.msg) except CatchableError as error: - raise newException(JsonRpcSignerError, error.msg) + raise newException(JsonRpcSignerError, error.msg, error) method provider*(signer: JsonRpcSigner): Provider {.gcsafe, raises: [SignerError].} = @@ -300,7 +291,7 @@ method provider*(signer: JsonRpcSigner): Provider method getAddress*( signer: JsonRpcSigner -): Future[Address] {.async: (raises: [ProviderError, SignerError, CancelledError]).} = +): Future[Address] {.async: (raises: [ProviderError, SignerError, CancelledError, RpcNetworkError]).} = if address =? signer.address: return address @@ -308,11 +299,11 @@ method getAddress*( if accounts.len > 0: return accounts[0] - raiseJsonRpcSignerError "no address found" + raise newException(SignerError, "no address found") method signMessage*( signer: JsonRpcSigner, message: seq[byte] -): Future[seq[byte]] {.async: (raises: [SignerError, CancelledError]).} = +): Future[seq[byte]] {.async: (raises: [SignerError, CancelledError, RpcNetworkError]).} = convertSignerError: let client = await signer.provider.client let address = await signer.getAddress() @@ -321,7 +312,7 @@ method signMessage*( method sendTransaction*( signer: JsonRpcSigner, transaction: Transaction ): Future[TransactionResponse] {. - async: (raises: [SignerError, ProviderError, CancelledError]) + async: (raises: [SignerError, ProviderError, CancelledError, RpcNetworkError]) .} = convertError: let diff --git a/ethers/providers/jsonrpc/errors.nim b/ethers/providers/jsonrpc/errors.nim index dfa550e..b0c7fcd 100644 --- a/ethers/providers/jsonrpc/errors.nim +++ b/ethers/providers/jsonrpc/errors.nim @@ -31,19 +31,53 @@ func new*(_: type JsonRpcProviderError, json: JsonNode): ref JsonRpcProviderErro error proc raiseJsonRpcProviderError*( - message: string) {.raises: [JsonRpcProviderError].} = - if json =? JsonNode.fromJson(message): + error: ref CatchableError, message = error.msg) {.raises: [JsonRpcProviderError].} = + if json =? JsonNode.fromJson(error.msg): raise JsonRpcProviderError.new(json) else: raise newException(JsonRpcProviderError, message) +proc underlyingErrorOf(e: ref Exception, T: type CatchableError): (ref CatchableError) = + if e of (ref T): + return (ref T)(e) + elif not e.parent.isNil: + return e.parent.underlyingErrorOf T + else: + return nil + template convertError*(body) = try: - body + try: + body + # Inspect SubscriptionErrors and re-raise underlying JsonRpcErrors so that + # exception inspection and resolution only needs to happen once. All + # CatchableErrors that occur in the Subscription module are converted to + # SubscriptionError, with the original error preserved as the exception's + # parent. + except SubscriptionError, SignerError: + let e = getCurrentException() + let parent = e.underlyingErrorOf(JsonRpcError) + if not parent.isNil: + raise parent except CancelledError as error: raise error + except RpcPostError as error: + raiseNetworkError(error) + except FailedHttpResponse as error: + raiseNetworkError(error) + except ErrorResponse as error: + if error.status == 429: + raise newException(RequestLimitError, error.msg, error) + elif error.status == 408: + raise newException(RequestTimeoutError, error.msg, error) + else: + raiseJsonRpcProviderError(error) except JsonRpcError as error: - raiseJsonRpcProviderError(error.msg) + var message = error.msg + if jsn =? JsonNode.fromJson(message): + if "message" in jsn: + message = jsn{"message"}.getStr + raiseJsonRpcProviderError(error, message) except CatchableError as error: - raiseJsonRpcProviderError(error.msg) + raiseJsonRpcProviderError(error) diff --git a/ethers/providers/jsonrpc/subscriptions.nim b/ethers/providers/jsonrpc/subscriptions.nim index 95c094e..39a0ba9 100644 --- a/ethers/providers/jsonrpc/subscriptions.nim +++ b/ethers/providers/jsonrpc/subscriptions.nim @@ -122,7 +122,7 @@ template withLock*(subscriptions: WebSocketSubscriptions, body: untyped) = # This is a workaround to manage the 5 minutes limit due to hardhat. # See https://github.com/NomicFoundation/hardhat/issues/2053#issuecomment-1061374064 -proc resubscribeWebsocketEventsOnTimeout*(subscriptions: WebsocketSubscriptions) {.async: (raises: [CancelledError]).} = +proc resubscribeWebsocketEventsOnTimeout*(subscriptions: WebSocketSubscriptions) {.async: (raises: [CancelledError]).} = while true: await sleepAsync(subscriptions.resubscribeInterval.seconds) try: diff --git a/ethers/signer.nim b/ethers/signer.nim index a6aa82f..27eafa3 100644 --- a/ethers/signer.nim +++ b/ethers/signer.nim @@ -1,4 +1,5 @@ import pkg/questionable +import pkg/json_rpc/errors import ./basics import ./errors import ./provider @@ -15,16 +16,6 @@ type template raiseSignerError*(message: string, parent: ref CatchableError = nil) = raise newException(SignerError, message, parent) -template convertError(body) = - try: - body - except CancelledError as error: - raise error - except ProviderError as error: - raise error # do not convert provider errors - except CatchableError as error: - raiseSignerError(error.msg) - method provider*( signer: Signer): Provider {.base, gcsafe, raises: [SignerError].} = doAssert false, "not implemented" @@ -32,42 +23,41 @@ method provider*( method getAddress*( signer: Signer ): Future[Address] {. - base, async: (raises: [ProviderError, SignerError, CancelledError]) + base, async: (raises: [ProviderError, SignerError, CancelledError, RpcNetworkError]) .} = doAssert false, "not implemented" method signMessage*( signer: Signer, message: seq[byte] -): Future[seq[byte]] {.base, async: (raises: [SignerError, CancelledError]).} = +): Future[seq[byte]] {.base, async: (raises: [SignerError, CancelledError, RpcNetworkError]).} = doAssert false, "not implemented" method sendTransaction*( signer: Signer, transaction: Transaction ): Future[TransactionResponse] {. - base, async: (raises: [SignerError, ProviderError, CancelledError]) + base, async: (raises: [SignerError, ProviderError, CancelledError, RpcNetworkError]) .} = doAssert false, "not implemented" method getGasPrice*( signer: Signer ): Future[UInt256] {. - base, async: (raises: [ProviderError, SignerError, CancelledError]) + base, async: (raises: [ProviderError, SignerError, CancelledError, RpcNetworkError]) .} = return await signer.provider.getGasPrice() method getTransactionCount*( signer: Signer, blockTag = BlockTag.latest ): Future[UInt256] {. - base, async: (raises: [SignerError, ProviderError, CancelledError]) + base, async: (raises: [SignerError, ProviderError, CancelledError, RpcNetworkError]) .} = - convertError: - let address = await signer.getAddress() - return await signer.provider.getTransactionCount(address, blockTag) + let address = await signer.getAddress() + return await signer.provider.getTransactionCount(address, blockTag) method estimateGas*( signer: Signer, transaction: Transaction, blockTag = BlockTag.latest ): Future[UInt256] {. - base, async: (raises: [SignerError, ProviderError, CancelledError]) + base, async: (raises: [SignerError, ProviderError, CancelledError, RpcNetworkError]) .} = var transaction = transaction transaction.sender = some(await signer.getAddress()) @@ -76,14 +66,14 @@ method estimateGas*( method getChainId*( signer: Signer ): Future[UInt256] {. - base, async: (raises: [SignerError, ProviderError, CancelledError]) + base, async: (raises: [SignerError, ProviderError, CancelledError, RpcNetworkError]) .} = return await signer.provider.getChainId() method getNonce( signer: Signer ): Future[UInt256] {. - base, async: (raises: [SignerError, ProviderError, CancelledError]) + base, async: (raises: [SignerError, ProviderError, CancelledError, RpcNetworkError]) .} = return await signer.getTransactionCount(BlockTag.pending) @@ -103,15 +93,14 @@ template withLock*(signer: Signer, body: untyped) = method populateTransaction*( signer: Signer, transaction: Transaction): Future[Transaction] - {.base, async: (raises: [CancelledError, ProviderError, SignerError]).} = + {.base, async: (raises: [CancelledError, ProviderError, SignerError, RpcNetworkError]).} = ## Populates a transaction with sender, chainId, gasPrice, nonce, and gasLimit. ## NOTE: to avoid async concurrency issues, this routine should be called with ## a lock if it is followed by sendTransaction. For reference, see the `send` ## function in contract.nim. var address: Address - convertError: - address = await signer.getAddress() + address = await signer.getAddress() if sender =? transaction.sender and sender != address: raiseSignerError("from address mismatch") @@ -154,7 +143,7 @@ method populateTransaction*( method cancelTransaction*( signer: Signer, tx: Transaction -): Future[TransactionResponse] {.base, async: (raises: [SignerError, CancelledError, ProviderError]).} = +): Future[TransactionResponse] {.base, async: (raises: [SignerError, CancelledError, ProviderError, RpcNetworkError]).} = # cancels a transaction by sending with a 0-valued transaction to ourselves # with the failed tx's nonce @@ -164,7 +153,7 @@ method cancelTransaction*( raiseSignerError "transaction must have nonce" withLock(signer): - convertError: - var cancelTx = Transaction(to: sender, value: 0.u256, nonce: some nonce) - cancelTx = await signer.populateTransaction(cancelTx) - return await signer.sendTransaction(cancelTx) + # convertError: + var cancelTx = Transaction(to: sender, value: 0.u256, nonce: some nonce) + cancelTx = await signer.populateTransaction(cancelTx) + return await signer.sendTransaction(cancelTx) diff --git a/ethers/signers/wallet.nim b/ethers/signers/wallet.nim index 95cca30..f56dae4 100644 --- a/ethers/signers/wallet.nim +++ b/ethers/signers/wallet.nim @@ -69,7 +69,7 @@ method provider*(wallet: Wallet): Provider {.gcsafe, raises: [SignerError].} = method getAddress*( wallet: Wallet): Future[Address] - {.async: (raises:[ProviderError, SignerError, CancelledError]).} = + {.async: (raises:[ProviderError, SignerError, CancelledError, RpcNetworkError]).} = return wallet.address @@ -83,7 +83,7 @@ proc signTransaction*(wallet: Wallet, method sendTransaction*( wallet: Wallet, transaction: Transaction): Future[TransactionResponse] - {.async: (raises:[SignerError, ProviderError, CancelledError]).} = + {.async: (raises:[SignerError, ProviderError, CancelledError, RpcNetworkError]).} = let signed = await signTransaction(wallet, transaction) return await provider(wallet).sendTransaction(signed) diff --git a/ethers/signers/wallet/error.nim b/ethers/signers/wallet/error.nim index 00381be..bee51b0 100644 --- a/ethers/signers/wallet/error.nim +++ b/ethers/signers/wallet/error.nim @@ -6,10 +6,3 @@ type func raiseWalletError*(message: string) {.raises: [WalletError].}= raise newException(WalletError, message) -template convertError*(body) = - try: - body - except CancelledError as error: - raise error - except CatchableError as error: - raiseWalletError(error.msg) diff --git a/testmodule/mocks.nim b/testmodule/mocks.nim index 0be9c34..a387862 100644 --- a/testmodule/mocks.nim +++ b/testmodule/mocks.nim @@ -13,13 +13,13 @@ method provider*(signer: MockSigner): Provider = method getAddress*( signer: MockSigner): Future[Address] - {.async: (raises:[ProviderError, SignerError, CancelledError]).} = + {.async: (raises:[ProviderError, SignerError, CancelledError, RpcNetworkError]).} = return signer.address method sendTransaction*( signer: MockSigner, transaction: Transaction): Future[TransactionResponse] - {.async: (raises:[SignerError, ProviderError, CancelledError]).} = + {.async: (raises:[SignerError, ProviderError, CancelledError, RpcNetworkError]).} = signer.transactions.add(transaction) diff --git a/testmodule/providers/jsonrpc/mocks/mockHttpServer.nim b/testmodule/providers/jsonrpc/mocks/mockHttpServer.nim new file mode 100644 index 0000000..d02cad8 --- /dev/null +++ b/testmodule/providers/jsonrpc/mocks/mockHttpServer.nim @@ -0,0 +1,80 @@ +import std/tables +import pkg/serde +import pkg/chronos/apps/http/httpclient +import pkg/chronos/apps/http/httpserver +import pkg/stew/byteutils +import pkg/questionable + +export httpserver + +{.push raises: [].} + +type + RpcResponse* = proc(request: HttpRequestRef): Future[HttpResponseRef] {.async: (raises: [CancelledError]), raises: [].} + + MockHttpServer* = object + server: HttpServerRef + rpcResponses: ref Table[string, RpcResponse] + + RequestRx {.deserialize.} = object + jsonrpc*: string + id* : int + `method`* : string + + +proc init*(_: type MockHttpServer, address: TransportAddress): MockHttpServer = + var server: MockHttpServer + + proc processRequest(r: RequestFence): Future[HttpResponseRef] {.async: (raises: [CancelledError]).} = + if r.isErr: + return defaultResponse() + + let request = r.get() + try: + let body = string.fromBytes(await request.getBody()) + echo "mockHttpServer.processRequest request: ", body + without req =? RequestRx.fromJson(body), error: + echo "failed to deserialize, error: ", error.msg + return await request.respond(Http400, "Invalid request, must be valid json rpc request") + + echo "Received request with method: ", req.method + if not server.rpcResponses.contains(req.method): + return await request.respond(Http404, "Method not registered") + + try: + let rpcResponseProc = server.rpcResponses[req.method] + return await rpcResponseProc(request) + except KeyError as e: + return await request.respond(Http500, "Method lookup error with key, error: " & e.msg) + + except HttpProtocolError as e: + echo "HttpProtocolError encountered, error: ", e.msg + return defaultResponse(e) + except HttpTransportError as e: + echo "HttpTransportError encountered, error: ", e.msg + return defaultResponse(e) + except HttpWriteError as exc: + return defaultResponse(exc) + + let + socketFlags = {ServerFlags.TcpNoDelay, ServerFlags.ReuseAddr} + serverFlags = {HttpServerFlags.Http11Pipeline} + res = HttpServerRef.new(address, processRequest, + socketFlags = socketFlags, + serverFlags = serverFlags) + server = MockHttpServer(server: res.get(), rpcResponses: newTable[string, RpcResponse]()) + return server + +template registerRpcMethod*(server: MockHttpServer, `method`: string, rpcResponse: untyped): untyped = + server.rpcResponses[`method`] = rpcResponse + +proc address*(server: MockHttpServer): TransportAddress = + server.server.instance.localAddress() + +proc start*(server: MockHttpServer) = + server.server.start() + +proc stop*(server: MockHttpServer) {.async: (raises: []).} = + await server.server.stop() + await server.server.closeWait() + diff --git a/testmodule/providers/jsonrpc/mocks/mockRpcHttpServer.nim b/testmodule/providers/jsonrpc/mocks/mockRpcHttpServer.nim new file mode 100644 index 0000000..3c0258b --- /dev/null +++ b/testmodule/providers/jsonrpc/mocks/mockRpcHttpServer.nim @@ -0,0 +1,31 @@ +import ../../../examples +import ../../../../ethers/provider +import ../../../../ethers/providers/jsonrpc/conversions + +import std/sequtils +import pkg/stew/byteutils +import pkg/json_rpc/rpcserver except `%`, `%*` +import pkg/json_rpc/errors + +{.push raises: [].} + +type MockRpcHttpServer* = ref object of RootObj + srv: RpcHttpServer + +proc new*(_: type MockRpcHttpServer): MockRpcHttpServer {.raises: [JsonRpcError].} = + let srv = newRpcHttpServer(["127.0.0.1:0"]) + MockRpcHttpServer(srv: srv) + + +template registerRpcMethod*(server: MockRpcHttpServer, path: string, body: untyped): untyped = + server.srv.router.rpc(path, body) + +method start*(server: MockRpcHttpServer) {.gcsafe, base.} = + server.srv.start() + +proc stop*(server: MockRpcHttpServer) {.async.} = + await server.srv.stop() + await server.srv.closeWait() + +proc localAddress*(server: MockRpcHttpServer): seq[TransportAddress] = + return server.srv.localAddress() diff --git a/testmodule/providers/jsonrpc/mocks/mockRpcHttpServerSubscriptions.nim b/testmodule/providers/jsonrpc/mocks/mockRpcHttpServerSubscriptions.nim new file mode 100644 index 0000000..3b9edac --- /dev/null +++ b/testmodule/providers/jsonrpc/mocks/mockRpcHttpServerSubscriptions.nim @@ -0,0 +1,53 @@ +import ../../../examples +import ../../../../ethers/provider +import ../../../../ethers/providers/jsonrpc/conversions + +import std/sequtils +import pkg/stew/byteutils +import pkg/json_rpc/rpcserver except `%`, `%*` +import pkg/json_rpc/errors +import ./mockRpcHttpServer + +export mockRpcHttpServer + +{.push raises: [].} + +type MockRpcHttpServerSubscriptions* = ref object of MockRpcHttpServer + filters*: seq[string] + nextGetChangesReturnsError*: bool + +proc new*(_: type MockRpcHttpServerSubscriptions): MockRpcHttpServerSubscriptions {.raises: [JsonRpcError].} = + let srv = newRpcHttpServer(["127.0.0.1:0"]) + MockRpcHttpServerSubscriptions(filters: @[], srv: srv, nextGetChangesReturnsError: false) + +proc invalidateFilter*(server: MockRpcHttpServerSubscriptions, jsonId: JsonNode) = + server.filters.keepItIf it != jsonId.getStr + +method start*(server: MockRpcHttpServerSubscriptions) = + server.registerRpcMethod("eth_newFilter") do(filter: EventFilter) -> string: + let filterId = "0x" & (array[16, byte].example).toHex + server.filters.add filterId + return filterId + + server.registerRpcMethod("eth_newBlockFilter") do() -> string: + let filterId = "0x" & (array[16, byte].example).toHex + server.filters.add filterId + return filterId + + server.registerRpcMethod("eth_getFilterChanges") do(id: string) -> seq[string]: + if server.nextGetChangesReturnsError: + raise (ref ApplicationError)(code: -32000, msg: "unknown error") + + if id notin server.filters: + raise (ref ApplicationError)(code: -32000, msg: "filter not found") + + return @[] + + server.registerRpcMethod("eth_uninstallFilter") do(id: string) -> bool: + if id notin server.filters: + raise (ref ApplicationError)(code: -32000, msg: "filter not found") + + server.invalidateFilter(%id) + return true + + procCall MockRpcHttpServer(server).start() diff --git a/testmodule/providers/jsonrpc/rpc_mock.nim b/testmodule/providers/jsonrpc/rpc_mock.nim deleted file mode 100644 index 4abdb6e..0000000 --- a/testmodule/providers/jsonrpc/rpc_mock.nim +++ /dev/null @@ -1,56 +0,0 @@ -import ../../examples -import ../../../ethers/provider -import ../../../ethers/providers/jsonrpc/conversions - -import std/sequtils -import pkg/stew/byteutils -import pkg/json_rpc/rpcserver except `%`, `%*` -import pkg/json_rpc/errors - -type MockRpcHttpServer* = ref object - filters*: seq[string] - nextGetChangesReturnsError*: bool - srv: RpcHttpServer - -proc new*(_: type MockRpcHttpServer): MockRpcHttpServer = - let srv = newRpcHttpServer(["127.0.0.1:0"]) - MockRpcHttpServer(filters: @[], srv: srv, nextGetChangesReturnsError: false) - -proc invalidateFilter*(server: MockRpcHttpServer, jsonId: JsonNode) = - server.filters.keepItIf it != jsonId.getStr - -proc start*(server: MockRpcHttpServer) = - server.srv.router.rpc("eth_newFilter") do(filter: EventFilter) -> string: - let filterId = "0x" & (array[16, byte].example).toHex - server.filters.add filterId - return filterId - - server.srv.router.rpc("eth_newBlockFilter") do() -> string: - let filterId = "0x" & (array[16, byte].example).toHex - server.filters.add filterId - return filterId - - server.srv.router.rpc("eth_getFilterChanges") do(id: string) -> seq[string]: - if server.nextGetChangesReturnsError: - raise (ref ApplicationError)(code: -32000, msg: "unknown error") - - if id notin server.filters: - raise (ref ApplicationError)(code: -32000, msg: "filter not found") - - return @[] - - server.srv.router.rpc("eth_uninstallFilter") do(id: string) -> bool: - if id notin server.filters: - raise (ref ApplicationError)(code: -32000, msg: "filter not found") - - server.invalidateFilter(%id) - return true - - server.srv.start() - -proc stop*(server: MockRpcHttpServer) {.async.} = - await server.srv.stop() - await server.srv.closeWait() - -proc localAddress*(server: MockRpcHttpServer): seq[TransportAddress] = - return server.srv.localAddress() diff --git a/testmodule/providers/jsonrpc/testErrors.nim b/testmodule/providers/jsonrpc/testErrors.nim index fe8cbed..e15d798 100644 --- a/testmodule/providers/jsonrpc/testErrors.nim +++ b/testmodule/providers/jsonrpc/testErrors.nim @@ -1,7 +1,17 @@ -import std/unittest +import std/sequtils +import std/typetraits + +import stew/byteutils +import pkg/asynctest/chronos/unittest import pkg/serde import pkg/questionable +import pkg/ethers/providers/jsonrpc import pkg/ethers/providers/jsonrpc/errors +import pkg/ethers/erc20 +import pkg/json_rpc/clients/httpclient +import ./mocks/mockHttpServer +import ../../examples +import ../../hardhat suite "JSON RPC errors": @@ -25,3 +35,106 @@ suite "JSON RPC errors": } } check JsonRpcProviderError.new(error).data == some @[0xab'u8, 0xcd'u8] + +type + TestToken = ref object of Erc20Token + +method mint(token: TestToken, holder: Address, amount: UInt256): Confirmable {.base, contract.} + +suite "Network errors": + + var provider: JsonRpcProvider + var mockServer: MockHttpServer + var token: TestToken + + setup: + mockServer = MockHttpServer.init(initTAddress("127.0.0.1:0")) + mockServer.start() + provider = JsonRpcProvider.new("http://" & $mockServer.address) + + let deployment = readDeployment() + token = TestToken.new(!deployment.address(TestToken), provider) + + teardown: + await provider.close() + await mockServer.stop() + + proc registerRpcMethods(response: RpcResponse) = + mockServer.registerRpcMethod("eth_accounts", response) + mockServer.registerRpcMethod("eth_call", response) + mockServer.registerRpcMethod("eth_sendTransaction", response) + mockServer.registerRpcMethod("eth_sendRawTransaction", response) + mockServer.registerRpcMethod("eth_newBlockFilter", response) + mockServer.registerRpcMethod("eth_newFilter", response) + # mockServer.registerRpcMethod("eth_subscribe", response) # TODO: handle + # eth_subscribe for websockets + + proc testCustomResponse(errorName: string, responseHttpCode: HttpCode, responseText: string, errorType: type CatchableError) = + let response = proc(request: HttpRequestRef): Future[HttpResponseRef] {.async: (raises: [CancelledError]).} = + try: + return await request.respond(responseHttpCode, responseText) + except HttpWriteError as exc: + return defaultResponse(exc) + + let testNamePrefix = errorName & " error response is converted to " & errorType.name & " for " + test testNamePrefix & "sending a manual RPC method request": + registerRpcMethods(response) + expect errorType: + discard await provider.send("eth_accounts") + + test testNamePrefix & "calling a provider method that converts errors when calling a generated RPC request": + registerRpcMethods(response) + expect errorType: + discard await provider.listAccounts() + + test testNamePrefix & "calling a view method of a contract": + registerRpcMethods(response) + expect errorType: + discard await token.balanceOf(Address.example) + + test testNamePrefix & "calling a contract method that executes a transaction": + registerRpcMethods(response) + expect errorType: + token = TestToken.new(token.address, provider.getSigner()) + discard await token.mint( + Address.example, 100.u256, + TransactionOverrides(gasLimit: 100.u256.some, chainId: 1.u256.some) + ) + + test testNamePrefix & "sending a manual transaction": + registerRpcMethods(response) + expect errorType: + let tx = Transaction.example + discard await provider.getSigner().sendTransaction(tx) + + test testNamePrefix & "sending a raw transaction": + registerRpcMethods(response) + expect errorType: + const pk_with_funds = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + let wallet = !Wallet.new(pk_with_funds) + let tx = Transaction( + to: wallet.address, + nonce: some 0.u256, + chainId: some 31337.u256, + gasPrice: some 1_000_000_000.u256, + gasLimit: some 21_000.u256, + ) + let signedTx = await wallet.signTransaction(tx) + discard await provider.sendTransaction(signedTx) + + test testNamePrefix & "subscribing to blocks": + registerRpcMethods(response) + expect errorType: + let emptyHandler = proc(blckResult: ?!Block) = discard + discard await provider.subscribe(emptyHandler) + + test testNamePrefix & "subscribing to logs": + registerRpcMethods(response) + expect errorType: + let filter = EventFilter(address: Address.example, topics: @[array[32, byte].example]) + let emptyHandler = proc(log: ?!Log) = discard + discard await provider.subscribe(filter, emptyHandler) + + testCustomResponse("429", Http429, "Too many requests", RequestLimitError) + testCustomResponse("408", Http408, "Request timed out", RequestTimeoutError) + testCustomResponse("non-429", Http500, "Server error", JsonRpcProviderError) diff --git a/testmodule/providers/jsonrpc/testJsonRpcSubscriptions.nim b/testmodule/providers/jsonrpc/testJsonRpcSubscriptions.nim index 9417cfa..fdbaa5d 100644 --- a/testmodule/providers/jsonrpc/testJsonRpcSubscriptions.nim +++ b/testmodule/providers/jsonrpc/testJsonRpcSubscriptions.nim @@ -8,7 +8,7 @@ import ethers/provider import ethers/providers/jsonrpc/subscriptions import ../../examples -import ./rpc_mock +import ./mocks/mockRpcHttpServerSubscriptions suite "JsonRpcSubscriptions": @@ -111,18 +111,18 @@ suite "HTTP polling subscriptions - mock tests": var subscriptions: PollingSubscriptions var client: RpcHttpClient - var mockServer: MockRpcHttpServer + var mockServer: MockRpcHttpServerSubscriptions privateAccess(PollingSubscriptions) privateAccess(JsonRpcSubscriptions) proc startServer() {.async.} = - mockServer = MockRpcHttpServer.new() + mockServer = MockRpcHttpServerSubscriptions.new() mockServer.start() - await client.connect("http://" & $mockServer.localAddress()[0]) + await client.connect("http://" & $MockRpcHttpServer(mockServer).localAddress()[0]) proc stopServer() {.async.} = - await mockServer.stop() + await MockRpcHttpServer(mockServer).stop() setup: client = newRpcHttpClient()