Skip to content

Commit

Permalink
prevent stuck transactions by async locking nonce sequencing (+ estim…
Browse files Browse the repository at this point in the history
…ate gas) (#55)

- async lock during nonce sequencing + gas estimation
- simplified cancelTransaction (still exported) such that the new transaction is populated using populateTransaction, so that all gas and fees are reset
- moved reverting contract function into its own testing helpers module, and refactored any tests to use it
- updated the test helper reverts to check EstimateGasErrors
- combine ensureNonceSequence into populateTransaction
  • Loading branch information
emizzle authored Oct 24, 2023
1 parent 620b402 commit 7eac841
Show file tree
Hide file tree
Showing 9 changed files with 182 additions and 57 deletions.
1 change: 0 additions & 1 deletion ethers/contract.nim
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,6 @@ proc confirm*(tx: Future[?TransactionResponse],
## `await token.connect(signer0)
## .mint(accounts[1], 100.u256)
## .confirm(3)`

without response =? (await tx):
raise newException(
EthersError,
Expand Down
11 changes: 6 additions & 5 deletions ethers/providers/jsonrpc.nim
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type
subscriptions: JsonRpcSubscriptions
id: JsonNode

proc raiseProviderError(message: string) {.upraises: [JsonRpcProviderError].} =
proc raiseJsonRpcProviderError(message: string) {.upraises: [JsonRpcProviderError].} =
var message = message
try:
message = parseJson(message){"message"}.getStr
Expand All @@ -40,11 +40,11 @@ template convertError(body) =
try:
body
except JsonRpcError as error:
raiseProviderError(error.msg)
raiseJsonRpcProviderError(error.msg)
# Catch all ValueErrors for now, at least until JsonRpcError is actually
# raised. PR created: https://github.com/status-im/nim-json-rpc/pull/151
except ValueError as error:
raiseProviderError(error.msg)
raiseJsonRpcProviderError(error.msg)

# Provider

Expand Down Expand Up @@ -228,7 +228,7 @@ method getAddress*(signer: JsonRpcSigner): Future[Address] {.async.} =
if accounts.len > 0:
return accounts[0]

raiseProviderError "no address found"
raiseJsonRpcProviderError "no address found"

method signMessage*(signer: JsonRpcSigner,
message: seq[byte]): Future[seq[byte]] {.async.} =
Expand All @@ -240,7 +240,8 @@ method signMessage*(signer: JsonRpcSigner,
method sendTransaction*(signer: JsonRpcSigner,
transaction: Transaction): Future[TransactionResponse] {.async.} =
convertError:
signer.updateNonce(transaction.nonce)
if nonce =? transaction.nonce:
signer.updateNonce(nonce)
let
client = await signer.provider.client
hash = await client.eth_sendTransaction(transaction)
Expand Down
90 changes: 75 additions & 15 deletions ethers/signer.nim
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,25 @@ export basics
type
Signer* = ref object of RootObj
lastSeenNonce: ?UInt256
populateLock: AsyncLock

type SignerError* = object of EthersError

template raiseSignerError(message: string) =
raise newException(SignerError, message)
type
SignerError* = object of EthersError
EstimateGasError* = object of SignerError
transaction*: Transaction

template raiseSignerError(message: string, parent: ref ProviderError = nil) =
raise newException(SignerError, message, parent)

proc raiseEstimateGasError(
transaction: Transaction,
parent: ref ProviderError = nil
) =
let e = (ref EstimateGasError)(
msg: "Estimate gas failed",
transaction: transaction,
parent: parent)
raise e

method provider*(signer: Signer): Provider {.base, gcsafe.} =
doAssert false, "not implemented"
Expand Down Expand Up @@ -39,23 +53,27 @@ method estimateGas*(signer: Signer,
transaction: Transaction): Future[UInt256] {.base, async.} =
var transaction = transaction
transaction.sender = some(await signer.getAddress)
return await signer.provider.estimateGas(transaction)
try:
return await signer.provider.estimateGas(transaction)
except ProviderError as e:
raiseEstimateGasError transaction, e

method getChainId*(signer: Signer): Future[UInt256] {.base, gcsafe.} =
signer.provider.getChainId()

method getNonce(signer: Signer): Future[UInt256] {.base, gcsafe, async.} =
var nonce = await signer.getTransactionCount(BlockTag.pending)

if lastSeen =? signer.lastSeenNonce and lastSeen >= nonce:
nonce = (lastSeen + 1.u256)
signer.lastSeenNonce = some nonce

return nonce

method updateNonce*(signer: Signer, nonce: ?UInt256) {.base, gcsafe.} =
without nonce =? nonce:
return
method updateNonce*(
signer: Signer,
nonce: UInt256
) {.base, gcsafe.} =

without lastSeen =? signer.lastSeenNonce:
signer.lastSeenNonce = some nonce
Expand All @@ -64,6 +82,10 @@ method updateNonce*(signer: Signer, nonce: ?UInt256) {.base, gcsafe.} =
if nonce > lastSeen:
signer.lastSeenNonce = some nonce

method decreaseNonce*(signer: Signer) {.base, gcsafe.} =
if lastSeen =? signer.lastSeenNonce and lastSeen > 0:
signer.lastSeenNonce = some lastSeen - 1

method populateTransaction*(signer: Signer,
transaction: Transaction):
Future[Transaction] {.base, async.} =
Expand All @@ -73,17 +95,55 @@ method populateTransaction*(signer: Signer,
if chainId =? transaction.chainId and chainId != await signer.getChainId():
raiseSignerError("chain id mismatch")

if signer.populateLock.isNil:
signer.populateLock = newAsyncLock()

await signer.populateLock.acquire()

var populated = transaction

if transaction.sender.isNone:
populated.sender = some(await signer.getAddress())
if transaction.nonce.isNone:
populated.nonce = some(await signer.getNonce())
if transaction.chainId.isNone:
populated.chainId = some(await signer.getChainId())
if transaction.gasPrice.isNone and (transaction.maxFee.isNone or transaction.maxPriorityFee.isNone):
if transaction.gasPrice.isNone and (populated.maxFee.isNone or populated.maxPriorityFee.isNone):
populated.gasPrice = some(await signer.getGasPrice())
if transaction.gasLimit.isNone:
populated.gasLimit = some(await signer.estimateGas(populated))

if transaction.nonce.isNone and transaction.gasLimit.isNone:
# when both nonce and gasLimit are not populated, we must ensure getNonce is
# followed by an estimateGas so we can determine if there was an error. If
# there is an error, the nonce must be deprecated to prevent nonce gaps and
# stuck transactions
try:
populated.nonce = some(await signer.getNonce())
populated.gasLimit = some(await signer.estimateGas(populated))
except ProviderError, EstimateGasError:
let e = getCurrentException()
signer.decreaseNonce()
raise e
finally:
signer.populateLock.release()

else:
if transaction.nonce.isNone:
populated.nonce = some(await signer.getNonce())
if transaction.gasLimit.isNone:
populated.gasLimit = some(await signer.estimateGas(populated))

return populated

method cancelTransaction*(
signer: Signer,
tx: Transaction
): Future[TransactionResponse] {.async, base.} =
# cancels a transaction by sending with a 0-valued transaction to ourselves
# with the failed tx's nonce

without sender =? tx.sender:
raiseSignerError "transaction must have sender"
without nonce =? tx.nonce:
raiseSignerError "transaction must have nonce"

var cancelTx = Transaction(to: sender, value: 0.u256, nonce: some nonce)
cancelTx = await signer.populateTransaction(cancelTx)
return await signer.sendTransaction(cancelTx)
22 changes: 17 additions & 5 deletions ethers/testing.nim
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import std/strutils
import ./provider
import ./signer

proc revertReason*(e: ref ProviderError): string =
var msg = e.msg
proc revertReason*(emsg: string): string =
var msg = emsg
const revertPrefixes = @[
# hardhat
"Error: VM Exception while processing transaction: reverted with " &
Expand All @@ -15,14 +16,18 @@ proc revertReason*(e: ref ProviderError): string =
msg = msg.replace("\'")
return msg

proc revertReason*(e: ref EthersError): string =
var msg = e.msg
msg.revertReason

proc reverts*[T](call: Future[T]): Future[bool] {.async.} =
try:
when T is void:
await call
else:
discard await call
return false
except ProviderError:
except ProviderError, EstimateGasError:
return true

proc reverts*[T](call: Future[T], reason: string): Future[bool] {.async.} =
Expand All @@ -32,5 +37,12 @@ proc reverts*[T](call: Future[T], reason: string): Future[bool] {.async.} =
else:
discard await call
return false
except ProviderError as error:
return reason == error.revertReason
except ProviderError, EstimateGasError:
let e = getCurrentException()
var passed = reason == (ref EthersError)(e).revertReason
if not passed and
not e.parent.isNil and
e.parent of (ref EthersError):
let revertReason = (ref EthersError)(e.parent).revertReason
passed = reason == revertReason
return passed
3 changes: 2 additions & 1 deletion ethers/wallet.nim
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,6 @@ proc signTransaction*(wallet: Wallet,

method sendTransaction*(wallet: Wallet, transaction: Transaction): Future[TransactionResponse] {.async.} =
let signed = await signTransaction(wallet, transaction)
wallet.updateNonce(transaction.nonce)
if nonce =? transaction.nonce:
wallet.updateNonce(nonce)
return await provider(wallet).sendTransaction(signed)
14 changes: 14 additions & 0 deletions testmodule/helpers.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import pkg/ethers
import ./hardhat

type
TestHelpers* = ref object of Contract

method doRevert*(
self: TestHelpers,
revertReason: string
): ?TransactionResponse {.base, contract.}

proc new*(_: type TestHelpers, signer: Signer): TestHelpers =
let deployment = readDeployment()
TestHelpers.new(!deployment.address(TestHelpers), signer)
31 changes: 31 additions & 0 deletions testmodule/testContracts.nim
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import std/json
import std/options
import pkg/asynctest
import pkg/questionable
import pkg/stint
import pkg/ethers
import pkg/ethers/erc20
import ./hardhat
import ./helpers
import ./miner
import ./mocks

Expand Down Expand Up @@ -235,3 +237,32 @@ for url in ["ws://localhost:8545", "http://localhost:8545"]:
check logs == @[
Transfer(receiver: accounts[0], value: 100.u256)
]

test "concurrent transactions with first failing increment nonce correctly":
let signer = provider.getSigner()
let token = TestToken.new(token.address, signer)
let helpersContract = TestHelpers.new(signer)

# emulate concurrent populateTransaction calls, where the first one fails
let futs = await allFinished(
helpersContract.doRevert("some reason"),
token.mint(accounts[0], 100.u256)
)
check futs[0].error of EstimateGasError
let receipt = await futs[1].confirm(1)

check receipt.status == TransactionStatus.Success

test "non-concurrent transactions with first failing increment nonce correctly":
let signer = provider.getSigner()
let token = TestToken.new(token.address, signer)
let helpersContract = TestHelpers.new(signer)

expect EstimateGasError:
discard await helpersContract.doRevert("some reason")

let receipt = await token
.mint(accounts[0], 100.u256)
.confirm(1)

check receipt.status == TransactionStatus.Success
Loading

0 comments on commit 7eac841

Please sign in to comment.