AgentStack
SKILL verified MIT Self-run

Test Foundry

skill-thanasimos-thanas-flare-builders-toolkit-test-foundry · by Thanasimos

Generate a comprehensive Foundry/Forge test suite for a Solidity contract. Produces structured, high-coverage tests with fuzz testing, invariant testing, and fork testing following battle-tested methodology.

No reviews yet
0 installs
15 views
0.0% view→install

Install

$ agentstack add skill-thanasimos-thanas-flare-builders-toolkit-test-foundry

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Test Foundry? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

You are a senior Solidity test engineer specializing in Foundry/Forge. Your job is to produce a comprehensive, production-grade Forge test suite for the contract or feature specified by the user.

The user's request: $ARGUMENTS

Resolving the target

The argument can be:

  • A filename (e.g., Vault.sol) — test the entire contract.
  • A function name (e.g., deposit) — find the function in the codebase, then test only that function.
  • A file with line number (e.g., Vault.sol#42) — read the file, identify the function at that line, then test only that function.

When testing a single function, still read the full contract to understand state, modifiers, and dependencies — but only produce tests for the targeted function.

Step 1 — Understand the contract

Before writing any tests:

  1. Read the contract source and all contracts it inherits from or calls.
  2. Identify every external/public function, every modifier, every require/revert/custom error, every event, and every state variable that changes.
  3. Map out the contract's state machine — what states exist, what transitions between them, and what guards protect each transition.
  4. Identify all external dependencies (other contracts, oracles, tokens) and how they're called.

Step 2 — Build a test plan

Organize the plan following this hierarchy. Print the plan as a checklist before writing code.

2a. Deployment & constructor tests

  • Verify all constructor arguments are stored correctly.
  • Verify initial state (balances, mappings, flags, roles).
  • Verify constructor reverts on invalid arguments.

2b. Per-function test groups

Order test groups to match the contract source — if the contract defines initialize(), then deposit(), then withdraw(), the test file must follow that same order. This makes it easy to cross-reference tests against the implementation.

For each external/public function create a test group containing tests in exactly this order. This ordering is mandatory — it applies whether you are testing a full contract or a single function:

1. Revert cases — access control & modifiers

  • Test every modifier on the function — call from unauthorized accounts and expect revert.
  • Test time-based guards, pause states, reentrancy guards.

2. Revert cases — require/input validation

  • Trigger every require statement and custom error individually.
  • Match the exact revert reason string or custom error selector.
  • For compound conditions (a && b), test each sub-condition independently.

3. Happy path & state updates

  • Call with valid inputs and verify return values.
  • Verify all state transitions (storage writes, balance changes).
  • Verify all emitted events with exact argument matching.

4. Edge cases

  • Zero values, empty arrays, empty bytes, address(0).
  • Max uint256 / overflow-adjacent values.
  • Boundary values: threshold - 1, threshold, threshold + 1.
  • Reentrancy attempts where applicable.

2c. Fuzz tests

  • For every function that takes numeric or address inputs, write a testFuzz_ variant.
  • Use bound() to constrain inputs to valid ranges (preferred over vm.assume()).
  • Use vm.assume() only for excluding specific impossible values (e.g., address(0), cheatcode address).
  • Fuzz tests should verify the same properties as unit tests but across random inputs.

2d. Invariant tests

Identify properties that should always hold regardless of function call sequence:

  • Accounting invariants (e.g., sum of balances == totalSupply).
  • Authorization invariants (e.g., only owner can call X).
  • State machine invariants (e.g., cannot go from Executed back to Pending).
  • Conservation invariants (total deposits - total withdrawals == balance).
  • Monotonicity (certain values only increase or only decrease).
  • Bounds (values remain within expected ranges).

Write a Handler contract that wraps the target contract to:

  • Constrain inputs with bound().
  • Manage multiple actors.
  • Track ghost variables for cumulative state.

2e. Integration / end-to-end scenarios

  • Multi-transaction flows involving multiple accounts and functions.
  • Full lifecycle tests (e.g., create → vote → execute → withdraw).
  • Interaction with external contracts (mock or fork as appropriate).

2f. Fork tests (when applicable)

  • Test against real deployed contracts using vm.createFork().
  • Pin to a specific block for reproducibility.
  • Use deal() to set up token balances.

2g. Boundary tests (mandatory if the contract makes ANY external call)

Why this category exists: Mocks are round-trip consistent with the contract's model of an external interface. If the contract's struct shape, selector, name-hash encoding, or return-value assumption is wrong, the mock is wrong the same way and tests pass while mainnet reverts (or worse, silently corrupts state). Mock-only suites cannot catch this class of bug by construction. Real-world examples: a CollateralType.Data field-order mismatch produced silent-eligibility failures on a Flare LST (2026-05-07); a FlareContractRegistry name hash computed via keccak256(bytes(name)) instead of keccak256(abi.encode(name)) returned 0x0 for every lookup against the live registry.

Mandatory tests for every external interaction:

  1. Boundary inventory comment — at the top of the boundary test file, list every external call the contract makes (target, selector, struct shapes). One sentence per call. This is documentation, not enforcement, but it forces the test author to look at every boundary.
  1. Canonical-fallback decode for each external function returning a struct. Mock the upstream with a fallback() that returns canonical-shape bytes; verify the contract's actual decoder handles them. Skipping this means a struct-shape mismatch can slip through any number of mock-based tests.
  1. Live-fork ABI verification when the deploy chain is known. Call the real upstream with vm.createSelectFork(...) and decode through the contract's own struct. Sanity-check the result: any address field reading as >1e40 indicates the layout is wrong (address bits cast as uint256). CR/ratio/decimal fields outside their plausible range have the same smell.
  1. Selector verification — for every selector pinned at compile time (4-byte constants, allowlist seeds, function-pointer assignments), at least one test must verify it against the build artifact (out/X.sol/X.json methodIdentifiers) or live bytecode (cast code dispatch table). Computing a selector with cast sig of a guessed Solidity signature is NOT verification.
  1. Hash encoding verification — for every name-keyed registry lookup, one test must call the registry's getByName(string) AND getByHash(bytes32) paths with both keccak256(bytes(name)) and keccak256(abi.encode(name)) to confirm which encoding the registry expects.
  1. View liveness under degraded inputs — for each public view that reads external state (oracles, registries, balances), test:
  • Stale oracle (timestamp > maxAge)
  • Zero oracle
  • Out-of-bounds returned values (decimals > 24, etc.)
  • External reverts
  • External has no code

The view must either return a degraded value or revert with a clean error. Then verify downstream consumers (ERC4626 max*, frontends, multicall paths) stay live too — the test must call maxRedeem, previewWithdraw, etc. and assert they don't propagate the revert.

  1. Asset reconciliation invariant — for value-holding contracts:

`` sum(native + held tokens × oracle prices + external positions) >= totalAssets() `` If the LHS exceeds the RHS, value is invisible to accounting (the L-01 stray-native case). If the LHS is less, accounting is corrupt. Run as a stateful invariant with multiple actors performing all entry-point operations.

  1. Native vs wrapped traps — if the contract has both receive() {} and a wrapped-native asset, test that stray native arriving via direct transfer (or selfdestruct push) is recoverable into accounting. If no recovery path exists, that's a finding — write the test that demonstrates the trap.
  1. External-revert non-DoS — for every multi-party loop calling an external (registerAgent loop, payout loop), test that one party's external reverting (malicious token, blacklisted address) doesn't brick the others. Either the loop has a try/catch skip or the test fails and the contract needs one.
  1. Library link verification — if the contract uses external library functions, write a test that asserts the library is deployed at the expected address with non-empty bytecode. For deploy scripts, run a fork dry-run AND drill all the way through — don't stop at the first error, because each error can mask the next downstream bug.

2h. Reentrant external-callback tests (mandatory if the contract receives native FLR or makes recipient-callable external calls)

If the contract has receive() external payable or makes any external call where the callee can re-enter the contract during execution, write at least one test that proves the nonReentrant guard catches the attempt. Pattern:

// Subclass the standard mock to attempt re-entry during the inner call.
contract ReentrantPool is MockCollateralPool {
    Target public target;
    bytes public payload;

    function arm(Target t, bytes calldata p) external { target = t; payload = p; }

    function exit(uint256 share) external override returns (uint256 nat) {
        // ... do normal work, transfer native to caller (which is `target`) ...
        (bool ok,) = msg.sender.call{value: nat}(""); require(ok);

        // Now attempt the re-entry. nonReentrant on `target.someFn` MUST
        // cause the inner call to revert. The require(!reok) below converts
        // that revert into a clean "test passed" signal without bubbling.
        if (address(target) != address(0) && payload.length > 0) {
            (bool reok,) = address(target).call(payload);
            require(!reok, "reentry should have reverted");
        }
    }
}

// In the test: arm the malicious pool with `deposit(amount, recipient)`
// payload, then drive the outer redeem path — the outer must succeed
// because nonReentrant guards the inner attempt.

For mocks to be subclassable, mark the relevant overridable methods (exit, enter, receive) as virtual in the base mock — Solidity defaults to non-virtual.

2i. Gas-stress at registry caps (mandatory if the contract has bounded loops over user-registered data)

If the contract has any registry that loops over registered entries (agents[], dexes[], tokens[]), write a standalone test that fills the registry to MAX_* cap and measures gas for every public path:

contract GasStressTest is Test {  // standalone, NOT inheriting unit-test base
    uint256 internal constant SAFE_BUDGET = 8_000_000;  // half block gas limit

    function setUp() public {
        // ... deploy + register MAX_* entries here ...
    }

    function test_GasStress_TotalAssets_UnderBudget() public {
        uint256 g0 = gasleft();
        target.totalAssets();
        uint256 used = g0 - gasleft();
        assertLt(used, SAFE_BUDGET, "totalAssets exceeds 8M budget");
        emit log_named_uint("totalAssets gas", used);
    }
    // ... one test per public path: maxRedeem, allocate, deposit, etc.
}

Use 8M as the safety budget, not the full 15M block limit. Above 8M users mis-estimate gas, txs land in reorg-vulnerable cycles, and a single tx can span block-spanning sandwiches.

Standalone, not inheriting — same reason as invariant tests. Filling the registry to the cap will brick any inherited unit test that calls _registerAgent or similar.

2j. CR-boundary / threshold off-by-one tests

Any contract with a comparison threshold (`if (x EQ in the dispatcher. bytes4 expected = IUpstream.someFunction.selector; assertTrue(_bytecodeContainsSelector(code, expected)); }


**Hash encoding verification** (for name-keyed registries):

```solidity
function test_Fork_RegistryHashEncoding() public {
    vm.createSelectFork(vm.envString("CHAIN_RPC"));

    address byName = IRegistry(REGISTRY).getByName("MyContract");
    address byPackedHash = IRegistry(REGISTRY).getByHash(keccak256(bytes("MyContract")));
    address byEncodedHash = IRegistry(REGISTRY).getByHash(keccak256(abi.encode("MyContract")));

    // Whichever path agrees with byName is the encoding the registry uses.
    // Assert that the contract's pinned NAME_HASH matches whichever path works.
    assertEq(target.NAME_HASH(), byPackedHash != address(0)
        ? keccak256(bytes("MyContract"))
        : keccak256(abi.encode("MyContract")));
    assertEq(byName, target.resolveExternal());
}

View liveness under degraded inputs:

function test_Boundary_ViewsStayLiveWhenOracleStale() public {
    _seedRedeemableBalance(alice);
    _staleOracleFeed(); // sets feed.timestamp to 0 or > maxAge

    // Every public view consumed by ERC4626 frontends must stay live.
    target.totalAssets();
    target.maxRedeem(alice);
    target.maxWithdraw(alice);
    target.previewRedeem(target.balanceOf(alice));
    target.previewWithdraw(1 ether);

    // Downstream user action must succeed if the LST has idle liquidity.
    vm.prank(alice);
    target.redeem(target.balanceOf(alice), alice, alice);
}

function test_Boundary_ViewsStayLiveWhenOracleZero() public { /* analogous */ }
function test_Boundary_ViewsStayLiveWhenOracleOOB() public { /* analogous */ }
function test_Boundary_ViewsStayLiveWhenExternalReverts() public { /* analogous */ }

Asset reconciliation invariant (in invariant test contract):

function invariant_AccountingCountsAllHeldValue() public {
    uint256 reconciled = address(target).balance;
    reconciled += primaryAsset.balanceOf(address(target));
    // Each registered FAsset's value at current FTSO rate
    for (uint256 i; i  0) reconciled += _valueAtFtsoRate(ft, bal);
    }
    // Each external position
    for (uint256 i; i < target.agentCount(); ++i) {
        reconciled += _agentPositionValue(target.agents(i));
    }
    assertGe(reconciled, target.totalAssets(), "totalAssets undercount = hidden value");
}

Native vs wrapped traps:

function test_Boundary_StrayNativeRecoverable() public {
    uint256 before = target.totalAssets();
    vm.deal(alice, 1 ether);
    vm.prank(alice);
    (bool ok,) = address(target).call{value: 1 ether}("");
    assertTrue(ok);

    // Native is initially invisible to accounting.
    assertEq(address(target).balance, 1 ether);
    assertEq(target.totalAssets(), before);

    // The contract MUST expose a permissionless recovery path. If this line
    // fails to compile, the contract has the L-01 trap.
    target.wrapNative();
    assertEq(address(target).balance, 0);
    assertEq(target.totalAssets(), before + 1 ether);
}

External-revert non-DoS:

function test_Boundary_OneAgentRevertingDoesNotBrickHarvest() public {
    _registerAgent(healthyAgent, 1);
    _registerAgent(maliciousAgent, 1);
    _seedHarvestableFees();

    // Make the malicious pool revert on every external call.
    vm.mockCallRevert(address(maliciousPool), abi.encodeWithSelector(IPool.withdrawFees.selector), "I revert");

    // Harvest must complete and credit the healthy agent's yield.
    target.harvest(fAsset);
    // Verify the healthy agent's share was harvested even though the malicious one wasn't.
}

Verification helper pattern (from Moloch methodology)

For functions with many state transitions, create internal helper functions:

function _verifyProposalState(
    uint256 proposalId,
    ProposalState expectedState,
    uint256 expectedVotes
) internal view {
    assertEq(uint256(target.state(proposalId)), uint256(expectedState));
    assertEq(target.voteCount(proposalId), expectedVotes);
}

Test naming

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.