DEV Community

Cover image for Building an EVM Transaction Manager for Automated Trading Bots
hamssog
hamssog

Posted on Originally published at hamssog.substack.com

Building an EVM Transaction Manager for Automated Trading Bots

A trading bot can detect an opportunity correctly and still execute incorrectly.

The strategy may be fine.

The transaction infrastructure may not be.

For automated EVM trading, I treat transaction management as its own engineering layer.

The system has to handle:

Signal
  ↓
Transaction Request
  ↓
Nonce Allocation
  ↓
Simulation
  ↓
Submission
  ↓
Pending
  ↓
Confirmation
  ↓
Reconciliation
Enter fullscreen mode Exit fullscreen mode

And the failure paths matter just as much:

Pending
  ├── Reverted
  ├── Timed Out
  ├── Replaced
  ├── RPC Failure
  └── Unknown Nonce Consumption
Enter fullscreen mode Exit fullscreen mode

For this project, I am building an EVM Transaction Manager with TypeScript, viem, SQLite, Vitest, and Foundry.

The goal is to create reusable infrastructure that can sit underneath:

  • automated trading bots
  • arbitrage systems
  • copy-trading systems
  • Pons applications
  • stock-token trading systems
  • trading terminals
  • custom EVM applications

This is not a trading strategy.

It is the transaction lifecycle underneath the strategy.


Architecture

The basic architecture is:

+----------------------+
| Trading Strategy     |
+----------+-----------+
           |
           v
+----------------------+
| Risk / Execution     |
+----------+-----------+
           |
           v
+----------------------+
| EVM Transaction      |
| Manager              |
+----------------------+
| Nonce Manager        |
| Persistence          |
| Submission           |
| Monitoring           |
| Retry Policy         |
| Replacement          |
| Reconciliation       |
+----------+-----------+
           |
           v
+----------------------+
| EVM RPC              |
+----------+-----------+
           |
           v
+----------------------+
| Blockchain            |
+----------------------+
Enter fullscreen mode Exit fullscreen mode

The strategy should not own:

  • nonce allocation
  • transaction persistence
  • receipt polling
  • replacement logic
  • recovery after restart

Those responsibilities belong inside the transaction manager.


Why transaction management needs its own layer

A naive bot often looks like:

const hash = await walletClient.sendTransaction({
  account,
  to,
  data,
});
Enter fullscreen mode Exit fullscreen mode

That produces a transaction hash.

It does not provide a complete execution lifecycle.

A more useful abstraction is:

submit()
   ↓
transaction ID
   ↓
pending
   ↓
confirmed / reverted
   ↓
reconciled
Enter fullscreen mode Exit fullscreen mode

This lets the rest of the application work with a logical transaction rather than directly managing every RPC operation.


Project structure

The repository is organized around clear responsibilities:

src/
├── config/
│   └── env.ts
│
├── domain/
│   ├── transaction.ts
│   ├── transaction-status.ts
│   └── transaction-error.ts
│
├── clients/
│   └── evm-client.ts
│
├── nonce/
│   ├── nonce-manager.ts
│   └── nonce-lock.ts
│
├── persistence/
│   ├── database.ts
│   └── transaction-repository.ts
│
├── submission/
│   ├── transaction-submitter.ts
│   ├── fee-policy.ts
│   └── replacement-policy.ts
│
├── monitoring/
│   ├── transaction-monitor.ts
│   ├── receipt-monitor.ts
│   └── confirmation-monitor.ts
│
├── reconciliation/
│   └── transaction-reconciler.ts
│
└── services/
    └── transaction-manager.ts
Enter fullscreen mode Exit fullscreen mode

The goal is not to create dozens of abstractions.

The goal is to keep blockchain access, persistence, lifecycle state, and business logic from becoming one giant class.


Transaction domain model

I use an explicit transaction record.

A simplified TypeScript version:

export type TransactionStatus =
  | "CREATED"
  | "QUEUED"
  | "SIMULATING"
  | "SUBMITTING"
  | "SUBMITTED"
  | "PENDING"
  | "CONFIRMED"
  | "REVERTED"
  | "REPLACEMENT_PENDING"
  | "REPLACED"
  | "TIMED_OUT"
  | "RECONCILIATION_REQUIRED"
  | "RECONCILED"
  | "FAILED";
Enter fullscreen mode Exit fullscreen mode

And the record:

export type TransactionRecord = {
  id: string;
  chainId: number;
  account: `0x${string}`;
  nonce: number;
  to: `0x${string}`;
  data?: `0x${string}`;
  value?: bigint;

  currentHash?: `0x${string}`;
  replacementHashes: `0x${string}`[];

  status: TransactionStatus;

  attemptCount: number;

  createdAt: number;
  submittedAt?: number;
  confirmedAt?: number;
  reconciledAt?: number;
  lastCheckedAt?: number;

  blockNumber?: bigint;
  blockHash?: `0x${string}`;

  errorCode?: string;
  errorMessage?: string;
};
Enter fullscreen mode Exit fullscreen mode

One important implementation detail is avoiding JavaScript floating-point numbers for EVM amounts and fee values.

Use bigint for values that represent on-chain integers.


Explicit state transitions

A transaction should not be able to jump randomly between states.

For example:

CREATED
   ↓
QUEUED
   ↓
SIMULATING
   ↓
SUBMITTING
   ↓
SUBMITTED
   ↓
PENDING
   ↓
CONFIRMED
   ↓
RECONCILED
Enter fullscreen mode Exit fullscreen mode

Failure paths:

SUBMITTED
   ├── REVERTED
   ├── TIMED_OUT
   └── REPLACEMENT_PENDING
                    ↓
                REPLACED
Enter fullscreen mode Exit fullscreen mode

Create a transition validator:

const allowedTransitions: Record<
  TransactionStatus,
  TransactionStatus[]
> = {
  CREATED: ["QUEUED", "FAILED"],
  QUEUED: ["SIMULATING", "FAILED"],
  SIMULATING: ["SUBMITTING", "FAILED"],
  SUBMITTING: ["SUBMITTED", "FAILED"],
  SUBMITTED: [
    "PENDING",
    "CONFIRMED",
    "REVERTED",
    "REPLACEMENT_PENDING",
    "TIMED_OUT",
    "RECONCILIATION_REQUIRED",
  ],
  PENDING: [
    "CONFIRMED",
    "REVERTED",
    "REPLACEMENT_PENDING",
    "TIMED_OUT",
    "RECONCILIATION_REQUIRED",
  ],
  CONFIRMED: ["RECONCILED"],
  RECONCILIATION_REQUIRED: ["RECONCILED", "FAILED"],
  REPLACEMENT_PENDING: [
    "REPLACED",
    "CONFIRMED",
    "REVERTED",
    "RECONCILIATION_REQUIRED",
  ],
  REPLACED: ["CONFIRMED", "RECONCILIATION_REQUIRED"],
  TIMED_OUT: ["REPLACEMENT_PENDING", "RECONCILIATION_REQUIRED"],
  RECONCILED: [],
  REVERTED: [],
  FAILED: [],
};
Enter fullscreen mode Exit fullscreen mode

The exact state model can evolve, but the important part is that transitions are explicit and validated.


Nonce management

Nonce management becomes difficult as soon as multiple transactions use the same account.

Consider:

Strategy A ──┐
Strategy B ──┼──> Same wallet
Strategy C ──┘
Enter fullscreen mode Exit fullscreen mode

If each service independently requests the pending nonce, they can race.

For example:

Request A → nonce 100
Request B → nonce 100
Enter fullscreen mode Exit fullscreen mode

The transaction manager should allocate nonces centrally:

pending nonce = 100

Request A → 100
Request B → 101
Request C → 102
Enter fullscreen mode Exit fullscreen mode

The allocation itself needs concurrency control.


Nonce manager API

A clean interface might look like:

export interface NonceManager {
  getNextNonce(
    account: `0x${string}`
  ): Promise<number>;

  allocateNonce(
    account: `0x${string}`
  ): Promise<number>;

  reconcileNonce(
    account: `0x${string}`
  ): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

The implementation should:

  1. query chain state
  2. establish the starting nonce
  3. allocate locally
  4. serialize concurrent allocation
  5. periodically reconcile

The important rule is:

Never assume two concurrent calls will see different chain nonces.

The local allocator is what guarantees uniqueness inside the application.


Concurrent nonce allocation

This should be tested explicitly.

Pseudo-scenario:

const requests = Array.from(
  { length: 50 },
  () => nonceManager.allocateNonce(account)
);

const nonces = await Promise.all(requests);
Enter fullscreen mode Exit fullscreen mode

The test should verify:

50 requests
      ↓
50 unique nonces
Enter fullscreen mode Exit fullscreen mode

and not:

50 requests
      ↓
same nonce repeated
Enter fullscreen mode Exit fullscreen mode

Do not make this reliable through arbitrary delays.

Use an actual mutex or serialized queue.


Keeping chain state and local state synchronized

Local nonce tracking can become stale.

For example:

Local state:
next nonce = 120

Blockchain:
nonce usage has moved forward
Enter fullscreen mode Exit fullscreen mode

The transaction manager needs reconciliation logic.

A useful mental model is:

Blockchain
    ↑
    |
Local Nonce State
    ↑
    |
Transaction Records
Enter fullscreen mode Exit fullscreen mode

The local state is an optimization and coordination mechanism.

The blockchain remains the source of truth.


Submission flow

A transaction submission should look like:

Transaction Request
        ↓
Validate
        ↓
Allocate Nonce
        ↓
Simulate
        ↓
Prepare Fees
        ↓
Submit
        ↓
Persist Hash
        ↓
Monitor
Enter fullscreen mode Exit fullscreen mode

A simplified public API:

const result = await manager.submit({
  account,
  to,
  data,
  value,
});
Enter fullscreen mode Exit fullscreen mode

Return a logical transaction ID as well as the initial transaction hash:

{
  transactionId,
  hash
}
Enter fullscreen mode Exit fullscreen mode

This is important because one logical transaction may later have multiple attempts.


Simulation before submission

For contract calls, the manager can optionally simulate before sending.

The flow is:

Request
  ↓
Simulation
  |
  +-- fail → don't submit
  |
  +-- success
        ↓
      submit
Enter fullscreen mode Exit fullscreen mode

With viem, the application can use simulation before writing when the request supports it.

The important limitation is:

Simulation is a check against one observed state, not a guarantee of future mining success.

The state can change between simulation and execution.

So simulation improves validation but does not replace monitoring and reconciliation.


Fee management

Transaction replacement depends on fee policy.

I keep fee logic separate:

export interface FeePolicy {
  getInitialFees(): Promise<{
    maxFeePerGas: bigint;
    maxPriorityFeePerGas: bigint;
  }>;

  getReplacementFees(
    previous: {
      maxFeePerGas: bigint;
      maxPriorityFeePerGas: bigint;
    },
    attempt: number
  ): {
    maxFeePerGas: bigint;
    maxPriorityFeePerGas: bigint;
  };
}
Enter fullscreen mode Exit fullscreen mode

The replacement policy can then be configured with values such as:

replacementBumpPercent
minimumFeeBump
maxFeePerGasCap
maxPriorityFeePerGasCap
Enter fullscreen mode Exit fullscreen mode

Avoid assuming one universal replacement percentage works across every EVM network.


Retry policy

Retries should be based on failure classification.

For example:

RPC timeout
      ↓
possibly retry
Enter fullscreen mode Exit fullscreen mode

versus:

contract reverted
      ↓
do not blindly retry
Enter fullscreen mode Exit fullscreen mode

A simple classification:

Retryable
---------
temporary RPC failure
transport failure
pending timeout

Not automatically retryable
---------------------------
contract revert
invalid calldata
authorization failure
insufficient funds
Enter fullscreen mode Exit fullscreen mode

The final policy depends on the application.

The important engineering principle is that retry behavior must be intentional.


Replacement transactions

A stuck transaction may need a replacement.

Suppose:

nonce = 200
hash = 0xAAA
status = PENDING
Enter fullscreen mode Exit fullscreen mode

The manager can submit:

nonce = 200
hash = 0xBBB
higher fee
Enter fullscreen mode Exit fullscreen mode

The nonce must stay the same.

That gives:

nonce 200
   |
   +--> 0xAAA
   |
   +--> 0xBBB
   |
   +--> final result
Enter fullscreen mode Exit fullscreen mode

The database should preserve the whole attempt history.


Attempt history

Instead of replacing one hash with another, keep each attempt.

For example:

transaction
-----------
id: tx-123
nonce: 200
currentHash: 0xCCC

attempts
--------
1 → 0xAAA
2 → 0xBBB
3 → 0xCCC
Enter fullscreen mode Exit fullscreen mode

This is useful for debugging.

It also prevents the database from losing important historical information.


Detecting unknown nonce consumption

One subtle case is:

Original:
nonce = 200
hash = 0xAAA

Later:
account nonce advanced
Enter fullscreen mode Exit fullscreen mode

That does not automatically prove:

0xAAA was replaced by 0xBBB
Enter fullscreen mode Exit fullscreen mode

Another transaction may have used nonce 200.

The manager should therefore be conservative:

known replacement
      ↓
REPLACED
Enter fullscreen mode Exit fullscreen mode

but:

unknown nonce consumption
      ↓
RECONCILIATION_REQUIRED
Enter fullscreen mode Exit fullscreen mode

That prevents the application from creating false certainty.


Monitoring pending transactions

The monitor periodically checks active transactions:

+--------------------+
| Transaction Monitor|
+---------+----------+
          |
          +--> TX A
          +--> TX B
          +--> TX C
          +--> TX D
Enter fullscreen mode Exit fullscreen mode

For each transaction:

receipt exists?
     |
     +-- no
     |    |
     |    +--> still pending
     |    +--> timeout policy
     |
     +-- yes
          |
          +--> success
          |
          +--> reverted
Enter fullscreen mode Exit fullscreen mode

Use a configurable polling interval.

Avoid creating a permanent uncontrolled timer for every transaction.


Confirmation tracking

A transaction may be mined but still require additional confirmations.

For example:

Receipt
  ↓
1 confirmation
  ↓
2 confirmations
  ↓
3 confirmations
  ↓
Application accepts final state
Enter fullscreen mode Exit fullscreen mode

Make the required confirmation count configurable.

Persist useful blockchain metadata:

blockNumber
blockHash
transactionIndex
confirmationCount
Enter fullscreen mode Exit fullscreen mode

Reconciliation

Receipt monitoring answers:

Did the transaction get mined?

Reconciliation answers:

What actually happened?

The reconciler can inspect:

transaction
receipt
status
logs
nonce
block
Enter fullscreen mode Exit fullscreen mode

and then update application state.

The lifecycle becomes:

SUBMITTED
    ↓
PENDING
    ↓
CONFIRMED
    ↓
RECONCILED
Enter fullscreen mode Exit fullscreen mode

That last step is important for trading systems because the requested action and the actual blockchain result can differ.


Process restart

Consider:

09:31:00 transaction submitted
09:31:01 process crashes
09:31:03 transaction mined
09:31:10 service restarts
Enter fullscreen mode Exit fullscreen mode

A memory-only application loses context.

A persistent transaction manager can recover:

SQLite
  ↓
find active transactions
  ↓
fetch blockchain state
  ↓
reconcile
  ↓
restore correct status
Enter fullscreen mode Exit fullscreen mode

This is one of the main reasons I store transaction state rather than keeping it only in memory.


SQLite persistence

For a small reusable reference implementation, SQLite is enough.

The main table can contain:

transactions
------------
id
chain_id
account
nonce
to_address
data
value
current_hash
status
attempt_count
created_at
submitted_at
confirmed_at
reconciled_at
block_number
block_hash
error_code
error_message
Enter fullscreen mode Exit fullscreen mode

A second table tracks attempts:

transaction_attempts
--------------------
id
transaction_id
hash
nonce
attempt_number
submitted_at
max_fee_per_gas
max_priority_fee_per_gas
status
Enter fullscreen mode Exit fullscreen mode

Keep SQL inside a repository layer.

Do not spread SQL statements throughout the transaction manager.


Repository interface

For example:

export interface TransactionRepository {
  create(
    transaction: TransactionRecord
  ): Promise<void>;

  update(
    id: string,
    patch: Partial<TransactionRecord>
  ): Promise<void>;

  findById(
    id: string
  ): Promise<TransactionRecord | null>;

  findByHash(
    hash: `0x${string}`
  ): Promise<TransactionRecord | null>;

  findPending(): Promise<TransactionRecord[]>;

  findByAccountAndNonce(
    account: `0x${string}`,
    nonce: number
  ): Promise<TransactionRecord[]>;

  addAttempt(
    attempt: TransactionAttempt
  ): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

That allows the domain logic to remain independent of SQLite.


Error handling

Transaction infrastructure benefits from typed errors.

Examples:

NonceAllocationError
NonceMismatchError
SimulationError
SubmissionError
TransactionRevertedError
ReplacementError
ReconciliationError
ConfigurationError
Enter fullscreen mode Exit fullscreen mode

Instead of:

catch (error) {
  throw new Error("something went wrong");
}
Enter fullscreen mode Exit fullscreen mode

preserve useful information.

For example:

throw new SimulationError({
  transactionId,
  cause: error,
});
Enter fullscreen mode Exit fullscreen mode

The application can then decide whether the error is retryable.


Security boundaries

The transaction manager should never become a secret-management system.

Do not:

  • store private keys in SQLite
  • log private keys
  • log seed phrases
  • include .env in Git
  • print credentials during debugging

The wallet/account layer should be injectable.

That makes local tests easier and avoids coupling the system to one key-management approach.


Testing strategy

I split testing into two layers.

Foundry
  ↓
Solidity / local contracts

Vitest
  ↓
TypeScript transaction infrastructure
Enter fullscreen mode Exit fullscreen mode

That keeps the test responsibilities clear.


Unit tests

The TypeScript test suite should cover:

Nonce Manager
----------------
initial nonce
sequential allocation
concurrent allocation
reconciliation


State Machine
----------------
valid transition
invalid transition
terminal state


Retry Policy
----------------
retryable failure
non-retryable failure
maximum attempts


Replacement
----------------
same nonce
higher fee
attempt history


Persistence
----------------
create
update
reload
pending lookup
attempt lookup
Enter fullscreen mode Exit fullscreen mode

Failure-path tests

The failure paths are particularly valuable.

Test:

RPC unavailable
RPC timeout
simulation revert
submission error
duplicate nonce
stale nonce
pending timeout
replacement
reverted receipt
missing receipt
process restart
unknown nonce consumption
Enter fullscreen mode Exit fullscreen mode

A transaction manager that only tests:

send → success
Enter fullscreen mode Exit fullscreen mode

does not demonstrate much about reliability.


Foundry integration contracts

For local integration testing, create simple contracts.

For example:

contract MockTarget {
    uint256 public executions;

    function execute() external {
        executions++;
    }
}
Enter fullscreen mode Exit fullscreen mode

And a reverting contract:

contract MockRevertingTarget {
    function execute() external pure {
        revert("MOCK_REVERT");
    }
}
Enter fullscreen mode Exit fullscreen mode

These give the TypeScript integration suite deterministic targets.


End-to-end flow

A complete successful test should look like:

Application
    ↓
Transaction Manager
    ↓
Nonce Allocation
    ↓
Simulation
    ↓
Transaction Submission
    ↓
Transaction Hash
    ↓
Persistence
    ↓
Receipt Monitoring
    ↓
Confirmation
    ↓
Reconciliation
    ↓
RECONCILED
Enter fullscreen mode Exit fullscreen mode

And the failure path:

Application
    ↓
Transaction Manager
    ↓
Submission
    ↓
PENDING
    ↓
Receipt
    ↓
REVERTED
Enter fullscreen mode Exit fullscreen mode

No blind retry.


Using the manager from a trading system

The advantage of this architecture is that the strategy code stays simple.

A Pons strategy could produce:

const request = buildTradeRequest(signal);
Enter fullscreen mode Exit fullscreen mode

An arbitrage strategy could produce:

const request = buildArbitrageRequest(opportunity);
Enter fullscreen mode Exit fullscreen mode

A copy-trading strategy could produce:

const request = buildCopyTradeRequest(trade);
Enter fullscreen mode Exit fullscreen mode

All three can submit through the same transaction manager:

const result = await transactionManager.submit(request);
Enter fullscreen mode Exit fullscreen mode

The infrastructure is reusable.


Example application architecture

A larger trading product can look like:

+-----------------------+
| Trading UI            |
+-----------+-----------+
            |
            v
+-----------------------+
| API                   |
+-----------+-----------+
            |
            v
+-----------------------+
| Strategy / Risk       |
+-----------+-----------+
            |
            v
+-----------------------+
| EVM Transaction       |
| Manager               |
+-----------+-----------+
            |
            v
+-----------------------+
| Blockchain            |
+-----------+-----------+
            |
            v
+-----------------------+
| Indexer / Reconciler  |
+-----------------------+
Enter fullscreen mode Exit fullscreen mode

This is the architecture I prefer over putting transaction logic directly inside every strategy.


Why this matters for Pons and Stock Token systems

The transaction manager does not need to understand one specific protocol.

That is a feature.

For example:

Pons Bot
    |
    v
Execution Request
    |
    v
EVM Transaction Manager
    |
    v
Robinhood Chain
Enter fullscreen mode Exit fullscreen mode

Or:

Stock Token Strategy
    |
    v
Execution Request
    |
    v
EVM Transaction Manager
    |
    v
EVM Protocol
Enter fullscreen mode Exit fullscreen mode

Or:

Arbitrage Strategy
    |
    v
Execution Request
    |
    v
EVM Transaction Manager
Enter fullscreen mode Exit fullscreen mode

The same lifecycle infrastructure can serve all three.


What I am trying to prove

This repository is not primarily about sending an EVM transaction.

It demonstrates the engineering around that transaction:

Solidity / EVM
      +
TypeScript
      +
Nonce Management
      +
Persistence
      +
Retries
      +
Replacement
      +
Receipt Monitoring
      +
Reconciliation
      +
Failure Handling
Enter fullscreen mode Exit fullscreen mode

That is the part of automated trading infrastructure that often determines whether a prototype can evolve into a usable application.


Repository

The project is:

evm-transaction-manager

The repository is intended as a reusable reference implementation rather than a claim of audited or production-certified infrastructure.

The README documents:

  • architecture
  • state transitions
  • nonce management
  • replacement logic
  • reconciliation
  • local setup
  • testing
  • failure scenarios

The code is designed so another EVM application can use the transaction manager without embedding transaction lifecycle logic inside its trading strategy.


Final architecture

                 Trading Strategy
                       |
                       v
                 Risk / Validation
                       |
                       v
             +----------------------+
             | EVM Transaction      |
             | Manager              |
             +----------------------+
             | Nonce Management     |
             | Persistence          |
             | Simulation           |
             | Submission           |
             | Monitoring           |
             | Retry Policy         |
             | Replacement          |
             | Reconciliation       |
             +----------+-----------+
                        |
                        v
                     EVM RPC
                        |
                        v
                   Blockchain
                        |
                        v
                Receipt / Events
                        |
                        v
                 Reconciliation
                        |
                        v
                Position / PnL
Enter fullscreen mode Exit fullscreen mode

The strategy determines what transaction should be attempted.

The transaction manager determines how that transaction is submitted, tracked, recovered, and reconciled.

That separation is the foundation for building reliable automated EVM trading systems.

Top comments (0)