Skip to content

Commit 765379a

Browse files
authored
fix: nonce too high (#81)
* fix nonce issues by locking populate and send transaction Concurrent asynchronous population of transactions cause issues with nonces not being in sync with the transaction count for an account on chain. This was being mitigated by tracking a "last seen" nonce and locking inside of `populateTransaction` so that the nonce could be populated in a concurrent fashion. However, if there was an async cancellation before the transaction was sent, then the nonce would become out of sync. One solution was to decrease the nonce if a cancellation occurred. The other solution, in this commit, is simply to lock the populate and sendTransaction calls together, so that there will not be concurrent nonce discrepancies. This removes the need for "lastSeenNonce" and is overall more simple. * remove lastSeenNonce Internal nonce tracking is no longer needed since populate/sendTransaction is now locked. Even if cancelled midway, the nonce will get a refreshed value from the number of transactions from chain. * chronos v4 exception tracking * Add tests
1 parent b68bea9 commit 765379a

File tree

6 files changed

+91
-89
lines changed

6 files changed

+91
-89
lines changed

ethers/contract.nim

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,17 +90,17 @@ proc decodeResponse(T: type, bytes: seq[byte]): T =
9090

9191
proc call(provider: Provider,
9292
transaction: Transaction,
93-
overrides: TransactionOverrides): Future[seq[byte]] =
93+
overrides: TransactionOverrides): Future[seq[byte]] {.async: (raises: [ProviderError]).} =
9494
if overrides of CallOverrides and
9595
blockTag =? CallOverrides(overrides).blockTag:
96-
provider.call(transaction, blockTag)
96+
await provider.call(transaction, blockTag)
9797
else:
98-
provider.call(transaction)
98+
await provider.call(transaction)
9999

100100
proc call(contract: Contract,
101101
function: string,
102102
parameters: tuple,
103-
overrides = TransactionOverrides()) {.async.} =
103+
overrides = TransactionOverrides()) {.async: (raises: [ProviderError, SignerError]).} =
104104
var transaction = createTransaction(contract, function, parameters, overrides)
105105

106106
if signer =? contract.signer and transaction.sender.isNone:
@@ -112,7 +112,7 @@ proc call(contract: Contract,
112112
function: string,
113113
parameters: tuple,
114114
ReturnType: type,
115-
overrides = TransactionOverrides()): Future[ReturnType] {.async.} =
115+
overrides = TransactionOverrides()): Future[ReturnType] {.async: (raises: [ProviderError, SignerError, ContractError]).} =
116116
var transaction = createTransaction(contract, function, parameters, overrides)
117117

118118
if signer =? contract.signer and transaction.sender.isNone:
@@ -121,16 +121,20 @@ proc call(contract: Contract,
121121
let response = await contract.provider.call(transaction, overrides)
122122
return decodeResponse(ReturnType, response)
123123

124-
proc send(contract: Contract,
125-
function: string,
126-
parameters: tuple,
127-
overrides = TransactionOverrides()):
128-
Future[?TransactionResponse] {.async.} =
124+
proc send(
125+
contract: Contract,
126+
function: string,
127+
parameters: tuple,
128+
overrides = TransactionOverrides()
129+
): Future[?TransactionResponse] {.async: (raises: [AsyncLockError, CancelledError, CatchableError]).} =
130+
129131
if signer =? contract.signer:
130-
let transaction = createTransaction(contract, function, parameters, overrides)
131-
let populated = await signer.populateTransaction(transaction)
132-
var txResp = await signer.sendTransaction(populated)
133-
return txResp.some
132+
withLock(signer):
133+
let transaction = createTransaction(contract, function, parameters, overrides)
134+
let populated = await signer.populateTransaction(transaction)
135+
trace "sending contract transaction", function, params = $parameters
136+
let txResp = await signer.sendTransaction(populated)
137+
return txResp.some
134138
else:
135139
await call(contract, function, parameters, overrides)
136140
return TransactionResponse.none

ethers/providers/jsonrpc.nim

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,6 @@ method sendTransaction*(
327327
{.async: (raises:[SignerError, ProviderError]).} =
328328

329329
convertError:
330-
if nonce =? transaction.nonce:
331-
signer.updateNonce(nonce)
332330
let
333331
client = await signer.provider.client
334332
hash = await client.eth_sendTransaction(transaction)

ethers/signer.nim

Lines changed: 32 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ export basics
88

99
type
1010
Signer* = ref object of RootObj
11-
lastSeenNonce: ?UInt256
1211
populateLock: AsyncLock
1312
SignerError* = object of EthersError
1413

@@ -81,34 +80,26 @@ method getChainId*(
8180
method getNonce(
8281
signer: Signer): Future[UInt256] {.base, async: (raises: [SignerError, ProviderError]).} =
8382

84-
var nonce = await signer.getTransactionCount(BlockTag.pending)
83+
return await signer.getTransactionCount(BlockTag.pending)
8584

86-
if lastSeen =? signer.lastSeenNonce and lastSeen >= nonce:
87-
nonce = (lastSeen + 1.u256)
88-
signer.lastSeenNonce = some nonce
89-
90-
return nonce
91-
92-
method updateNonce*(
93-
signer: Signer,
94-
nonce: UInt256
95-
) {.base, gcsafe.} =
96-
97-
without lastSeen =? signer.lastSeenNonce:
98-
signer.lastSeenNonce = some nonce
99-
return
100-
101-
if nonce > lastSeen:
102-
signer.lastSeenNonce = some nonce
85+
template withLock*(signer: Signer, body: untyped) =
86+
if signer.populateLock.isNil:
87+
signer.populateLock = newAsyncLock()
10388

104-
method decreaseNonce*(signer: Signer) {.base, gcsafe.} =
105-
if lastSeen =? signer.lastSeenNonce and lastSeen > 0:
106-
signer.lastSeenNonce = some lastSeen - 1
89+
await signer.populateLock.acquire()
90+
try:
91+
body
92+
finally:
93+
signer.populateLock.release()
10794

10895
method populateTransaction*(
10996
signer: Signer,
11097
transaction: Transaction): Future[Transaction]
111-
{.base, async: (raises: [CancelledError, AsyncLockError, ProviderError, SignerError]).} =
98+
{.base, async: (raises: [CancelledError, ProviderError, SignerError]).} =
99+
## Populates a transaction with sender, chainId, gasPrice, nonce, and gasLimit.
100+
## NOTE: to avoid async concurrency issues, this routine should be called with
101+
## a lock if it is followed by sendTransaction. For reference, see the `send`
102+
## function in contract.nim.
112103

113104
var address: Address
114105
convertError:
@@ -119,20 +110,14 @@ method populateTransaction*(
119110
if chainId =? transaction.chainId and chainId != await signer.getChainId():
120111
raiseSignerError("chain id mismatch")
121112

122-
if signer.populateLock.isNil:
123-
signer.populateLock = newAsyncLock()
124-
125-
await signer.populateLock.acquire()
126-
127113
var populated = transaction
128114

129-
try:
130-
if transaction.sender.isNone:
131-
populated.sender = some(address)
132-
if transaction.chainId.isNone:
133-
populated.chainId = some(await signer.getChainId())
134-
if transaction.gasPrice.isNone and (transaction.maxFee.isNone or transaction.maxPriorityFee.isNone):
135-
populated.gasPrice = some(await signer.getGasPrice())
115+
if transaction.sender.isNone:
116+
populated.sender = some(address)
117+
if transaction.chainId.isNone:
118+
populated.chainId = some(await signer.getChainId())
119+
if transaction.gasPrice.isNone and (transaction.maxFee.isNone or transaction.maxPriorityFee.isNone):
120+
populated.gasPrice = some(await signer.getGasPrice())
136121

137122
if transaction.nonce.isNone and transaction.gasLimit.isNone:
138123
# when both nonce and gasLimit are not populated, we must ensure getNonce is
@@ -143,27 +128,23 @@ method populateTransaction*(
143128
try:
144129
populated.gasLimit = some(await signer.estimateGas(populated, BlockTag.pending))
145130
except EstimateGasError as e:
146-
signer.decreaseNonce()
147131
raise e
148132
except ProviderError as e:
149-
signer.decreaseNonce()
150133
raiseSignerError(e.msg)
151134

152-
else:
153-
if transaction.nonce.isNone:
154-
populated.nonce = some(await signer.getNonce())
155-
if transaction.gasLimit.isNone:
156-
populated.gasLimit = some(await signer.estimateGas(populated, BlockTag.pending))
157-
158-
finally:
159-
signer.populateLock.release()
135+
else:
136+
if transaction.nonce.isNone:
137+
let nonce = await signer.getNonce()
138+
populated.nonce = some nonce
139+
if transaction.gasLimit.isNone:
140+
populated.gasLimit = some(await signer.estimateGas(populated, BlockTag.pending))
160141

161142
return populated
162143

163144
method cancelTransaction*(
164145
signer: Signer,
165146
tx: Transaction
166-
): Future[TransactionResponse] {.base, async: (raises: [SignerError, ProviderError]).} =
147+
): Future[TransactionResponse] {.base, async: (raises: [SignerError, CancelledError, AsyncLockError, ProviderError]).} =
167148
# cancels a transaction by sending with a 0-valued transaction to ourselves
168149
# with the failed tx's nonce
169150

@@ -172,7 +153,8 @@ method cancelTransaction*(
172153
without nonce =? tx.nonce:
173154
raiseSignerError "transaction must have nonce"
174155

175-
var cancelTx = Transaction(to: sender, value: 0.u256, nonce: some nonce)
176-
convertError:
177-
cancelTx = await signer.populateTransaction(cancelTx)
178-
return await signer.sendTransaction(cancelTx)
156+
withLock(signer):
157+
convertError:
158+
var cancelTx = Transaction(to: sender, value: 0.u256, nonce: some nonce)
159+
cancelTx = await signer.populateTransaction(cancelTx)
160+
return await signer.sendTransaction(cancelTx)

ethers/signers/wallet.nim

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,4 @@ method sendTransaction*(
8686
{.async: (raises:[SignerError, ProviderError]).} =
8787

8888
let signed = await signTransaction(wallet, transaction)
89-
if nonce =? transaction.nonce:
90-
wallet.updateNonce(nonce)
9189
return await provider(wallet).sendTransaction(signed)

testmodule/providers/jsonrpc/testJsonRpcSigner.nim

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -84,24 +84,3 @@ suite "JsonRpcSigner":
8484
transaction.chainId = 0xdeadbeef.u256.some
8585
expect SignerError:
8686
discard await signer.populateTransaction(transaction)
87-
88-
test "concurrent populate calls increment nonce":
89-
let signer = provider.getSigner()
90-
let count = await signer.getTransactionCount(BlockTag.pending)
91-
var transaction1 = Transaction.example
92-
var transaction2 = Transaction.example
93-
var transaction3 = Transaction.example
94-
95-
let populated = await allFinished(
96-
signer.populateTransaction(transaction1),
97-
signer.populateTransaction(transaction2),
98-
signer.populateTransaction(transaction3)
99-
)
100-
101-
transaction1 = await populated[0]
102-
transaction2 = await populated[1]
103-
transaction3 = await populated[2]
104-
105-
check !transaction1.nonce == count
106-
check !transaction2.nonce == count + 1.u256
107-
check !transaction3.nonce == count + 2.u256

testmodule/testContracts.nim

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,44 @@ for url in ["ws://" & providerUrl, "http://" & providerUrl]:
272272
.confirm(1)
273273

274274
check receipt.status == TransactionStatus.Success
275+
276+
test "can cancel procs that execute transactions":
277+
let signer = provider.getSigner()
278+
let token = TestToken.new(token.address, signer)
279+
let countBefore = await signer.getTransactionCount(BlockTag.pending)
280+
281+
proc executeTx {.async.} =
282+
discard await token.mint(accounts[0], 100.u256)
283+
284+
await executeTx().cancelAndWait()
285+
let countAfter = await signer.getTransactionCount(BlockTag.pending)
286+
check countBefore == countAfter
287+
288+
test "concurrent transactions succeed even if one is cancelled":
289+
let signer = provider.getSigner()
290+
let token = TestToken.new(token.address, signer)
291+
let balanceBefore = await token.myBalance()
292+
293+
proc executeTx: Future[Confirmable] {.async.} =
294+
return await token.mint(accounts[0], 100.u256)
295+
296+
proc executeTxWithCancellation: Future[Confirmable] {.async.} =
297+
let fut = token.mint(accounts[0], 100.u256)
298+
fut.cancelSoon()
299+
return await fut
300+
301+
# emulate concurrent populateTransaction/sendTransaction calls, where the
302+
# first one fails
303+
let futs = await allFinished(
304+
executeTxWithCancellation(),
305+
executeTx(),
306+
executeTx()
307+
)
308+
let receipt1 = await futs[1].confirm(0)
309+
let receipt2 = await futs[2].confirm(0)
310+
311+
check receipt1.status == TransactionStatus.Success
312+
check receipt2.status == TransactionStatus.Success
313+
314+
let balanceAfter = await token.myBalance()
315+
check balanceAfter == balanceBefore + 200.u256

0 commit comments

Comments
 (0)