Eric 933103ca6d
Refactor error bubbling
Ensure that all errors bubble to the main `convertError` proc.

Add websockets mocks and tests.

Formatting updates via nph.
2025-07-08 12:10:02 +10:00

412 lines
14 KiB
Nim

import std/importutils
import std/sequtils
import std/typetraits
import std/net
import pkg/stew/byteutils
import pkg/asynctest/chronos/unittest
import pkg/chronos/apps/http/httpclient
import pkg/serde
import pkg/questionable
import pkg/ethers/providers/jsonrpc except toJson, `%`, `%*`
import pkg/ethers/providers/jsonrpc/errors except toJson, `%`, `%*`
import pkg/ethers/erc20 except toJson, `%`, `%*`
import pkg/json_rpc/clients/httpclient except toJson, `%`, `%*`
import pkg/json_rpc/clients/websocketclient except toJson, `%`, `%*`
import pkg/websock/websock
import pkg/websock/http/common
import ./mocks/mockHttpServer
import ./mocks/mockWebSocketServer
import ../../examples
import ../../hardhat
suite "JSON RPC errors":
test "converts JSON RPC error to Nim error":
let error = %*{"message": "some error"}
check JsonRpcProviderError.new(error).msg == "some error"
test "converts error data to bytes":
let error =
%*{"message": "VM Exception: reverted with 'some error'", "data": "0xabcd"}
check JsonRpcProviderError.new(error).data == some @[0xab'u8, 0xcd'u8]
test "converts nested error data to bytes":
let error =
%*{
"message": "VM Exception: reverted with 'some error'",
"data":
{"message": "VM Exception: reverted with 'some error'", "data": "0xabcd"},
}
check JsonRpcProviderError.new(error).data == some @[0xab'u8, 0xcd'u8]
type
TestToken = ref object of Erc20Token
Before = proc(): Future[void] {.gcsafe, raises: [].}
# A proc that runs before each test
proc runBefore(before: Before) {.async.} =
if before != nil:
await before()
method mint(
token: TestToken, holder: Address, amount: UInt256
): Confirmable {.base, contract.}
suite "Network errors - HTTP":
var provider: JsonRpcProvider
var mockServer: MockHttpServer
var token: TestToken
var blockingSocket: Socket
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()
if not blockingSocket.isNil:
blockingSocket.close()
blockingSocket = nil
proc registerRpcMethods(response: RpcResponse) =
mockServer.registerRpcResponse("eth_accounts", response)
mockServer.registerRpcResponse("eth_call", response)
mockServer.registerRpcResponse("eth_sendTransaction", response)
mockServer.registerRpcResponse("eth_sendRawTransaction", response)
mockServer.registerRpcResponse("eth_newBlockFilter", response)
mockServer.registerRpcResponse("eth_newFilter", response)
proc testCustomResponse(
testNamePrefix: string,
response: RpcResponse,
errorType: type CatchableError,
before: Before = nil,
) =
let prefix = testNamePrefix & " when "
test prefix & "sending a manual RPC method request":
registerRpcMethods(response)
await runBefore(before)
expect errorType:
discard await provider.send("eth_accounts")
test prefix &
"calling a provider method that converts errors when calling a generated RPC request":
registerRpcMethods(response)
await runBefore(before)
expect errorType:
discard await provider.listAccounts()
test prefix & "calling a view method of a contract":
registerRpcMethods(response)
await runBefore(before)
expect errorType:
token = TestToken.new(token.address, provider.getSigner())
discard await token.balanceOf(Address.example)
test prefix & "calling a contract method that executes a transaction":
registerRpcMethods(response)
await runBefore(before)
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 prefix & "sending a manual transaction":
registerRpcMethods(response)
await runBefore(before)
expect errorType:
let tx = Transaction.example
discard await provider.getSigner().sendTransaction(tx)
test prefix & "sending a raw transaction":
registerRpcMethods(response)
await runBefore(before)
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 prefix & "subscribing to blocks":
registerRpcMethods(response)
await runBefore(before)
expect errorType:
let emptyHandler = proc(blckResult: ?!Block) =
discard
discard await provider.subscribe(emptyHandler)
test prefix & "subscribing to logs":
registerRpcMethods(response)
await runBefore(before)
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)
proc testCustomHttpResponse(
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 prefix = errorName & " error response is converted to " & errorType.name
testCustomResponse(prefix, response, errorType)
testCustomHttpResponse("429", Http429, "Too many requests", HttpRequestLimitError)
testCustomHttpResponse("408", Http408, "Request timed out", HttpRequestTimeoutError)
testCustomHttpResponse("non-429", Http500, "Server error", JsonRpcProviderError)
testCustomResponse(
"raises RpcNetworkError after a timeout waiting for reading response headers",
response = proc(
request: HttpRequestRef
): Future[HttpResponseRef] {.async: (raises: [CancelledError]).} =
try:
await sleepAsync(5.minutes)
return await request.respond(Http200, "OK")
except HttpWriteError as exc:
return defaultResponse(exc),
RpcNetworkError,
before = proc(): Future[void] {.async.} =
privateAccess(JsonRpcProvider)
privateAccess(RpcHttpClient)
let rpcClient = await provider.client
let client: RpcHttpClient = (RpcHttpClient)(rpcClient)
client.httpSession = HttpSessionRef.new(headersTimeout = 1.millis),
)
testCustomResponse(
"raises RpcNetworkError for a closed connection",
response = proc(
request: HttpRequestRef
): Future[HttpResponseRef] {.async: (raises: [CancelledError]).} =
# Simulate a closed connection
return HttpResponseRef.new(),
RpcNetworkError,
before = proc(): Future[void] {.async.} =
await mockServer.stop()
,
)
testCustomResponse(
"raises RpcNetworkError for a timed out connection",
response = proc(
request: HttpRequestRef
): Future[HttpResponseRef] {.async: (raises: [CancelledError]).} =
# Simulate a closed connection
return HttpResponseRef.new(),
RpcNetworkError,
# msg: Failed to send POST Request with JSON-RPC: Connection timed out
before = proc(): Future[void] {.async.} =
privateAccess(JsonRpcProvider)
privateAccess(RpcHttpClient)
let rpcClient = await provider.client
let client: RpcHttpClient = (RpcHttpClient)(rpcClient)
client.httpSession.connectTimeout = 10.millis
blockingSocket = newSocket()
blockingSocket.setSockOpt(OptReuseAddr, true)
blockingSocket.bindAddr(Port(9999))
await client.connect("http://localhost:9999")
,
)
suite "Network errors - WebSocket":
var provider: JsonRpcProvider
var token: TestToken
var mockWsServer: MockWebSocketServer
setup:
mockWsServer = MockWebSocketServer.init(initTAddress("127.0.0.1:0"))
await mockWsServer.start()
# Get the actual bound address
provider = JsonRpcProvider.new("ws://" & $mockWsServer.localAddress)
let deployment = readDeployment()
token = TestToken.new(!deployment.address(TestToken), provider)
teardown:
await mockWsServer.stop()
try:
await provider.close()
except WebsocketConnectionError:
# WebsocketConnectionError is raised when the connection is already closed
discard
provider = nil
proc registerRpcMethods(response: WebSocketResponse) =
mockWsServer.registerRpcResponse("eth_accounts", response)
mockWsServer.registerRpcResponse("eth_call", response)
mockWsServer.registerRpcResponse("eth_sendTransaction", response)
mockWsServer.registerRpcResponse("eth_sendRawTransaction", response)
mockWsServer.registerRpcResponse("eth_subscribe", response)
proc testCustomResponse(
name: string,
errorType: type CatchableError,
response: WebSocketResponse,
before: Before = nil,
) =
test name & " when sending a manual RPC method request":
registerRpcMethods(response)
await runBefore(before)
expect errorType:
discard await provider.send("eth_accounts")
test name &
" when calling a provider method that converts errors when calling a generated RPC request":
registerRpcMethods(response)
await runBefore(before)
expect errorType:
discard await provider.listAccounts()
test name & " when calling a view method of a contract":
registerRpcMethods(response)
await runBefore(before)
expect errorType:
token = TestToken.new(token.address, provider.getSigner())
discard await token.balanceOf(Address.example)
test name & " when calling a contract method that executes a transaction":
registerRpcMethods(response)
await runBefore(before)
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 name & " when sending a manual transaction":
registerRpcMethods(response)
await runBefore(before)
expect errorType:
let tx = Transaction.example
discard await provider.getSigner().sendTransaction(tx)
test name & " when sending a raw transaction":
registerRpcMethods(response)
await runBefore(before)
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 name & " when subscribing to blocks":
privateAccess(JsonRpcProvider)
registerRpcMethods(response)
await runBefore(before)
expect errorType:
let emptyHandler = proc(blckResult: ?!Block) =
discard
discard await provider.subscribe(emptyHandler)
test name & " when subscribing to logs":
registerRpcMethods(response)
await runBefore(before)
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)
test "should not raise error on normal connection and request":
mockWsServer.registerRpcResponse(
"eth_accounts",
proc(ws: WSSession) {.async.} =
let response =
ResponseTx(jsonrpc: "2.0", id: 1, result: % @["123"], kind: rkResult)
await ws.send(response.toJson)
,
)
let accounts = await provider.send("eth_accounts")
check @["123"] == !seq[string].fromJson(accounts)
testCustomResponse(
"should raise JsonRpcProviderError for a returned error response",
JsonRpcProviderError,
proc(ws: WSSession) {.async.} =
let response = ResponseTx(
jsonrpc: "2.0",
id: 1,
error: ResponseError(code: 1, message: "some error"),
kind: rkError,
)
await ws.send(response.toJson)
,
)
testCustomResponse(
"raises WebsocketConnectionError for closed connection",
WebsocketConnectionError,
proc(ws: WSSession) {.async.} =
# Simulate a closed connection
await ws.close(StatusGoingAway, "Going away")
,
)
testCustomResponse(
"raises WebsocketConnectionError for failed connection",
WebsocketConnectionError,
response = proc(ws: WSSession) {.async.} =
return ,
before = proc() {.async.} =
# Used to simulate an HttpError, which is also raised for "Timeout expired
# while receiving headers", however replicating that exact scenario would
# take 120s as the HttpHeadersTimeout is hardcoded to 120 seconds.
provider = JsonRpcProvider.new("ws://localhost:9999"),
)
testCustomResponse(
"raises JsonRpcProviderError for exceptions in onProcessMessage callback",
JsonRpcProviderError,
response = proc(ws: WSSession) {.async.} =
let response = ResponseTx(jsonrpc: "2.0", id: 1, result: %"", kind: rkResult)
await ws.send(response.toJson)
,
before = proc() {.async.} =
privateAccess(JsonRpcProvider)
let rpcClient = await provider.client
rpcClient.onProcessMessage = proc(
client: RpcClient, line: string
): Result[bool, string] {.gcsafe, raises: [].} =
return err "Some error",
)