DEV Community

Cover image for Testing Solidity Trading Contracts with Foundry: Unit, Fork, and Failure-Path Testing
hamssog
hamssog

Posted on Originally published at hamssog.substack.com

Testing Solidity Trading Contracts with Foundry: Unit, Fork, and Failure-Path Testing

A Solidity contract can compile successfully and still be the wrong contract to put behind a trading system.

For trading infrastructure, I care about a different question:

What happens when execution goes wrong?

A useful test suite should prove more than this:

valid input
    ↓
trade succeeds
Enter fullscreen mode Exit fullscreen mode

It should also prove:

unauthorized caller
    ↓
revert

unsupported router
    ↓
revert

invalid token
    ↓
revert

bad slippage
    ↓
revert

expired transaction
    ↓
revert

external protocol failure
    ↓
revert

valid transaction
    ↓
execute
    ↓
emit event
    ↓
backend reconciles
Enter fullscreen mode Exit fullscreen mode

That is the approach I use when thinking about Solidity and EVM engineering for trading systems.

Robinhood Chain is EVM-compatible, so standard Solidity development and Foundry testing workflows apply to contracts deployed there. The official Robinhood Chain documentation also recommends testing on testnet before deploying to mainnet.


Why testing matters more for trading contracts

A normal application can sometimes recover from a bug with a new deployment.

A smart contract may control assets directly.

That changes the engineering priorities.

For a trading contract, I want explicit guarantees around:

Access
Authorization
Assets
Routers
Amounts
Slippage
Deadlines
State
Events
External Calls
Enter fullscreen mode Exit fullscreen mode

The contract should fail predictably when an assumption is violated.

The test suite should prove those assumptions.


The testing pyramid

I generally think about contract testing in layers:

                 Production
                     ↑
                  Testnet
                     ↑
                 Fork Tests
                     ↑
              Integration Tests
                     ↑
            Invariant / Fuzz Tests
                     ↑
                  Unit Tests
Enter fullscreen mode Exit fullscreen mode

Each layer answers a different question.

Unit tests

Does the contract behave correctly for a known case?

Fuzz tests

Does it behave correctly across many unexpected inputs?

Invariant tests

Does an important property remain true after many actions?

Integration tests

Does the contract interact correctly with the surrounding protocol?

Fork tests

Does it behave correctly against realistic chain state?

Testnet

Does the deployed artifact behave correctly in a real network environment?


Example: a trading executor

Consider a simplified execution contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function approve(
        address spender,
        uint256 amount
    ) external returns (bool);

    function balanceOf(
        address account
    ) external view returns (uint256);
}

interface IRouter {
    function swap(
        address tokenIn,
        address tokenOut,
        uint256 amountIn,
        uint256 minAmountOut,
        address recipient
    ) external returns (uint256 amountOut);
}

contract TradingExecutor {
    address public immutable owner;

    mapping(address => bool) public allowedRouter;
    mapping(address => bool) public allowedToken;

    bool public paused;

    error Unauthorized();
    error Paused();
    error RouterNotAllowed();
    error TokenNotAllowed();
    error InvalidAmount();
    error SlippageExceeded();
    error DeadlineExpired();

    event RouterUpdated(
        address indexed router,
        bool allowed
    );

    event TokenUpdated(
        address indexed token,
        bool allowed
    );

    event TradeExecuted(
        bytes32 indexed operationId,
        address indexed router,
        address indexed tokenIn,
        address tokenOut,
        uint256 amountIn,
        uint256 amountOut
    );

    constructor(address initialOwner) {
        owner = initialOwner;
    }

    modifier onlyOwner() {
        if (msg.sender != owner) {
            revert Unauthorized();
        }

        _;
    }

    function setRouter(
        address router,
        bool allowed
    ) external onlyOwner {
        allowedRouter[router] = allowed;

        emit RouterUpdated(
            router,
            allowed
        );
    }

    function setToken(
        address token,
        bool allowed
    ) external onlyOwner {
        allowedToken[token] = allowed;

        emit TokenUpdated(
            token,
            allowed
        );
    }

    function executeTrade(
        bytes32 operationId,
        address router,
        address tokenIn,
        address tokenOut,
        uint256 amountIn,
        uint256 minAmountOut,
        uint256 deadline,
        address recipient
    ) external onlyOwner returns (uint256 amountOut) {
        if (paused) {
            revert Paused();
        }

        if (!allowedRouter[router]) {
            revert RouterNotAllowed();
        }

        if (!allowedToken[tokenIn]) {
            revert TokenNotAllowed();
        }

        if (!allowedToken[tokenOut]) {
            revert TokenNotAllowed();
        }

        if (amountIn == 0) {
            revert InvalidAmount();
        }

        if (block.timestamp > deadline) {
            revert DeadlineExpired();
        }

        amountOut = IRouter(router).swap(
            tokenIn,
            tokenOut,
            amountIn,
            minAmountOut,
            recipient
        );

        if (amountOut < minAmountOut) {
            revert SlippageExceeded();
        }

        emit TradeExecuted(
            operationId,
            router,
            tokenIn,
            tokenOut,
            amountIn,
            amountOut
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally simplified.

The important part is the number of assumptions it exposes.

That gives us something concrete to test.


1. Test the happy path

The first test should prove that the intended operation works.

function testExecuteTrade() public {
    mockRouter.setAmountOut(100 ether);

    vm.prank(owner);

    uint256 amountOut = executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        100 ether,
        95 ether,
        block.timestamp + 60,
        recipient
    );

    assertEq(amountOut, 100 ether);
}
Enter fullscreen mode Exit fullscreen mode

This proves:

authorized caller
+
allowed router
+
allowed tokens
+
valid amount
+
valid deadline
+
acceptable output
=
successful execution
Enter fullscreen mode Exit fullscreen mode

But that is only one path.


2. Unauthorized caller

The next test should prove that another address cannot execute the contract.

function testRejectsUnauthorizedCaller() public {
    vm.prank(attacker);

    vm.expectRevert(
        TradingExecutor.Unauthorized.selector
    );

    executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        100 ether,
        95 ether,
        block.timestamp + 60,
        recipient
    );
}
Enter fullscreen mode Exit fullscreen mode

The property is simple:

attacker
   ↓
executeTrade()
   ↓
REVERT
Enter fullscreen mode Exit fullscreen mode

This is one of the first tests I would expect to see in a serious trading contract.


3. Wrong router

The execution contract should not call arbitrary addresses.

function testRejectsUnknownRouter() public {
    address unknownRouter =
        address(0x1234);

    vm.prank(owner);

    vm.expectRevert(
        TradingExecutor.RouterNotAllowed.selector
    );

    executor.executeTrade(
        OPERATION_ID,
        unknownRouter,
        address(tokenIn),
        address(tokenOut),
        100 ether,
        95 ether,
        block.timestamp + 60,
        recipient
    );
}
Enter fullscreen mode Exit fullscreen mode

Now the contract guarantees:

configured router
    ↓
allowed

unknown router
    ↓
rejected
Enter fullscreen mode Exit fullscreen mode

That is a much stronger security boundary than relying on the frontend to select the correct router.


4. Unsupported token

Do the same for assets.

function testRejectsUnsupportedToken() public {
    address unsupportedToken =
        address(0x5678);

    vm.prank(owner);

    vm.expectRevert(
        TradingExecutor.TokenNotAllowed.selector
    );

    executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        unsupportedToken,
        address(tokenOut),
        100 ether,
        95 ether,
        block.timestamp + 60,
        recipient
    );
}
Enter fullscreen mode Exit fullscreen mode

This protects against accidental or malicious execution using an unexpected asset.


5. Zero amount

Never assume callers will provide sensible amounts.

function testRejectsZeroAmount() public {
    vm.prank(owner);

    vm.expectRevert(
        TradingExecutor.InvalidAmount.selector
    );

    executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        0,
        0,
        block.timestamp + 60,
        recipient
    );
}
Enter fullscreen mode Exit fullscreen mode

This looks trivial.

It is still worth testing explicitly.

The goal is to make the contract's assumptions visible.


6. Slippage failure

Trading systems need an execution boundary.

Suppose the application expects:

Expected output: 100
Minimum output: 95
Actual output:   90
Enter fullscreen mode Exit fullscreen mode

The trade must fail.

function testRejectsBadSlippage() public {
    mockRouter.setAmountOut(90 ether);

    vm.prank(owner);

    vm.expectRevert(
        TradingExecutor.SlippageExceeded.selector
    );

    executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        100 ether,
        95 ether,
        block.timestamp + 60,
        recipient
    );
}
Enter fullscreen mode Exit fullscreen mode

The property is:

amountOut < minAmountOut
        ↓
REVERT
Enter fullscreen mode Exit fullscreen mode

This is far more meaningful than simply testing:

swap works
Enter fullscreen mode Exit fullscreen mode

7. Deadline failure

A quote can become stale.

So test expired execution:

function testRejectsExpiredTrade() public {
    uint256 deadline =
        block.timestamp + 10;

    vm.warp(block.timestamp + 11);

    vm.prank(owner);

    vm.expectRevert(
        TradingExecutor.DeadlineExpired.selector
    );

    executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        100 ether,
        95 ether,
        deadline,
        recipient
    );
}
Enter fullscreen mode Exit fullscreen mode

Foundry's cheatcodes make time-dependent testing deterministic.

The test does not need to wait eleven real seconds.


8. Paused execution

If the contract has an emergency control, test it.

function testRejectsPausedExecution() public {
    vm.prank(owner);

    executor.setPaused(true);

    vm.prank(owner);

    vm.expectRevert(
        TradingExecutor.Paused.selector
    );

    executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        100 ether,
        95 ether,
        block.timestamp + 60,
        recipient
    );
}
Enter fullscreen mode Exit fullscreen mode

The desired property is:

paused = true
      ↓
no new execution
Enter fullscreen mode Exit fullscreen mode

An emergency control that isn't tested is not much of an emergency control.


9. Event testing

A contract should make important state transitions observable.

For example:

event TradeExecuted(
    bytes32 indexed operationId,
    address indexed router,
    address indexed tokenIn,
    address tokenOut,
    uint256 amountIn,
    uint256 amountOut
);
Enter fullscreen mode Exit fullscreen mode

Then test the event directly:

function testEmitsTradeExecuted() public {
    mockRouter.setAmountOut(100 ether);

    vm.expectEmit(
        true,
        true,
        true,
        true
    );

    emit TradeExecuted(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        100 ether,
        100 ether
    );

    vm.prank(owner);

    executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        100 ether,
        95 ether,
        block.timestamp + 60,
        recipient
    );
}
Enter fullscreen mode Exit fullscreen mode

This matters because the backend may depend on this event.

The architecture becomes:

Solidity
   ↓
TradeExecuted
   ↓
viem
   ↓
TypeScript
   ↓
Trade State
Enter fullscreen mode Exit fullscreen mode

10. Fuzz testing

Fixed test values cover known cases.

Fuzzing explores a much larger input space.

For example:

function testFuzzRejectsZeroAmount(
    uint256 amountIn
) public {
    vm.assume(amountIn == 0);

    vm.prank(owner);

    vm.expectRevert(
        TradingExecutor.InvalidAmount.selector
    );

    executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        amountIn,
        0,
        block.timestamp + 60,
        recipient
    );
}
Enter fullscreen mode Exit fullscreen mode

The useful part of fuzzing is not merely generating random numbers.

It is expressing a property:

for every input satisfying condition X,
property Y must remain true
Enter fullscreen mode Exit fullscreen mode

For trading contracts, useful fuzzing targets include:

amounts
minimum outputs
deadlines
addresses
limits
configuration boundaries
Enter fullscreen mode Exit fullscreen mode

Foundry provides fuzz testing as part of its Solidity testing workflow.


11. Invariant testing

Fuzzing tests inputs.

Invariant testing tests system properties across sequences of actions.

For example:

paused
   ↓
no trade execution
Enter fullscreen mode Exit fullscreen mode

or:

unauthorized user
   ↓
cannot modify router configuration
Enter fullscreen mode Exit fullscreen mode

or:

unsupported router
   ↓
never becomes executable
Enter fullscreen mode Exit fullscreen mode

These are stronger than a single test case because they describe what should always remain true.

For trading infrastructure, I would define invariants before writing a large amount of contract code.


12. Integration testing

Mocks are useful.

They are not enough.

Suppose the executor calls:

Router
   ↓
Token
   ↓
Trade
Enter fullscreen mode Exit fullscreen mode

A perfect mock may hide:

allowance problems
different revert behavior
unexpected return data
event differences
real token decimals
Enter fullscreen mode Exit fullscreen mode

Integration tests should therefore validate contract boundaries.

For example:

TradingExecutor
      ↓
Router Mock
      ↓
ERC-20 Mock
      ↓
Assertions
Enter fullscreen mode Exit fullscreen mode

The test should prove the contracts behave correctly together.


13. External protocol failures

One of the most important tests is:

Your contract
      ↓
External protocol
      ↓
REVERT
Enter fullscreen mode Exit fullscreen mode

The expected result is:

external failure
      ↓
transaction reverts
      ↓
no partial state
Enter fullscreen mode Exit fullscreen mode

A mock router can simulate this.

function testProtocolFailureReverts() public {
    mockRouter.setShouldRevert(true);

    vm.prank(owner);

    vm.expectRevert();

    executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        100 ether,
        95 ether,
        block.timestamp + 60,
        recipient
    );
}
Enter fullscreen mode Exit fullscreen mode

This is one of the first failure paths I would add to a trading contract.


14. State rollback

Suppose a transaction performs several operations:

update state
   ↓
transfer
   ↓
external call
   ↓
failure
Enter fullscreen mode Exit fullscreen mode

Ethereum transaction atomicity means a reverted transaction should not leave those earlier state changes committed.

A useful test should verify the state before and after.

function testFailedExecutionDoesNotChangeState() public {
    uint256 beforeValue =
        executor.someState();

    mockRouter.setShouldRevert(true);

    vm.prank(owner);

    vm.expectRevert();

    executor.executeTrade(
        OPERATION_ID,
        address(mockRouter),
        address(tokenIn),
        address(tokenOut),
        100 ether,
        95 ether,
        block.timestamp + 60,
        recipient
    );

    uint256 afterValue =
        executor.someState();

    assertEq(
        afterValue,
        beforeValue
    );
}
Enter fullscreen mode Exit fullscreen mode

This verifies a property rather than only checking the revert itself.


15. Fork testing

This is where the test becomes much more realistic.

Instead of:

Your Contract
   ↓
Mock Protocol
Enter fullscreen mode Exit fullscreen mode

the environment becomes:

Robinhood Chain State
       ↓
Fork
       ↓
Your Contract
       ↓
Real Protocol Contracts
Enter fullscreen mode Exit fullscreen mode

Foundry supports fork testing against live chain state.

This is useful for testing:

real token behavior
real router behavior
real balances
real allowances
real contract state
real event behavior
Enter fullscreen mode Exit fullscreen mode

without sending a production transaction.


16. Fork testing a trading path

A simplified example:

function testForkedExecution() public {
    vm.createSelectFork(
        vm.envString("RH_RPC_URL")
    );

    // Load actual deployed contracts.
    // Configure test accounts.
    // Provide test balances.
    // Execute the real protocol path.
    // Verify balances and events.
}
Enter fullscreen mode Exit fullscreen mode

The RPC URL should come from environment configuration:

RH_RPC_URL=...
Enter fullscreen mode Exit fullscreen mode

Never commit:

private keys
API keys
RPC credentials
Enter fullscreen mode Exit fullscreen mode

to the repository.

Robinhood's current deployment guidance also explicitly warns against committing real private keys and recommends testnet-first deployment.


17. Pons integration testing

The same methodology applies to Pons.

Pons V2 has a distinct bonding-curve lifecycle:

Launch
   ↓
Curve
   ↓
Trading
   ↓
Graduation
   ↓
Uniswap V4
Enter fullscreen mode Exit fullscreen mode

Its documented event model includes launch and curve-trading events that can be used for integration and indexing workflows.

A Pons integration test should therefore validate:

launch detection
curve state
quote inputs
trade event
actual tokens out
market transition
graduation state
Enter fullscreen mode Exit fullscreen mode

The test should use the current verified ABI, not a hand-created approximation.


18. Testing the scanner and contract together

This is where the smart-contract and TypeScript layers connect.

A complete test might look like:

Pons Contract
      ↓
Event
      ↓
TypeScript Decoder
      ↓
Normalized Trade
      ↓
Database
      ↓
Position
Enter fullscreen mode Exit fullscreen mode

For example:

CurveBuy
   ↓
decode
   ↓
quoteIn
tokensOut
fee
tax
   ↓
Trade Record
Enter fullscreen mode Exit fullscreen mode

That prevents a common mistake:

requested amount
≠
actual execution amount
Enter fullscreen mode Exit fullscreen mode

The backend should consume the actual onchain result.


19. Transaction-state testing

A trading system also needs tests outside Solidity.

For example:

CREATED
   ↓
SIGNED
   ↓
SUBMITTED
   ↓
PENDING
   ↓
CONFIRMED
Enter fullscreen mode Exit fullscreen mode

and:

SUBMITTED
   ↓
RPC TIMEOUT
   ↓
UNKNOWN
   ↓
RECONCILIATION
Enter fullscreen mode Exit fullscreen mode

The important test is:

RPC timeout
   ↓
do NOT blindly submit another transaction
Enter fullscreen mode Exit fullscreen mode

Instead:

lookup transaction
   ↓
check receipt
   ↓
check events
   ↓
update state
Enter fullscreen mode Exit fullscreen mode

This is where EVM testing becomes trading-infrastructure testing.


20. Idempotency testing

Suppose the same event is delivered twice:

TradeExecuted
TradeExecuted
Enter fullscreen mode Exit fullscreen mode

The database should still contain one logical event.

A good test is:

process event
   ↓
process same event again
   ↓
no duplicate record
Enter fullscreen mode Exit fullscreen mode

Use:

chainId
+
transactionHash
+
logIndex
Enter fullscreen mode Exit fullscreen mode

as the event identity.

This is especially important for applications that use:

WebSocket listeners
backfills
RPC retries
reconnects
Enter fullscreen mode Exit fullscreen mode

21. Testing the reconciliation layer

Suppose the application believes:

BUY 100
Enter fullscreen mode Exit fullscreen mode

but the onchain event says:

tokensOut = 63
Enter fullscreen mode Exit fullscreen mode

The reconciliation test should prove that the final position is:

63
Enter fullscreen mode Exit fullscreen mode

not:

100
Enter fullscreen mode Exit fullscreen mode

The complete flow becomes:

Transaction
    ↓
Receipt
    ↓
Events
    ↓
Actual Fill
    ↓
Position
    ↓
Database Reconciliation
Enter fullscreen mode Exit fullscreen mode

This is one of the strongest places to demonstrate that the project understands real trading infrastructure.


22. A practical test matrix

For a trading executor, I would start with:

Area Test
Authorization Valid caller
Authorization Invalid caller
Router Allowed router
Router Unknown router
Token Allowed token
Token Unknown token
Amount Zero amount
Slippage Valid output
Slippage Output below minimum
Deadline Valid deadline
Deadline Expired deadline
Pause Trading enabled
Pause Trading paused
Protocol Successful call
Protocol Reverted call
Events Correct event
State No partial state
Fuzzing Random boundary inputs
Invariants Security properties
Fork Real protocol state
Integration TypeScript decoding
Reconciliation Correct final position

That test matrix tells a much more convincing story than simply saying:

"The contract has tests."


23. Recommended Foundry project structure

For a contract project:

contracts/
├── src/
│   ├── executors/
│   ├── adapters/
│   ├── interfaces/
│   └── libraries/
│
├── test/
│   ├── unit/
│   ├── fuzz/
│   ├── invariant/
│   ├── integration/
│   └── fork/
│
├── script/
│   └── Deploy.s.sol
│
└── foundry.toml
Enter fullscreen mode Exit fullscreen mode

Then keep the TypeScript application separately:

src/
├── strategy/
├── risk/
├── execution/
├── monitoring/
├── portfolio/
└── reconciliation/
Enter fullscreen mode Exit fullscreen mode

The architecture becomes:

Solidity
   ↓
  EVM
   ↓
Robinhood Chain
   ↓
TypeScript
   ↓
Trading Infrastructure
Enter fullscreen mode Exit fullscreen mode

24. What I want the test suite to prove

A strong repository should make these properties obvious:

Only authorized accounts can execute.

Unsupported routers are rejected.

Unsupported tokens are rejected.

Invalid amounts are rejected.

Bad slippage is rejected.

Expired transactions are rejected.

Paused execution is blocked.

External failures revert cleanly.

Important state transitions emit events.

Events can be decoded reliably.

Duplicate events do not create duplicate state.

Actual execution results become the canonical position state.
Enter fullscreen mode Exit fullscreen mode

Those are engineering properties.

They are much more valuable than a list of technologies.


25. From contract testing to client work

This is also why I am adding Solidity/EVM work to my Robinhood Chain development portfolio.

A client may need only:

Solidity contract
Enter fullscreen mode Exit fullscreen mode

Another may need:

Solidity
+
TypeScript
+
EVM integration
Enter fullscreen mode Exit fullscreen mode

Another may need:

Smart Contract
+
Trading Engine
+
Risk
+
Wallets
+
Database
+
Dashboard
Enter fullscreen mode Exit fullscreen mode

The testing approach scales with the product.


26. Public implementation direction

My current Robinhood Chain work already covers the application side:

Pons Sniper Bot

Pons Bundler

Pons Token Scanner

Pons Trading Terminal

Stock Token Trading Bot

Stock Token Arbitrage Bot

The Solidity/EVM layer adds another dimension:

Smart Contracts
       ↓
Protocol Integration
       ↓
Trading Engine
       ↓
Execution
       ↓
Reconciliation
Enter fullscreen mode Exit fullscreen mode

That makes the portfolio demonstrate more than frontend or TypeScript development.


Final Thoughts

For a trading contract, the most useful question isn't:

"Does the swap work?"

It is:

Can the wrong person execute it?

Can the wrong asset be used?

Can the wrong router be called?

What happens when the output is too low?

What happens when the transaction expires?

What happens when the external protocol reverts?

What happens when the RPC disappears?

What does the backend record?

What is the actual position afterward?
Enter fullscreen mode Exit fullscreen mode

That is why I treat testing as part of the architecture.

The workflow is:

Solidity
   ↓
Unit Tests
   ↓
Fuzz / Invariants
   ↓
Integration
   ↓
Fork
   ↓
TypeScript
   ↓
Testnet
   ↓
Production
Enter fullscreen mode Exit fullscreen mode

The objective is not simply to make a contract compile.

The objective is to make the contract, EVM integration, trading engine, and state-management system behave predictably under both normal and failure conditions.

That is the level of engineering I am targeting with my Robinhood Chain and Pons work.

Need Solidity / EVM trading infrastructure?

I build Solidity contracts, EVM integrations, trading executors, Pons integrations, Stock Token systems, TypeScript trading engines, and full-stack trading applications, with testing and failure handling designed into the development process.

Top comments (0)