Skip to content

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

Open
aa-eyup wants to merge 3 commits into
masterfrom
ATEAM-1506-relayer_nonce_reuse
Open

fix(transaction): avoid eager reservation of nonce and stray pending tx#8
aa-eyup wants to merge 3 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 + hash present
  |     |
  |     +-- payload matches
  |     |     -> return existing id/hash
  |     |
  |     +-- payload differs
  |           -> warn
  |           -> return existing id/hash
  |
  +-- found + no hash
  |     |
  |     +-- payload differs
  |     |     -> 409 Conflict
  |     |
  |     +-- payload matches
  |           -> 400 Bad Request
  |
  +-- not found
        |
        v
lock relayer queue
  |
  +-- relayer paused
  |     -> 403 Forbidden
  |
  +-- unsupported blob tx
  |     -> error
  |
  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);
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.

2 participants