Refactor based on PR comments

- `TransactionReceipt.blockHash` is optional
- Block.number is optional (in case node doesn’t return this in the event)
- Refactor confirmations waiting such that there is no polling for a receipt at the start
- Make BlockHandler and SubscriptionHandler async
- change casing of constants
- change return type checking of contract method to check for `Confirmable` instead of `?TransactionRepsonse`
- Reduce miner sleep to 10ms
- Change `wait` and `Waitable` to `confirm` and `Confirmable` to avoid conflict with chrono’s `.wait`.
- Update params on `.confirm` so that the compiler can restrict values of the `int` to `Positive` and `Natural`.
- Add `Block` and `TransactionReceipt` conversion tests to test for missing block number and block hash.
- Add tests for confirmation calculations and determining if a tx has been mined from its receipt.
- Assume that blockNumber returned from node will be null or empty string, in which case we can parse as 0 and test for that condition.
This commit is contained in:
Eric Mastro 2022-05-18 23:14:39 +10:00 committed by Eric Mastro
parent a3e888128c
commit c5c9534876
6 changed files with 276 additions and 84 deletions

View File

@ -17,6 +17,7 @@ type
signer: ?Signer
address: Address
ContractError* = object of EthersError
Confirmable* = ?TransactionResponse
EventHandler*[E: Event] = proc(event: E) {.gcsafe, upraises:[].}
func new*(ContractType: type Contract,
@ -102,17 +103,16 @@ func isConstant(procedure: NimNode): bool =
false
func isTxResponse(returntype: NimNode): bool =
return returntype.kind == nnkPrefix and
returntype[0].kind == nnkIdent and
returntype[0].strVal == "?" and
returntype[1].kind == nnkIdent and
returntype[1].strVal == $TransactionResponse
return returntype.eqIdent($ Confirmable)
func addContractCall(procedure: var NimNode) =
let contract = procedure[3][1][0]
let function = $basename(procedure[0])
let parameters = getParameterTuple(procedure)
let returntype = procedure[3][0]
# procedure[5] =
# quote:
# static: checkReturnType(type(result))
procedure[6] =
if procedure.isConstant:
if returntype.kind == nnkEmpty:
@ -145,7 +145,6 @@ func addAsyncPragma(procedure: var NimNode) =
func checkReturnType(procedure: NimNode) =
let returntype = procedure[3][0]
if returntype.kind != nnkEmpty:
# Do not throw exception for methods that have a TransactionResponse
# return type as that is needed for .wait

View File

@ -33,23 +33,22 @@ type
transactionIndex*: UInt256
gasUsed*: UInt256
logsBloom*: seq[byte]
blockHash*: BlockHash
blockHash*: ?BlockHash
transactionHash*: TransactionHash
logs*: seq[Log]
blockNumber*: ?UInt256
cumulativeGasUsed*: UInt256
status*: TransactionStatus
LogHandler* = proc(log: Log) {.gcsafe, upraises:[].}
BlockHandler* = proc(blck: Block) {.gcsafe, upraises:[].}
BlockHandler* = proc(blck: Block): Future[void] {.gcsafe, upraises:[].}
Topic* = array[32, byte]
Block* = object
number*: UInt256
number*: ?UInt256
timestamp*: UInt256
hash*: array[32, byte]
const DEFAULT_CONFIRMATIONS* {.intdefine.} = 12
const RECEIPT_TIMEOUT_BLKS* {.intdefine.} = 50 # in blocks
const RECEIPT_POLLING_INTERVAL* {.intdefine.} = 1 # in seconds
const EthersDefaultConfirmations* {.intdefine.} = 12
const EthersReceiptTimeoutBlks* {.intdefine.} = 50 # in blocks
method getBlockNumber*(provider: Provider): Future[UInt256] {.base.} =
doAssert false, "not implemented"
@ -71,6 +70,11 @@ method getTransactionCount*(provider: Provider,
Future[UInt256] {.base.} =
doAssert false, "not implemented"
method getTransactionReceipt*(provider: Provider,
txHash: TransactionHash):
Future[?TransactionReceipt] {.base.} =
doAssert false, "not implemented"
method estimateGas*(provider: Provider,
transaction: Transaction): Future[UInt256] {.base.} =
doAssert false, "not implemented"

View File

@ -25,7 +25,7 @@ type
provider: JsonRpcProvider
address: ?Address
JsonRpcProviderError* = object of EthersError
SubscriptionHandler = proc(id, arguments: JsonNode) {.gcsafe, upraises:[].}
SubscriptionHandler = proc(id, arguments: JsonNode): Future[void] {.gcsafe, upraises:[].}
template raiseProviderError(message: string) =
raise newException(JsonRpcProviderError, message)
@ -59,7 +59,8 @@ proc connect(provider: JsonRpcProvider, url: string) =
proc handleSubscription(arguments: JsonNode) {.upraises: [].} =
if id =? arguments["subscription"].catch and
handler =? getSubscriptionHandler(id):
handler(id, arguments)
# fire and forget
discard handler(id, arguments)
proc subscribe: Future[RpcClient] {.async.} =
let client = await RpcClient.connect(url)
@ -114,6 +115,7 @@ method getTransactionCount*(provider: JsonRpcProvider,
Future[UInt256] {.async.} =
let client = await provider.client
return await client.eth_getTransactionCount(address, blockTag)
method getTransactionReceipt*(provider: JsonRpcProvider,
txHash: TransactionHash):
Future[?TransactionReceipt] {.async.} =
@ -148,7 +150,7 @@ method subscribe*(provider: JsonRpcProvider,
filter: Filter,
callback: LogHandler):
Future[Subscription] {.async.} =
proc handler(id, arguments: JsonNode) =
proc handler(id, arguments: JsonNode) {.async.} =
if log =? Log.fromJson(arguments["result"]).catch:
callback(log)
return await provider.subscribe("logs", filter.some, handler)
@ -156,9 +158,9 @@ method subscribe*(provider: JsonRpcProvider,
method subscribe*(provider: JsonRpcProvider,
callback: BlockHandler):
Future[Subscription] {.async.} =
proc handler(id, arguments: JsonNode) =
proc handler(id, arguments: JsonNode) {.async.} =
if blck =? Block.fromJson(arguments["result"]).catch:
callback(blck)
await callback(blck)
return await provider.subscribe("newHeads", Filter.none, handler)
method unsubscribe*(subscription: JsonRpcSubscription) {.async.} =
@ -196,84 +198,111 @@ method sendTransaction*(signer: JsonRpcSigner,
return TransactionResponse(hash: hash, provider: signer.provider)
method wait*(tx: TransactionResponse,
wantedConfirms = DEFAULT_CONFIRMATIONS,
timeoutInBlocks = RECEIPT_TIMEOUT_BLKS.some): # will error if tx not mined in x blocks
# Removed from `confirm` closure and exported so it can be tested.
# Likely there is a better way
func confirmations*(receiptBlk, atBlk: UInt256): UInt256 =
## Calculates the number of confirmations between two blocks
if atBlk < receiptBlk:
return 0.u256
else:
return (atBlk - receiptBlk) + 1 # add 1 for current block
# Removed from `confirm` closure and exported so it can be tested.
# Likely there is a better way
func hasBeenMined*(receipt: ?TransactionReceipt,
atBlock: UInt256,
wantedConfirms: int): bool =
## Returns true if the transaction receipt has been returned from the node
## with a valid block number and block hash and the specified number of
## blocks have passed since the tx was mined (confirmations)
if receipt.isSome and
receipt.get.blockNumber.isSome and
receipt.get.blockNumber.get > 0 and
# from ethers.js: "geth-etc" returns receipts before they are ready
receipt.get.blockHash.isSome:
let receiptBlock = receipt.get.blockNumber.get
return receiptBlock.confirmations(atBlock) >= wantedConfirms.u256
return false
method confirm*(tx: TransactionResponse,
wantedConfirms: Positive = EthersDefaultConfirmations,
timeoutInBlocks: Natural = EthersReceiptTimeoutBlks):
Future[TransactionReceipt]
{.async, upraises: [JsonRpcProviderError].} = # raises for clarity
## Waits for a transaction to be mined and for the specified number of blocks
## to pass since it was mined (confirmations).
## A timeout, in blocks, can be specified that will raise a
## JsonRpcProviderError if too many blocks have passed without the tx
## having been mined.
## Note: this method requires that the JsonRpcProvider client connects
## using RpcWebSocketClient, otherwise it will raise a Defect.
var
receipt: ?TransactionReceipt
subscription: JsonRpcSubscription
var subscription: JsonRpcSubscription
let
provider = JsonRpcProvider(tx.provider)
retFut = newFuture[TransactionReceipt]("wait")
proc confirmations(receipt: TransactionReceipt, atBlkNum: UInt256): UInt256 =
var confirms = (atBlkNum - !receipt.blockNumber) + 1
if confirms <= 0: confirms = 1.u256
return confirms
proc newBlock(blk: Block) =
# has been mined, need to check # of confirmations thus far
let confirms = (!receipt).confirmations(blk.number)
if confirms >= wantedConfirms.u256:
# fire and forget
discard subscription.unsubscribe()
retFut.complete(!receipt)
# used to check for block timeouts
let startBlock = await provider.getBlockNumber()
# loop until the tx is mined, or times out (in blocks) if timeout specified
while receipt.isNone:
receipt = await provider.getTransactionReceipt(tx.hash)
if receipt.isSome and (!receipt).blockNumber.isSome:
break
proc newBlock(blk: Block) {.async.} =
## subscription callback, called every time a new block event is sent from
## the node
if timeoutInBlocks.isSome:
let currBlock = await provider.getBlockNumber()
let blocksPassed = (currBlock - startBlock) + 1
if blocksPassed >= (!timeoutInBlocks).u256:
raiseProviderError("Transaction was not mined in " &
$(!timeoutInBlocks) & " blocks")
# if ethereum node doesn't include blockNumber in the event
without blkNum =? blk.number:
return
await sleepAsync(RECEIPT_POLLING_INTERVAL.seconds)
let receipt = await provider.getTransactionReceipt(tx.hash)
if receipt.hasBeenMined(blkNum, wantedConfirms):
# fire and forget
discard subscription.unsubscribe()
retFut.complete(receipt.get)
# has been mined, need to check # of confirmations thus far
let confirms = (!receipt).confirmations(startBlock)
if confirms >= wantedConfirms.u256:
return !receipt
elif timeoutInBlocks > 0:
let blocksPassed = (blkNum - startBlock) + 1
if blocksPassed >= timeoutInBlocks.u256:
discard subscription.unsubscribe()
retFut.fail(
newException(JsonRpcProviderError, "Transaction was not mined in " &
$timeoutInBlocks & " blocks"))
# If our tx is already mined, return the receipt. Otherwise, check each
# new block to see if the tx has been mined
let receipt = await provider.getTransactionReceipt(tx.hash)
if receipt.hasBeenMined(startBlock, wantedConfirms):
return receipt.get
else:
let sub = await provider.subscribe(newBlock)
subscription = JsonRpcSubscription(sub)
return (await retFut)
method wait*(tx: Future[TransactionResponse],
wantedConfirms = DEFAULT_CONFIRMATIONS,
timeoutInBlocks = RECEIPT_TIMEOUT_BLKS.some):
method confirm*(tx: Future[TransactionResponse],
wantedConfirms: Positive = EthersDefaultConfirmations,
timeoutInBlocks: Natural = EthersReceiptTimeoutBlks):
Future[TransactionReceipt] {.async.} =
## Convenience method that allows wait to be chained to a sendTransaction
## call, eg:
## `await signer.sendTransaction(populated).wait(3)`
## `await signer.sendTransaction(populated).confirm(3)`
let txResp = await tx
return await txResp.wait(wantedConfirms, timeoutInBlocks)
return await txResp.confirm(wantedConfirms, timeoutInBlocks)
method wait*(tx: Future[?TransactionResponse],
wantedConfirms = DEFAULT_CONFIRMATIONS,
timeoutInBlocks = RECEIPT_TIMEOUT_BLKS.some):
method confirm*(tx: Future[?TransactionResponse],
wantedConfirms: Positive = EthersDefaultConfirmations,
timeoutInBlocks: Natural = EthersReceiptTimeoutBlks):
Future[TransactionReceipt] {.async.} =
## Convenience method that allows wait to be chained to a contract
## transaction, eg:
## `await token.connect(signer0)
## .mint(accounts[1], 100.u256)
## .wait(3)`
## .confirm(3)`
let txResp = await tx
if txResp.isNone:
without txResp =? (await tx):
raiseProviderError("Transaction hash required. Possibly was a call instead of a send?")
return await (!txResp).wait(wantedConfirms, timeoutInBlocks)
return await txResp.confirm(wantedConfirms, timeoutInBlocks)

View File

@ -5,4 +5,6 @@ import pkg/ethers/providers/jsonrpc
proc mineBlocks*(provider: JsonRpcProvider, blks: int) {.async.} =
for i in 1..blks:
discard await provider.send("evm_mine")
await sleepAsync(1.seconds)
# Gives time for the subscription to occur in `.wait`.
# Likely needed in slower environments, like CI.
await sleepAsync(2.milliseconds)

View File

@ -19,7 +19,7 @@ method totalSupply*(erc20: Erc20): UInt256 {.base, contract, view.}
method balanceOf*(erc20: Erc20, account: Address): UInt256 {.base, contract, view.}
method allowance*(erc20: Erc20, owner, spender: Address): UInt256 {.base, contract, view.}
method transfer*(erc20: Erc20, recipient: Address, amount: UInt256) {.base, contract.}
method mint(token: TestToken, holder: Address, amount: UInt256): ?TransactionResponse {.base, contract.}
method mint(token: TestToken, holder: Address, amount: UInt256): Confirmable {.base, contract.}
suite "Contracts":
@ -122,14 +122,20 @@ suite "Contracts":
check transfers == @[Transfer(receiver: accounts[0], value: 100.u256)]
test "can wait for contract interaction tx to be mined":
# must be spawned so we can get newHeads inside of .wait
asyncSpawn provider.mineBlocks(3)
# must not be awaited so we can get newHeads inside of .wait
let futMined = provider.mineBlocks(10)
let signer0 = provider.getSigner(accounts[0])
let receipt = await token.connect(signer0)
.mint(accounts[1], 100.u256)
.wait(3) # wait for 3 confirmations
.confirm(3) # wait for 3 confirmations
let endBlock = await provider.getBlockNumber()
check receipt.blockNumber.isSome # was eventually mined
check (endBlock - !receipt.blockNumber) + 1 == 3 # +1 for the block the tx was mined in
# >= 3 because more blocks may have been mined by the time the
# check in `.wait` was done.
# +1 for the block the tx was mined in
check (endBlock - !receipt.blockNumber) + 1 >= 3
await futMined

View File

@ -40,16 +40,16 @@ suite "JsonRpcProvider":
let block1 = !await provider.getBlock(BlockTag.earliest)
let block2 = !await provider.getBlock(BlockTag.latest)
check block1.hash != block2.hash
check block1.number < block2.number
check !block1.number < !block2.number
check block1.timestamp < block2.timestamp
test "subscribes to new blocks":
let oldBlock = !await provider.getBlock(BlockTag.latest)
var newBlock: Block
let blockHandler = proc(blck: Block) = newBlock = blck
let blockHandler = proc(blck: Block) {.async.} = newBlock = blck
let subscription = await provider.subscribe(blockHandler)
discard await provider.send("evm_mine")
check newBlock.number > oldBlock.number
check !newBlock.number > !oldBlock.number
check newBlock.timestamp > oldBlock.timestamp
check newBlock.hash != oldBlock.hash
await subscription.unsubscribe()
@ -67,28 +67,180 @@ suite "JsonRpcProvider":
let transaction = Transaction.example
let populated = await signer.populateTransaction(transaction)
# must be spawned so we can get newHeads inside of .wait
asyncSpawn provider.mineBlocks(3)
# must not be awaited so we can get newHeads inside of .wait
let futMined = provider.mineBlocks(5)
let receipt = await signer.sendTransaction(populated).wait(3)
let receipt = await signer.sendTransaction(populated).confirm(3)
let endBlock = await provider.getBlockNumber()
check receipt.blockNumber.isSome # was eventually mined
check (endBlock - !receipt.blockNumber) + 1 == 3 # +1 for the block the tx was mined in
# >= 3 because more blocks may have been mined by the time the
# check in `.wait` was done.
# +1 for the block the tx was mined in
check (endBlock - !receipt.blockNumber) + 1 >= 3
await futMined
test "waiting for block to be mined times out":
# must be spawned so we can get newHeads inside of .wait
asyncSpawn provider.mineBlocks(10)
# must not be awaited so we can get newHeads inside of .wait
let futMined = provider.mineBlocks(7)
let startBlock = await provider.getBlockNumber()
let response = TransactionResponse(hash: TransactionHash.example,
provider: provider)
try:
discard await response.wait(wantedConfirms = 2,
timeoutInBlocks = 5.some)
discard await response.confirm(wantedConfirms = 2,
timeoutInBlocks = 5)
await futMined
except JsonRpcProviderError as e:
check e.msg == "Transaction was not mined in 5 blocks"
let endBlock = await provider.getBlockNumber()
check (endBlock - startBlock) + 1 == 5 # +1 including start block
# >= 5 because more blocks may have been mined by the time the
# check in `.wait` was done.
# +1 for including the start block
check (endBlock - startBlock) + 1 >= 5 # +1 including start block
if not futMined.completed and not futMined.finished: await futMined
test "Conversion: missing block number in Block isNone":
var blkJson = %*{
"subscription": "0x20",
"result":{
"number": newJNull(),
"hash":"0x2d7d68c8f48b4213d232a1f12cab8c9fac6195166bb70a5fb21397984b9fe1c7",
"timestamp":"0x6285c293"
}
}
var blk = Block.fromJson(blkJson["result"])
check blk.number.isNone
blkJson["result"]["number"] = newJString("")
blk = Block.fromJson(blkJson["result"])
check blk.number.isSome
check blk.number.get.isZero
test "Conversion: missing block number in TransactionReceipt isNone":
var txReceiptJson = %*{
"sender": newJNull(),
"to": "0x5fbdb2315678afecb367f032d93f642f64180aa3",
"contractAddress": newJNull(),
"transactionIndex": "0x0",
"gasUsed": "0x10db1",
"logsBloom": "0x00000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000840020000000000000000000800000000000000000000000010000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000020000000000000000000000000000000000001000000000000000000000000000000",
"blockHash": "0x7b00154e06fe4f27a87208eba220efb4dbc52f7429549a39a17bba2e0d98b960",
"transactionHash": "0xa64f07b370cbdcce381ec9bfb6c8004684341edfb6848fd418189969d4b9139c",
"logs": [
{
"data": "0x0000000000000000000000000000000000000000000000000000000000000064",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000000000000000000000000000000000000000000000",
"0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8"
]
}
],
"blockNumber": newJNull(),
"cumulativeGasUsed": "0x10db1",
"status": "0000000000000001"
}
var txReceipt = TransactionReceipt.fromJson(txReceiptJson)
check txReceipt.blockNumber.isNone
txReceiptJson["blockNumber"] = newJString("")
txReceipt = TransactionReceipt.fromJson(txReceiptJson)
check txReceipt.blockNumber.isSome
check txReceipt.blockNumber.get.isZero
test "Conversion: missing block hash in TransactionReceipt isNone":
var txReceiptJson = %*{
"sender": newJNull(),
"to": "0x5fbdb2315678afecb367f032d93f642f64180aa3",
"contractAddress": newJNull(),
"transactionIndex": "0x0",
"gasUsed": "0x10db1",
"logsBloom": "0x00000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000840020000000000000000000800000000000000000000000010000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000020000000000000000000000000000000000001000000000000000000000000000000",
"blockHash": newJNull(),
"transactionHash": "0xa64f07b370cbdcce381ec9bfb6c8004684341edfb6848fd418189969d4b9139c",
"logs": [
{
"data": "0x0000000000000000000000000000000000000000000000000000000000000064",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000000000000000000000000000000000000000000000",
"0x00000000000000000000000070997970c51812dc3a010c7d01b50e0d17dc79c8"
]
}
],
"blockNumber": newJNull(),
"cumulativeGasUsed": "0x10db1",
"status": "0000000000000001"
}
var txReceipt = TransactionReceipt.fromJson(txReceiptJson)
check txReceipt.blockHash.isNone
test "confirmations calculated correctly":
# when receipt block number is higher than current block number,
# should return 0
check confirmations(2.u256, 1.u256) == 0.u256
# Same receipt and current block counts as one confirmation
check confirmations(1.u256, 1.u256) == 1.u256
check confirmations(1.u256, 2.u256) == 2.u256
test "checks if transation has been mined correctly":
var receipt = TransactionReceipt.none
var currentBlock = 1.u256
var wantedConfirms = 1
let blockHash = hexToByteArray[32](
"0x7b00154e06fe4f27a87208eba220efb4dbc52f7429549a39a17bba2e0d98b960"
).some
# if TransactionReceipt is none, should not be considered mined
check not receipt.hasBeenMined(currentBlock, wantedConfirms)
# missing blockHash
receipt = TransactionReceipt(
blockNumber: 1.u256.some
).some
check not receipt.hasBeenMined(currentBlock, wantedConfirms)
# missing block number
receipt = TransactionReceipt(
blockHash: blockHash
).some
check not receipt.hasBeenMined(currentBlock, wantedConfirms)
# block number is 0
receipt = TransactionReceipt(
blockNumber: 0.u256.some
).some
check not receipt.hasBeenMined(currentBlock, wantedConfirms)
# not enough confirms
receipt = TransactionReceipt(
blockNumber: 1.u256.some
).some
check not receipt.hasBeenMined(currentBlock, wantedConfirms)
# success
receipt = TransactionReceipt(
blockNumber: 1.u256.some,
blockHash: blockHash
).some
currentBlock = int.high.u256
wantedConfirms = int.high
check receipt.hasBeenMined(currentBlock, wantedConfirms)