From 2f97a03fe29643a1b068f06a03921eff45ccbf85 Mon Sep 17 00:00:00 2001 From: Eric Mastro Date: Tue, 17 May 2022 12:34:22 +1000 Subject: [PATCH] feat: Can wait for transaction confirmations Allows specified number of transaction confirmations to be awaited before a transaction is considered finalised. Polls for a transaction receipt then listens for new blocks and calculates the number of blocks between the receipt and the new block. Notes 1. Only works with websockets currently 2. Replaced transaction not supported yet --- ethers/contract.nim | 11 +++- ethers/provider.nim | 55 ++++++++++++++++ ethers/providers/jsonrpc.nim | 81 +++++++++++++++++++++++- ethers/providers/jsonrpc/conversions.nim | 10 +++ ethers/providers/jsonrpc/signatures.nim | 3 +- ethers/signer.nim | 2 +- testmodule/testJsonRpcProvider.nim | 44 ++++++++++++- 7 files changed, 197 insertions(+), 9 deletions(-) diff --git a/ethers/contract.nim b/ethers/contract.nim index 799a994..7ae7d06 100644 --- a/ethers/contract.nim +++ b/ethers/contract.nim @@ -72,13 +72,17 @@ proc call(contract: Contract, let response = await contract.provider.call(transaction, blockTag) return decodeResponse(ReturnType, response) -proc send(contract: Contract, function: string, parameters: tuple) {.async.} = +proc send(contract: Contract, function: string, parameters: tuple): + Future[?TransactionResponse] {.async.} = + if signer =? contract.signer: let transaction = createTransaction(contract, function, parameters) let populated = await signer.populateTransaction(transaction) - await signer.sendTransaction(populated) + let txResp = await signer.sendTransaction(populated) + return txResp.some else: await call(contract, function, parameters) + return TransactionResponse.none func getParameterTuple(procedure: NimNode): NimNode = let parameters = procedure[3] @@ -112,7 +116,8 @@ func addContractCall(procedure: var NimNode) = return await call(`contract`, `function`, `parameters`, `returntype`) else: quote: - await send(`contract`, `function`, `parameters`) + # TODO: need to be able to use wait here + discard await send(`contract`, `function`, `parameters`) func addFuture(procedure: var NimNode) = let returntype = procedure[3][0] diff --git a/ethers/provider.nim b/ethers/provider.nim index 42d4c06..bfb6f9d 100644 --- a/ethers/provider.nim +++ b/ethers/provider.nim @@ -10,21 +10,76 @@ push: {.upraises: [].} type Provider* = ref object of RootObj + Subscription* = ref object of RootObj + Filter* = object address*: Address topics*: seq[Topic] + Log* = object data*: seq[byte] topics*: seq[Topic] + + TransactionHash* = array[32, byte] + + BlockHash* = array[32, byte] + + TransactionStatus* = enum + Failure = 0, + Success = 1, + Invalid = 2 + + TransactionResponse* = object + provider*: Provider + hash*: TransactionHash + + TransactionReceipt* = object + sender*: ?Address + to*: ?Address + contractAddress*: ?Address + transactionIndex*: UInt256 + gasUsed*: UInt256 + logsBloom*: seq[byte] + blockHash*: BlockHash + transactionHash*: TransactionHash + logs*: seq[Log] + blockNumber*: ?UInt256 + cumulativeGasUsed*: UInt256 + status*: TransactionStatus + + ProviderEventKind* = enum + LogEvent, + NewHeadEvent + + ProviderEvent* = object + case kind*: ProviderEventKind + of LogEvent: + log*: Log + of NewHeadEvent: + newHead*: NewHead + + ProviderEventHandler* = proc(event: ProviderEvent) {.gcsafe, upraises:[].} + + ProviderEventCallback* = (ProviderEventHandler, ProviderEventKind) + LogHandler* = proc(log: Log) {.gcsafe, upraises:[].} BlockHandler* = proc(blck: Block) {.gcsafe, upraises:[].} + + NewHead* = object + number*: UInt256 # block number + transactions*: seq[TransactionHash] + # NewHeadHandler* = EventHandler[NewHead] + Topic* = array[32, byte] + Block* = object number*: UInt256 timestamp*: UInt256 hash*: array[32, byte] +const DEFAULT_CONFIRMATIONS* {.intdefine.} = 12 + method getBlockNumber*(provider: Provider): Future[UInt256] {.base.} = doAssert false, "not implemented" diff --git a/ethers/providers/jsonrpc.nim b/ethers/providers/jsonrpc.nim index 256fcc6..3940896 100644 --- a/ethers/providers/jsonrpc.nim +++ b/ethers/providers/jsonrpc.nim @@ -114,6 +114,11 @@ 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.} = + let client = await provider.client + return await client.eth_getTransactionReceipt(txHash) method estimateGas*(provider: JsonRpcProvider, transaction: Transaction): Future[UInt256] {.async.} = @@ -184,6 +189,76 @@ method signMessage*(signer: JsonRpcSigner, return await client.eth_sign(address, message) method sendTransaction*(signer: JsonRpcSigner, - transaction: Transaction) {.async.} = - let client = await signer.provider.client - discard await client.eth_sendTransaction(transaction) + transaction: Transaction): Future[TransactionResponse] {.async.} = + let + client = await signer.provider.client + hash = await client.eth_sendTransaction(transaction) + + return TransactionResponse(hash: hash, provider: signer.provider) + +method wait*(tx: TransactionResponse, + wantedConfirms = DEFAULT_CONFIRMATIONS, + timeoutInBlocks = int.none): # will error if tx not mined in x blocks + Future[TransactionReceipt] + {.async, upraises: [JsonRpcProviderError].} = # raises for clarity + + var + receipt: ?TransactionReceipt + 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) + + 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 + + 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") + + # TODO: should this be set to the current block time? + await sleepAsync(1.seconds) + + # has been mined, need to check # of confirmations thus far + let confirms = (!receipt).confirmations(startBlock) + if confirms >= wantedConfirms.u256: + return !receipt + + else: + let sub = await provider.subscribe(newBlock) + subscription = JsonRpcSubscription(sub) + return (await retFut) + +method wait*(tx: Future[TransactionResponse], + wantedConfirms = DEFAULT_CONFIRMATIONS, + timeoutInBlocks = int.none): + Future[TransactionReceipt] {.async.} = + ## Convenience method that allows wait to be chained to a sendTransaction + ## call, eg: + ## `await signer.sendTransaction(populated).wait(3)` + + let txResp = await tx + return await txResp.wait(wantedConfirms, timeoutInBlocks) diff --git a/ethers/providers/jsonrpc/conversions.nim b/ethers/providers/jsonrpc/conversions.nim index 6e90886..1d93bf3 100644 --- a/ethers/providers/jsonrpc/conversions.nim +++ b/ethers/providers/jsonrpc/conversions.nim @@ -1,4 +1,5 @@ import std/json +import std/strutils import pkg/json_rpc/jsonmarshal import pkg/stew/byteutils import ../../basics @@ -74,3 +75,12 @@ func fromJson*(json: JsonNode, name: string, result: var Log) = fromJson(json["data"], "data", data) fromJson(json["topics"], "topics", topics) result = Log(data: data, topics: topics) + +# TransactionStatus + +func fromJson*(json: JsonNode, name: string, result: var TransactionStatus) = + let val = fromHex[int](json.getStr) + result = TransactionStatus(val) + +func `%`*(status: TransactionStatus): JsonNode = + %(status.int.toHex) diff --git a/ethers/providers/jsonrpc/signatures.nim b/ethers/providers/jsonrpc/signatures.nim index b946209..1d9a88f 100644 --- a/ethers/providers/jsonrpc/signatures.nim +++ b/ethers/providers/jsonrpc/signatures.nim @@ -7,7 +7,8 @@ proc eth_getBlockByNumber(blockTag: BlockTag, includeTransactions: bool): ?Block proc eth_getTransactionCount(address: Address, blockTag: BlockTag): UInt256 proc eth_estimateGas(transaction: Transaction): UInt256 proc eth_chainId(): UInt256 -proc eth_sendTransaction(transaction: Transaction): array[32, byte] +proc eth_sendTransaction(transaction: Transaction): TransactionHash +proc eth_getTransactionReceipt(hash: TransactionHash): ?TransactionReceipt proc eth_sign(account: Address, message: seq[byte]): seq[byte] proc eth_subscribe(name: string, filter = Filter.none): JsonNode proc eth_unsubscribe(id: JsonNode): bool diff --git a/ethers/signer.nim b/ethers/signer.nim index 0b3aa3b..bf6ea7b 100644 --- a/ethers/signer.nim +++ b/ethers/signer.nim @@ -20,7 +20,7 @@ method signMessage*(signer: Signer, doAssert false, "not implemented" method sendTransaction*(signer: Signer, - transaction: Transaction) {.base, async.} = + transaction: Transaction): Future[TransactionResponse] {.base, async.} = doAssert false, "not implemented" method getGasPrice*(signer: Signer): Future[UInt256] {.base.} = diff --git a/testmodule/testJsonRpcProvider.nim b/testmodule/testJsonRpcProvider.nim index fd44f9a..9391952 100644 --- a/testmodule/testJsonRpcProvider.nim +++ b/testmodule/testJsonRpcProvider.nim @@ -1,7 +1,10 @@ import std/json import pkg/asynctest import pkg/chronos -import pkg/ethers/providers/jsonrpc +import pkg/ethers #/providers/jsonrpc +import pkg/stew/byteutils +import ./examples +import ./miner suite "JsonRpcProvider": @@ -50,3 +53,42 @@ suite "JsonRpcProvider": check newBlock.timestamp > oldBlock.timestamp check newBlock.hash != oldBlock.hash await subscription.unsubscribe() + + test "can send a transaction": + let signer = provider.getSigner() + let transaction = Transaction.example + let populated = await signer.populateTransaction(transaction) + + let txResp = await signer.sendTransaction(populated) + check txResp.hash.len == 32 and UInt256.fromHex(txResp.hash.toHex) > 0 + + test "can wait for a transaction to be confirmed": + let signer = provider.getSigner() + 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) + + let receipt = await signer.sendTransaction(populated).wait(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 + + test "waiting for block to be mined times out": + + # must be spawned so we can get newHeads inside of .wait + asyncSpawn provider.mineBlocks(10) + + let startBlock = await provider.getBlockNumber() + let response = TransactionResponse(hash: TransactionHash.example, + provider: provider) + try: + discard await response.wait(wantedConfirms = 2, + timeoutInBlocks = 5.some) + 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