diff --git a/ethers/contract.nim b/ethers/contract.nim index d2e68cf..c786381 100644 --- a/ethers/contract.nim +++ b/ethers/contract.nim @@ -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 diff --git a/ethers/provider.nim b/ethers/provider.nim index 340ca44..e6c7624 100644 --- a/ethers/provider.nim +++ b/ethers/provider.nim @@ -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" diff --git a/ethers/providers/jsonrpc.nim b/ethers/providers/jsonrpc.nim index 37db082..2456f15 100644 --- a/ethers/providers/jsonrpc.nim +++ b/ethers/providers/jsonrpc.nim @@ -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) diff --git a/testmodule/miner.nim b/testmodule/miner.nim index 456c0e1..8286993 100644 --- a/testmodule/miner.nim +++ b/testmodule/miner.nim @@ -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) diff --git a/testmodule/testContracts.nim b/testmodule/testContracts.nim index 508e11b..d805e46 100644 --- a/testmodule/testContracts.nim +++ b/testmodule/testContracts.nim @@ -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 diff --git a/testmodule/testJsonRpcProvider.nim b/testmodule/testJsonRpcProvider.nim index 9a2a683..7df5d3d 100644 --- a/testmodule/testJsonRpcProvider.nim +++ b/testmodule/testJsonRpcProvider.nim @@ -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) +