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
And the failure paths matter just as much:
Pending
├── Reverted
├── Timed Out
├── Replaced
├── RPC Failure
└── Unknown Nonce Consumption
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 |
+----------------------+
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,
});
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
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
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";
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;
};
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
Failure paths:
SUBMITTED
├── REVERTED
├── TIMED_OUT
└── REPLACEMENT_PENDING
↓
REPLACED
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: [],
};
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 ──┘
If each service independently requests the pending nonce, they can race.
For example:
Request A → nonce 100
Request B → nonce 100
The transaction manager should allocate nonces centrally:
pending nonce = 100
Request A → 100
Request B → 101
Request C → 102
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>;
}
The implementation should:
- query chain state
- establish the starting nonce
- allocate locally
- serialize concurrent allocation
- 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);
The test should verify:
50 requests
↓
50 unique nonces
and not:
50 requests
↓
same nonce repeated
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
The transaction manager needs reconciliation logic.
A useful mental model is:
Blockchain
↑
|
Local Nonce State
↑
|
Transaction Records
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
A simplified public API:
const result = await manager.submit({
account,
to,
data,
value,
});
Return a logical transaction ID as well as the initial transaction hash:
{
transactionId,
hash
}
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
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;
};
}
The replacement policy can then be configured with values such as:
replacementBumpPercent
minimumFeeBump
maxFeePerGasCap
maxPriorityFeePerGasCap
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
versus:
contract reverted
↓
do not blindly retry
A simple classification:
Retryable
---------
temporary RPC failure
transport failure
pending timeout
Not automatically retryable
---------------------------
contract revert
invalid calldata
authorization failure
insufficient funds
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
The manager can submit:
nonce = 200
hash = 0xBBB
higher fee
The nonce must stay the same.
That gives:
nonce 200
|
+--> 0xAAA
|
+--> 0xBBB
|
+--> final result
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
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
That does not automatically prove:
0xAAA was replaced by 0xBBB
Another transaction may have used nonce 200.
The manager should therefore be conservative:
known replacement
↓
REPLACED
but:
unknown nonce consumption
↓
RECONCILIATION_REQUIRED
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
For each transaction:
receipt exists?
|
+-- no
| |
| +--> still pending
| +--> timeout policy
|
+-- yes
|
+--> success
|
+--> reverted
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
Make the required confirmation count configurable.
Persist useful blockchain metadata:
blockNumber
blockHash
transactionIndex
confirmationCount
Reconciliation
Receipt monitoring answers:
Did the transaction get mined?
Reconciliation answers:
What actually happened?
The reconciler can inspect:
transaction
receipt
status
logs
nonce
block
and then update application state.
The lifecycle becomes:
SUBMITTED
↓
PENDING
↓
CONFIRMED
↓
RECONCILED
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
A memory-only application loses context.
A persistent transaction manager can recover:
SQLite
↓
find active transactions
↓
fetch blockchain state
↓
reconcile
↓
restore correct status
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
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
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>;
}
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
Instead of:
catch (error) {
throw new Error("something went wrong");
}
preserve useful information.
For example:
throw new SimulationError({
transactionId,
cause: error,
});
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
.envin 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
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
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
A transaction manager that only tests:
send → success
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++;
}
}
And a reverting contract:
contract MockRevertingTarget {
function execute() external pure {
revert("MOCK_REVERT");
}
}
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
And the failure path:
Application
↓
Transaction Manager
↓
Submission
↓
PENDING
↓
Receipt
↓
REVERTED
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);
An arbitrage strategy could produce:
const request = buildArbitrageRequest(opportunity);
A copy-trading strategy could produce:
const request = buildCopyTradeRequest(trade);
All three can submit through the same transaction manager:
const result = await transactionManager.submit(request);
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 |
+-----------------------+
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
Or:
Stock Token Strategy
|
v
Execution Request
|
v
EVM Transaction Manager
|
v
EVM Protocol
Or:
Arbitrage Strategy
|
v
Execution Request
|
v
EVM Transaction Manager
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
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
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)