This repository demonstrates a reusable Solidity/EVM execution layer for automated trading applications.
It focuses on constrained execution, access control, slippage protection, deadline validation, event-driven state, transaction-state management, and reconciliation.
Trading applications often mix strategy, risk, and execution in one place. That makes the onchain boundary hard to audit. This project isolates the execution layer: a caller submits a trade that has already been chosen, and the contract enforces who may trade, which routers and tokens are allowed, and which output and deadline bounds must hold.
An execution contract that can call any address with arbitrary calldata is the wrong tool under a trading bot. This executor exposes one trade function. It pulls a known token, approves one allowlisted router for that call, checks what the recipient actually received, emits a structured event, and clears the allowance before returning.
The TypeScript side treats that event as the canonical fill. A quote is not assumed to equal the amount that landed.
Strategy
↓
Risk Engine
↓
Trade Intent
↓
TypeScript / viem
↓
Solidity TradingExecutor
↓
Approved Router
↓
EVM
↓
TradeExecuted
↓
Event Decoder
↓
Reconciliation
Strategy code is intentionally absent. src/risk/engine.ts accepts or rejects an intent before anything is signed. src/execution/executor.ts encodes, submits, and tracks that intent. The contract does not know why the trade was requested.
contracts/src/TradingExecutor.sol is a non-upgradeable executor.
Roles:
ADMINconfigures routers, tokens, pause, and the executor address. Admin transfer is two-step.EXECUTORmay callexecuteTrade. That role cannot change allowlists, pause state, or its own authorization.
executeTrade checks, in order: caller, pause, reentrancy, operation id, router allowlist, token allowlist, amount, identical tokens, recipient, and deadline. It then pulls tokenIn from the caller, swaps through ITradingRouter, and pays recipient.
ITradingRouter.swap is a project interface used by the tests and by future adapters. It is not a universal DEX interface.
Approval model:
Token
↓
Approved Router
↓
Specific Execution
The authorized caller approves the executor for amountIn. The executor approves the allowlisted router for that same amount, calls swap, then sets the router allowance back to zero. If the token rejects a non-zero allowance overwrite, the executor resets to zero and retries, following the OpenZeppelin SafeERC20.forceApprove sequence. Tokens that deliver a different amount than requested are rejected. The recipient's tokenOut balance increase is the canonical output. It must match the router's report and be at least minAmountOut.
A successful trade consumes its operationId. A reverted trade does not, so the same id can be retried after a revert. The TypeScript client still does not resubmit automatically.
Authorized Caller
↓
Trade Validation
↓
Allowed Router
↓
Allowed Token
↓
Amount Validation
↓
Slippage Protection
↓
Deadline Validation
↓
Execution
↓
Trade Event
Offchain, an operation id is keccak256(abi.encodePacked("trade", wallet, sequence)). That id is the join key from the intent to TradeExecuted.
- Only the executor address can trade.
- Only the admin can change configuration.
- Unknown routers and tokens revert.
amountInandminAmountOutmust be non-zero.block.timestampmust be less than or equal todeadline.- Output is measured on the recipient, not taken from an untrusted report alone.
- There is no
call(address target, bytes data)for arbitrary targets. - The contract does not custody idle inventory: leftovers of
tokenInare returned to the caller in the same transaction. - Reentrancy into
executeTradereverts.
The allowlisted router is trusted to pull up to the approved input for that call. The admin is trusted to maintain the allowlists. Those are explicit trust assumptions, not hidden ones.
- restricted callers
- router allowlist
- token allowlist
- slippage validation
- deadline validation
- pause control
- explicit external call boundaries
- no arbitrary target execution
- no private key storage
- deterministic tests
This repository is a technical demonstration and should undergo independent security review before controlling meaningful funds.
The Foundry suite is 60 tests: 46 unit, 9 fuzz, 4 invariants in one campaign, 3 integration, and 1 fork-harness test. Vitest adds 24 tests. Foundry covers the contract. Vitest covers the TypeScript state machine, decoder, reconciliation, and the rule that simulated mode does not broadcast.
| Area | Covered |
|---|---|
| Authorization | Yes |
| Router Allowlist | Yes |
| Token Allowlist | Yes |
| Pause | Yes |
| Deadline | Yes |
| Slippage | Yes |
| External Revert | Yes |
| Events | Yes |
| Fuzzing | Yes |
| Invariants | Yes |
| Integration | Yes |
| Fork Framework | Yes |
| Reconciliation | Yes |
Failure paths covered by the Foundry suite:
- unauthorized caller
- wrong router
- wrong token
- zero amount
- expired trade
- bad slippage
- paused
- router revert
- invalid recipient
Also covered: a router report that does not match the delivered balance, reentrancy, ERC-20 calls that omit a boolean, ERC-20 calls that return false, bubbled token reverts, and a non-zero allowance that must be reset before it can be changed.
Unit tests live in contracts/test/unit. They use MockERC20 and MockTradingRouter. Time is moved with vm.warp. No test waits on a real clock.
contracts/test/fuzz/TradingExecutorFuzz.t.sol checks properties, including:
amountIn == 0always revertsminAmountOut == 0always reverts- a deadline before
block.timestampalways reverts amountOut < minAmountOutalways revertsamountOut >= minAmountOutsucceeds for bounded values- a caller other than the executor always reverts
- an unknown router or token always reverts
contracts/test/invariant runs a handler for 64 sequences. After each call:
- while trading is paused, no trade succeeds
- a non-admin cannot change the router allowlist
- a non-admin cannot change the token allowlist
- the executor role cannot replace itself or unpause
contracts/test/integration runs the executor, mock router, and mock tokens together. The tests check balances, the refund when a router pulls less than amountIn, the cleared router allowance, and that a slippage revert leaves balances unchanged.
contracts/test/fork/ForkFramework.t.sol reads FORK_RPC_URL. When it is empty, the test returns without forking. When it is set, the test selects that endpoint and checks that a chain id and block number are available.
No protocol address is hardcoded. A fork test against a live router should be added only after that router's ABI and deployment are verified for the target chain.
The client is built with strict TypeScript, viem, Zod, and Pino.
src/risk/engine.tschecks caller, pause, allowlists, amounts, and deadline.src/execution/executor.tsencodesexecuteTrade, tracks state, waits for a receipt, and decodes the event.src/events/decoder.tsturnsTradeExecutedinto aTradeResult. Amounts are decimal strings.src/reconciliation/reconcile.tscompares the local record, the receipt, and the event.src/persistence/memoryStore.tsis the running store.db/schema.sqlis the PostgreSQL shape for a later adapter. Amounts aretextso they are not stored as floating-point numbers.
EXECUTION_MODE defaults to SIMULATED. submit throws unless the mode is TESTNET. A signing account is injected by the host. This repository does not read a key.
CREATED
↓
SIGNED
↓
SUBMITTED
↓
PENDING
↓
CONFIRMED
Failure before a hash exists:
SIGNED
↓
FAILED
Unknown outcome after broadcast:
SUBMITTED
↓
UNKNOWN
Recovery:
UNKNOWN
↓
RECONCILE
↓
CONFIRMED / FAILED / PENDING
An RPC timeout moves the record to UNKNOWN. The client does not send that operation again. A second submit for the same operation id throws. reconcileSubmitted applies a later receipt without calling send.
The canonical output is TradeExecuted.amountOut.
requested amountIn: 1000
quoted amountOut: 1000
actual amountOut: 970
canonical result: 970
status: SYNCED
Those figures are the simulated demo fixture, not a market fill.
SYNCED means the local record, receipt, and event agree, or a revert is consistently recorded. MISMATCH means they disagree, including a balance change that does not equal amountOut. UNKNOWN means the receipt or the event is missing.
The same log is stored once. The identity is chainId + transactionHash + logIndex.
Install Foundry and Node.js 22 or newer. pnpm is the package manager.
git submodule update --init --recursive
pnpm install
forge build
pnpm typecheck
pnpm test
pnpm build
pnpm lint
pnpm demoFoundry:
forge build
forge test
forge test -vvv
forge coveragepnpm test:integration
pnpm test:forkpnpm test reads out/TradingExecutor.sol/TradingExecutor.json, so run forge build first.
Copy .env.example. The demo does not need a filled environment. Invalid configuration throws.
RPC_URL=
FORK_RPC_URL=
CHAIN_ID=31337
EXECUTOR_ADDRESS=
API_PORT=3000
LOG_LEVEL=info
DATABASE_URL=
EXECUTION_MODE=SIMULATED
ADMIN_ADDRESS and EXECUTOR_CALLER are used by contracts/script/Deploy.s.sol. The deploy script does not read a signer key. Foundry supplies the signer at broadcast time, outside this repository.
DATABASE_URL is accepted and unused. Persistence in this version is in memory.
EXECUTION_MODE=TESTNET also requires RPC_URL and EXECUTOR_ADDRESS. There is no default live-trading path.
pnpm demo does not broadcast. It prints the checks, a fixture execution, and one structured log line:
EVM Trading Executor
--------------------
Authorization: PASS
Router Allowlist: PASS
Token Allowlist: PASS
Slippage Check: PASS
Deadline Check: PASS
Transaction State: SIMULATED
Event Decode: PASS
Reconciliation: PASS
The addresses in that output are fixtures (0x0000…0002 and the same pattern). transactionHash is the label fixture-not-broadcast. The operation id is the keccak of the fixture wallet and sequence 1.
evm-trading-executor/
├── contracts/
│ ├── src/
│ │ ├── TradingExecutor.sol
│ │ ├── errors/TradingErrors.sol
│ │ ├── interfaces/
│ │ └── libraries/
│ ├── script/Deploy.s.sol
│ └── test/
│ ├── unit/
│ ├── fuzz/
│ ├── invariant/
│ ├── integration/
│ ├── fork/
│ ├── mocks/
│ └── utils/
├── src/
│ ├── abi/
│ ├── clients/evm.ts
│ ├── config/env.ts
│ ├── execution/
│ ├── events/decoder.ts
│ ├── logging/logger.ts
│ ├── persistence/memoryStore.ts
│ ├── reconciliation/
│ ├── risk/engine.ts
│ ├── demo.ts
│ ├── demoFixtures.ts
│ └── index.ts
├── test/typescript/
├── test/fixtures/
├── db/schema.sql
├── foundry.toml
├── package.json
├── Dockerfile
└── README.md
The core contract does not reference Robinhood Chain.
Generic EVM Executor
↓
Protocol Adapter
↓
Robinhood Chain
A future adapter would translate a reviewed Robinhood Chain router into ITradingRouter, or sit in front of executeTrade and pass an allowlisted router address. Chain id, RPC, and contract addresses stay in configuration.
Pons is not implemented here.
Pons Adapter
↓
Generic Trade Intent
↓
TradingExecutor
An adapter should be written against the current official ABI for one Pons version. This repository does not mix Pons versions and does not ship a Pons address.
Stock Token Adapter
↓
Trade Intent
↓
Generic Executor
Stock token addresses would be configuration for the token allowlist. They are not hardcoded.
EVM Trading Executor is the generic execution layer. The projects below are separate and specific.
Pons Sniper Bot https://github.com/0xhamssog/pons-sniper-bot
Pons Sniper is a Pons-specific automated strategy.
Pons Bundler https://github.com/0xhamssog/pons-bundler
Pons Bundler is multi-wallet launch execution.
Stock Token Arbitrage Bot https://github.com/0xhamssog/robinhood-stock-token-arbitrage-bot
Stock Token Arbitrage is a Stock Token trading strategy.
This executor can serve as the onchain execution layer for:
- trading bots
- arbitrage systems
- copy-trading systems
- multi-wallet applications
- trading terminals
- Robinhood Chain integrations
- Pons integrations
- Stock Token applications
- custom EVM trading infrastructure
I build custom Solidity/EVM trading infrastructure and connect it to TypeScript backends, risk engines, protocol adapters, and trading applications.
- Add a protocol adapter only after its ABI and deployment are verified.
- Implement the PostgreSQL store described in
db/schema.sqlbehind the same store interface. - Document a testnet runbook for
EXECUTION_MODE=TESTNETafter a deployment exists. - Keep strategy, including arbitrage and copy-trading, outside this contract.