diff --git a/switchboard/nonce_manager.py b/switchboard/nonce_manager.py index 31c2735..0e1776d 100644 --- a/switchboard/nonce_manager.py +++ b/switchboard/nonce_manager.py @@ -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: diff --git a/tests/test_nonce_manager.py b/tests/test_nonce_manager.py index 02a6d39..49a7057 100644 --- a/tests/test_nonce_manager.py +++ b/tests/test_nonce_manager.py @@ -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