Skip to content

feat(api): Nonce management, gas pricing, and EVM transaction lifecycle #226

Description

@Emmyt24

Depends on: #225. Blocks: #224.

Description

Stellar sequence numbers are handled by the client and surface as a tx_bad_seq error the user
retries — see explain_code in submit.rs. EVM nonces
cannot be treated that way for server-originated transactions (gas funding and sweeps, #224), because
the server owns those accounts and must sequence them itself.

EVM adds three problems Stellar simply does not have:

  1. Nonce gaps block the account. Nonces must be strictly sequential. Transaction n+1 cannot
    mine until n does. One stuck transaction halts every subsequent one on that account.
  2. Transactions get stuck. An underpriced transaction can sit in the mempool indefinitely. The
    only fix is replacement — resubmitting the same nonce with ≥ 10% higher gas.
  3. Transactions can be dropped. A mempool eviction means a transaction you believe is pending
    simply does not exist any more, with no notification.

Build nonce management and a transaction lifecycle tracker for server-originated transactions.

Requirements and context

  • Nonce allocation must be atomic and gap-free per (chain, account). Reuse the transactional
    row-lock pattern from Store::allocate_address — the problem is the
    same shape as muxed-id allocation.
  • Reconcile against eth_getTransactionCount with both latest and pending tags on startup and
    periodically. These two disagree by design, and using the wrong one causes either gaps or
    collisions — document which you use where and why.
  • Note the tension with AD-5. Replacement is a resubmit at the same nonce, which is exactly the
    operation the submit-asymmetry rule forbids doing blindly. Replacement is safe only because the
    nonce makes it mutually exclusive with the original. Make that reasoning explicit at the call
    site so a future reader does not "fix" it by adding transport-level retries.
  • EIP-1559 gas: use eth_feeHistory for maxFeePerGas / maxPriorityFeePerGas. Cap the maximum
    gas price — an unbounded escalation loop during a gas spike can drain the gas tank.
  • Handle nonce-gap recovery: if nonce n is permanently stuck, submit a self-transfer of 0 at
    nonce n to unblock the queue.
  • Security: the gas price cap and the escalation policy bound how much a compromised or buggy
    worker can spend. Treat them as security controls, not tuning parameters.

Suggested execution

Branch: feat/evm-nonce-and-tx-lifecycle

Implement changes

  • Migration 00NN_evm_tx_lifecycle.sql: an evm_transactions table with (chain, from_address,
    nonce) unique, gas parameters, submission attempts, state machine (pendingsubmitted
    minedconfirmed, plus replaced / dropped / failed), and the replacement chain.
  • Implement allocate_nonce(chain_id, address) under a row lock, with startup reconciliation
    against on-chain counts.
  • Implement the lifecycle tracker: poll receipts for submitted transactions, detect drops
    (submitted, absent from the mempool, nonce not advanced), and promote on confirmation depth.
  • Implement replacement: escalate gas by ≥ 12.5% (above the 10% minimum, for margin), resubmit at
    the same nonce, and record the replacement chain so both hashes resolve to one logical transaction.
  • Add metrics and alerts: stuck transactions, replacement counts, gas spend per chain, nonce-gap
    detection.

Test and commit

  • Concurrency test: N parallel allocations on one account produce N sequential, gap-free nonces.
  • Anvil test (chore(testing): Anvil-based EVM integration test harness #219): submit an underpriced transaction, trigger replacement, assert the replacement
    mines and the original is marked replaced — not failed, and not double-counted.
  • Test the gas price cap is enforced and escalation stops there rather than looping.
  • Test nonce-gap recovery via a 0-value self-transfer.
  • Test drop detection and resubmission.
  • Test startup reconciliation with both a latest/pending divergence and a pre-existing on-chain
    nonce ahead of the database.
  • Document the lifecycle in docs/architecture.md with a state diagram.

Example commit message

feat(api): EVM nonce allocation and transaction lifecycle

Server-originated transactions (gas funding, sweeps) need strictly
sequential nonces: one stuck transaction halts every later one on the
account. Allocation reuses the row-lock pattern that keeps muxed ids
gap-free.

Adds replacement (same nonce, +12.5% gas) with an explicit price cap so
an escalation loop during a gas spike cannot drain the gas tank, plus
drop detection and 0-value self-transfer gap recovery.

Replacement resubmits at the same nonce, which is safe precisely
because the nonce makes it mutually exclusive with the original —
documented at the call site so it is not mistaken for a violation of
the submit-asymmetry rule.

Refs #226

Guidelines

Complex and easy to get subtly wrong. Write the state machine down before writing code, and put the
diagram in the PR description.


Metadata

Metadata

Assignees

No one assigned

    Labels

    area/backendBackend crates: api, store, ingest, webhooks, bin/serverdifficulty/hardHard, complex, cross-cuttingrisk/security-sensitiveTouches key material, signing, or fund movement — review carefullytype/epicTracking issue for a multi-issue epic

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions