Skip to content

Commit 7eac841

Browse files
authored
prevent stuck transactions by async locking nonce sequencing (+ estimate 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
1 parent 620b402 commit 7eac841

File tree

9 files changed

+182
-57
lines changed

9 files changed

+182
-57
lines changed

ethers/contract.nim

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,6 @@ proc confirm*(tx: Future[?TransactionResponse],
246246
## `await token.connect(signer0)
247247
## .mint(accounts[1], 100.u256)
248248
## .confirm(3)`
249-
250249
without response =? (await tx):
251250
raise newException(
252251
EthersError,

ethers/providers/jsonrpc.nim

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type
2828
subscriptions: JsonRpcSubscriptions
2929
id: JsonNode
3030

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

4949
# Provider
5050

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

231-
raiseProviderError "no address found"
231+
raiseJsonRpcProviderError "no address found"
232232

233233
method signMessage*(signer: JsonRpcSigner,
234234
message: seq[byte]): Future[seq[byte]] {.async.} =
@@ -240,7 +240,8 @@ method signMessage*(signer: JsonRpcSigner,
240240
method sendTransaction*(signer: JsonRpcSigner,
241241
transaction: Transaction): Future[TransactionResponse] {.async.} =
242242
convertError:
243-
signer.updateNonce(transaction.nonce)
243+
if nonce =? transaction.nonce:
244+
signer.updateNonce(nonce)
244245
let
245246
client = await signer.provider.client
246247
hash = await client.eth_sendTransaction(transaction)

ethers/signer.nim

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,25 @@ export basics
66
type
77
Signer* = ref object of RootObj
88
lastSeenNonce: ?UInt256
9+
populateLock: AsyncLock
910

10-
type SignerError* = object of EthersError
11-
12-
template raiseSignerError(message: string) =
13-
raise newException(SignerError, message)
11+
type
12+
SignerError* = object of EthersError
13+
EstimateGasError* = object of SignerError
14+
transaction*: Transaction
15+
16+
template raiseSignerError(message: string, parent: ref ProviderError = nil) =
17+
raise newException(SignerError, message, parent)
18+
19+
proc raiseEstimateGasError(
20+
transaction: Transaction,
21+
parent: ref ProviderError = nil
22+
) =
23+
let e = (ref EstimateGasError)(
24+
msg: "Estimate gas failed",
25+
transaction: transaction,
26+
parent: parent)
27+
raise e
1428

1529
method provider*(signer: Signer): Provider {.base, gcsafe.} =
1630
doAssert false, "not implemented"
@@ -39,23 +53,27 @@ method estimateGas*(signer: Signer,
3953
transaction: Transaction): Future[UInt256] {.base, async.} =
4054
var transaction = transaction
4155
transaction.sender = some(await signer.getAddress)
42-
return await signer.provider.estimateGas(transaction)
56+
try:
57+
return await signer.provider.estimateGas(transaction)
58+
except ProviderError as e:
59+
raiseEstimateGasError transaction, e
4360

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

4764
method getNonce(signer: Signer): Future[UInt256] {.base, gcsafe, async.} =
4865
var nonce = await signer.getTransactionCount(BlockTag.pending)
49-
66+
5067
if lastSeen =? signer.lastSeenNonce and lastSeen >= nonce:
5168
nonce = (lastSeen + 1.u256)
5269
signer.lastSeenNonce = some nonce
53-
70+
5471
return nonce
5572

56-
method updateNonce*(signer: Signer, nonce: ?UInt256) {.base, gcsafe.} =
57-
without nonce =? nonce:
58-
return
73+
method updateNonce*(
74+
signer: Signer,
75+
nonce: UInt256
76+
) {.base, gcsafe.} =
5977

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

85+
method decreaseNonce*(signer: Signer) {.base, gcsafe.} =
86+
if lastSeen =? signer.lastSeenNonce and lastSeen > 0:
87+
signer.lastSeenNonce = some lastSeen - 1
88+
6789
method populateTransaction*(signer: Signer,
6890
transaction: Transaction):
6991
Future[Transaction] {.base, async.} =
@@ -73,17 +95,55 @@ method populateTransaction*(signer: Signer,
7395
if chainId =? transaction.chainId and chainId != await signer.getChainId():
7496
raiseSignerError("chain id mismatch")
7597

98+
if signer.populateLock.isNil:
99+
signer.populateLock = newAsyncLock()
100+
101+
await signer.populateLock.acquire()
102+
76103
var populated = transaction
77104

78105
if transaction.sender.isNone:
79106
populated.sender = some(await signer.getAddress())
80-
if transaction.nonce.isNone:
81-
populated.nonce = some(await signer.getNonce())
82107
if transaction.chainId.isNone:
83108
populated.chainId = some(await signer.getChainId())
84-
if transaction.gasPrice.isNone and (transaction.maxFee.isNone or transaction.maxPriorityFee.isNone):
109+
if transaction.gasPrice.isNone and (populated.maxFee.isNone or populated.maxPriorityFee.isNone):
85110
populated.gasPrice = some(await signer.getGasPrice())
86-
if transaction.gasLimit.isNone:
87-
populated.gasLimit = some(await signer.estimateGas(populated))
111+
112+
if transaction.nonce.isNone and transaction.gasLimit.isNone:
113+
# when both nonce and gasLimit are not populated, we must ensure getNonce is
114+
# followed by an estimateGas so we can determine if there was an error. If
115+
# there is an error, the nonce must be deprecated to prevent nonce gaps and
116+
# stuck transactions
117+
try:
118+
populated.nonce = some(await signer.getNonce())
119+
populated.gasLimit = some(await signer.estimateGas(populated))
120+
except ProviderError, EstimateGasError:
121+
let e = getCurrentException()
122+
signer.decreaseNonce()
123+
raise e
124+
finally:
125+
signer.populateLock.release()
126+
127+
else:
128+
if transaction.nonce.isNone:
129+
populated.nonce = some(await signer.getNonce())
130+
if transaction.gasLimit.isNone:
131+
populated.gasLimit = some(await signer.estimateGas(populated))
88132

89133
return populated
134+
135+
method cancelTransaction*(
136+
signer: Signer,
137+
tx: Transaction
138+
): Future[TransactionResponse] {.async, base.} =
139+
# cancels a transaction by sending with a 0-valued transaction to ourselves
140+
# with the failed tx's nonce
141+
142+
without sender =? tx.sender:
143+
raiseSignerError "transaction must have sender"
144+
without nonce =? tx.nonce:
145+
raiseSignerError "transaction must have nonce"
146+
147+
var cancelTx = Transaction(to: sender, value: 0.u256, nonce: some nonce)
148+
cancelTx = await signer.populateTransaction(cancelTx)
149+
return await signer.sendTransaction(cancelTx)

ethers/testing.nim

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import std/strutils
22
import ./provider
3+
import ./signer
34

4-
proc revertReason*(e: ref ProviderError): string =
5-
var msg = e.msg
5+
proc revertReason*(emsg: string): string =
6+
var msg = emsg
67
const revertPrefixes = @[
78
# hardhat
89
"Error: VM Exception while processing transaction: reverted with " &
@@ -15,14 +16,18 @@ proc revertReason*(e: ref ProviderError): string =
1516
msg = msg.replace("\'")
1617
return msg
1718

19+
proc revertReason*(e: ref EthersError): string =
20+
var msg = e.msg
21+
msg.revertReason
22+
1823
proc reverts*[T](call: Future[T]): Future[bool] {.async.} =
1924
try:
2025
when T is void:
2126
await call
2227
else:
2328
discard await call
2429
return false
25-
except ProviderError:
30+
except ProviderError, EstimateGasError:
2631
return true
2732
2833
proc reverts*[T](call: Future[T], reason: string): Future[bool] {.async.} =
@@ -32,5 +37,12 @@ proc reverts*[T](call: Future[T], reason: string): Future[bool] {.async.} =
3237
else:
3338
discard await call
3439
return false
35-
except ProviderError as error:
36-
return reason == error.revertReason
40+
except ProviderError, EstimateGasError:
41+
let e = getCurrentException()
42+
var passed = reason == (ref EthersError)(e).revertReason
43+
if not passed and
44+
not e.parent.isNil and
45+
e.parent of (ref EthersError):
46+
let revertReason = (ref EthersError)(e.parent).revertReason
47+
passed = reason == revertReason
48+
return passed

ethers/wallet.nim

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,6 @@ proc signTransaction*(wallet: Wallet,
7070

7171
method sendTransaction*(wallet: Wallet, transaction: Transaction): Future[TransactionResponse] {.async.} =
7272
let signed = await signTransaction(wallet, transaction)
73-
wallet.updateNonce(transaction.nonce)
73+
if nonce =? transaction.nonce:
74+
wallet.updateNonce(nonce)
7475
return await provider(wallet).sendTransaction(signed)

testmodule/helpers.nim

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import pkg/ethers
2+
import ./hardhat
3+
4+
type
5+
TestHelpers* = ref object of Contract
6+
7+
method doRevert*(
8+
self: TestHelpers,
9+
revertReason: string
10+
): ?TransactionResponse {.base, contract.}
11+
12+
proc new*(_: type TestHelpers, signer: Signer): TestHelpers =
13+
let deployment = readDeployment()
14+
TestHelpers.new(!deployment.address(TestHelpers), signer)

testmodule/testContracts.nim

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import std/json
2+
import std/options
23
import pkg/asynctest
34
import pkg/questionable
45
import pkg/stint
56
import pkg/ethers
67
import pkg/ethers/erc20
78
import ./hardhat
9+
import ./helpers
810
import ./miner
911
import ./mocks
1012

@@ -235,3 +237,32 @@ for url in ["ws://localhost:8545", "http://localhost:8545"]:
235237
check logs == @[
236238
Transfer(receiver: accounts[0], value: 100.u256)
237239
]
240+
241+
test "concurrent transactions with first failing increment nonce correctly":
242+
let signer = provider.getSigner()
243+
let token = TestToken.new(token.address, signer)
244+
let helpersContract = TestHelpers.new(signer)
245+
246+
# emulate concurrent populateTransaction calls, where the first one fails
247+
let futs = await allFinished(
248+
helpersContract.doRevert("some reason"),
249+
token.mint(accounts[0], 100.u256)
250+
)
251+
check futs[0].error of EstimateGasError
252+
let receipt = await futs[1].confirm(1)
253+
254+
check receipt.status == TransactionStatus.Success
255+
256+
test "non-concurrent transactions with first failing increment nonce correctly":
257+
let signer = provider.getSigner()
258+
let token = TestToken.new(token.address, signer)
259+
let helpersContract = TestHelpers.new(signer)
260+
261+
expect EstimateGasError:
262+
discard await helpersContract.doRevert("some reason")
263+
264+
let receipt = await token
265+
.mint(accounts[0], 100.u256)
266+
.confirm(1)
267+
268+
check receipt.status == TransactionStatus.Success

0 commit comments

Comments
 (0)