Skip to content

fix(transaction): avoid eager reservation of nonce and stray pending tx#8

Merged
aa-eyup merged 4 commits into
masterfrom
ATEAM-1506-relayer_nonce_reuse
May 13, 2026
Merged

fix(transaction): avoid eager reservation of nonce and stray pending tx#8
aa-eyup merged 4 commits into
masterfrom
ATEAM-1506-relayer_nonce_reuse

Conversation

@aa-eyup
Copy link
Copy Markdown
Collaborator

@aa-eyup aa-eyup commented May 11, 2026

Motivation

POST /transactions/relayers/:relayer_id/send could reserve and advance a relayer nonce before gas estimation/simulation had fully succeeded. If simulation reverted, the API returned a server error and could leave a PENDING transaction row for a transaction that was never broadcast. Client retries could then consume future nonces due to a lack of idempotent handling.

This PR makes sends safer by moving nonce reservation later (while holding a lock from the beginning of the send lifecycle), classifying deterministic simulation failures as client errors, and adding optional per-relayer external_id idempotency so clients can safely retry accepted or failed sends without consuming additional nonces.

Changes

  • Added optional per-relayer external_id idempotency with a partial unique index.
  • Moved nonce reservation after successful gas estimation/simulation, using a scoped rollback guard.
  • Persist deterministic pre-broadcast simulation failures as FAILED, not PENDING.
  • Map deterministic simulation reverts to 400 Bad Request; keep RPC/transport failures retryable.
  • Initialize nonce cursor from max(chain pending nonce, highest open local nonce + 1). Previously nonces were only initialized from onchain data which ignored in-flight transactions.
  • Preflight cancel/replace duplicate external_ids before broadcasting.

Flow Diagram

POST /transactions/relayers/:relayer_id/send
  |
  |  API auth / permissions / rate limit
  v
find relayer queue
  |
  |  if external_id is present:
  v
lookup existing tx by (relayer_id, external_id)
  |
  +-- found
  |     |
  |     +-- payload differs
  |     |     -> 409 Conflict
  |     |
  |     +-- payload matches + hash present
  |     |     -> return existing id/hash
  |     |
  |     +-- payload matches + no hash
  |           -> 400 Bad Request
  |
  +-- not found
        |
        v
lock relayer queue
  |
  +-- relayer paused
  |     -> 403 Forbidden
  |
  +-- unsupported blob tx
  |     -> 400 Bad Request
  |
  v
build transaction with placeholder nonce
  |
  v
gas price + gas estimation / simulation
  |
  +-- deterministic simulation revert
  |     |
  |     v
  |   insert FAILED row
  |     |
  |     +-- insert wins
  |     |     -> 400 Bad Request
  |     |
  |     +-- external_id conflict
  |           -> resolve existing tx using the same rules above
  |
  +-- RPC / transport failure
  |     -> server/upstream error, no nonce reserved
  |
  v
sync nonce cursor with on-chain nonce
  |
  v
reserve next nonce
  |
  |  reservation increments cursor and holds guard
  v
assign nonce + compute transaction hash
  |
  +-- build/hash failure
  |     -> drop reservation, rollback cursor
  |
  v
insert PENDING tx row
  |
  +-- external_id conflict
  |     -> drop reservation, rollback cursor
  |     -> resolve existing tx using the same rules above
  |
  +-- other DB failure
  |     -> drop reservation, rollback cursor
  |
  v
add tx to in-memory pending queue
  |
  v
commit nonce reservation
  |
  v
return id/hash

@linear
Copy link
Copy Markdown

linear Bot commented May 11, 2026

ATEAM-1506

@aa-eyup aa-eyup changed the title fix(transaction): avoid eager reservation of nonce and stray pending … fix(transaction): avoid eager reservation of nonce and stray pending tx May 11, 2026

#[tokio::test]
async fn reservation_rolls_back_when_dropped() {
let manager = NonceManager::new(TransactionNonce::new(7));

{
let reservation = manager.reserve_next().await;
assert_eq!(reservation.nonce(), TransactionNonce::new(7));
assert_eq!(reservation.nonce(), TransactionNonce::new(7));
}

assert_eq!(manager.get_current_nonce().await, TransactionNonce::new(7));

#[tokio::test]
async fn reservation_commit_keeps_increment() {
let manager = NonceManager::new(TransactionNonce::new(7));
let manager = NonceManager::new(TransactionNonce::new(7));

let reservation = manager.reserve_next().await;
assert_eq!(reservation.nonce(), TransactionNonce::new(7));
);
inmempool.push_back(competitive);

let mined_transaction = transaction_with_nonce(TransactionStatus::MINED, 12);
)
.unwrap();

assert_eq!(next_nonce, TransactionNonce::new(13));
#[test]
fn idempotent_resolve_returns_matching_transaction_with_hash() {
let relayer_id = RelayerId::new();
let mut transaction = transaction_with_nonce(TransactionStatus::PENDING, 1);
#[test]
fn idempotent_resolve_rejects_payload_mismatch() {
let relayer_id = RelayerId::new();
let mut transaction = transaction_with_nonce(TransactionStatus::PENDING, 1);
#[test]
fn idempotent_resolve_returns_bad_request_for_prior_failed_simulation() {
let relayer_id = RelayerId::new();
let mut transaction = transaction_with_nonce(TransactionStatus::FAILED, 1);
aa-eyup added 2 commits May 11, 2026 17:11
…load mismatch

- the external_id should be obeyed as the consumer defines
  the mapping of external_id to transaction content
#[test]
fn idempotent_resolve_returns_accepted_transaction_with_payload_mismatch() {
let relayer_id = RelayerId::new();
let mut transaction = transaction_with_nonce(TransactionStatus::PENDING, 1);
}

#[tokio::test]
async fn reservation_commit_keeps_increment() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a few more test case to do here to help me know how this works:

  1. What happens if transatciont none reserves mulitple (7, 8, 9, 10), then drops 8.
  2. What happens if transatciont none reserves mulitple (7, 8, 9, 10), then commits 10, then drops 8.

We should see expected behaviour for both, which is either to re-use oldest nonce and next reserve gets 8, then next to jump up to 11 after that, or to then ignore the dropped 8 entirely if we can.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mutex guard inside NonceReservation makes those scenarios impossible because reserve_next() holds a OwnedMutexGuard until commit/drop. A second call blocks until the first resolves. You can't have 7, 8, 9, 10 reserved at the same time; there's at most one outstanding reservation per NonceManager.

Serialization may come at cost of throughput, but we get correctness (no gaps, no out-of-order commits, rollback-on-drop is trivial because only one reservation is ever live). IMO its reasonable given nonce correctness is the load-bearing property. Additionally ours, Push's use case of rrelayer is not latency sensitive (the lock is held at the TransactionsQueue level and there is one constructed per relayer (aka signer).

@aa-eyup aa-eyup merged commit 122840e into master May 13, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants