Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions switchboard/nonce_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,19 @@ def acquire_nonce(self, address: str, transaction: Optional[Any] = None) -> int:
# First, ensure our local state is synchronized with the latest on-chain nonce.
self._sync_with_onchain_nonce(state, address)

# Determine the next available nonce.
# If there are any pending nonces, the next one is the highest pending + 1.
# Otherwise, it's the current `confirmed_nonce` (which should be the next expected nonce).
# Determine the next available nonce: the lowest nonce at or above
# `confirmed_nonce` that is not already pending. Walking up from
# `confirmed_nonce` (rather than taking ``max(pending) + 1``) means a
# nonce freed by `release_nonce` — or a gap left by an out-of-order
# confirmation — is reused before the sequence is extended. Ethereum
# requires gapless nonces, so leaving a hole would stall every
# higher-nonce pending tx until the gap is filled.
next_nonce = state.confirmed_nonce
if state.pending_nonces:
next_nonce = max(state.pending_nonces) + 1

for pending in state.pending_nonces.irange(minimum=next_nonce):
if pending != next_nonce:
break
next_nonce += 1

# Add the chosen nonce to the set of pending nonces.
state.pending_nonces.add(next_nonce)
if transaction is not None:
Expand Down
31 changes: 31 additions & 0 deletions tests/test_nonce_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,37 @@ def test_release_nonce(self):
self.nonce_manager.release_nonce(self.wallet_address_1, 5)
self.assertEqual(self.nonce_manager.get_pending_nonces(self.wallet_address_1), SortedSet())

def test_release_lower_nonce_is_reused(self):
"""
Releasing a lower nonce while a higher one is still pending must make the
freed nonce available again on the next acquire — not leave a permanent
gap. Ethereum requires gapless nonces, so a skipped nonce would stall
every higher-nonce pending transaction until the gap is filled.

Regression test: previously `acquire_nonce` returned `max(pending) + 1`,
which skipped the released nonce.
"""
tx_w1_0 = MockTransaction(0, "w1_0")
tx_w1_1 = MockTransaction(1, "w1_1")
self.nonce_manager.acquire_nonce(self.wallet_address_1, tx_w1_0) # nonce 0
self.nonce_manager.acquire_nonce(self.wallet_address_1, tx_w1_1) # nonce 1
self.assertEqual(self.nonce_manager.get_pending_nonces(self.wallet_address_1), SortedSet([0, 1]))

# tx for nonce 0 fails locally before broadcast; free nonce 0 while 1 is still pending.
self.nonce_manager.release_nonce(self.wallet_address_1, 0)
self.assertEqual(self.nonce_manager.get_pending_nonces(self.wallet_address_1), SortedSet([1]))

# The next acquire must reuse the freed nonce 0, not jump to 2.
tx_w1_retry = MockTransaction(0, "w1_retry")
reused = self.nonce_manager.acquire_nonce(self.wallet_address_1, tx_w1_retry)
self.assertEqual(reused, 0)
self.assertEqual(self.nonce_manager.get_pending_nonces(self.wallet_address_1), SortedSet([0, 1]))

# A further acquire extends the sequence normally (no gaps remain).
tx_w1_2 = MockTransaction(2, "w1_2")
self.assertEqual(self.nonce_manager.acquire_nonce(self.wallet_address_1, tx_w1_2), 2)
self.assertEqual(self.nonce_manager.get_pending_nonces(self.wallet_address_1), SortedSet([0, 1, 2]))

def test_sync_with_onchain_nonce_external_confirmation(self):
"""
Tests synchronization with the on-chain nonce when external transactions
Expand Down
Loading