Install
$ agentstack add skill-thanasimos-thanas-flare-builders-toolkit-test-foundry ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo 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.
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:
- Read the contract source and all contracts it inherits from or calls.
- Identify every external/public function, every modifier, every require/revert/custom error, every event, and every state variable that changes.
- Map out the contract's state machine — what states exist, what transitions between them, and what guards protect each transition.
- 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 overvm.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:
- 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.
- 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.
- 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>1e40indicates the layout is wrong (address bits cast as uint256). CR/ratio/decimal fields outside their plausible range have the same smell.
- 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.jsonmethodIdentifiers) or live bytecode (cast codedispatch table). Computing a selector withcast sigof a guessed Solidity signature is NOT verification.
- Hash encoding verification — for every name-keyed registry lookup, one test must call the registry's
getByName(string)ANDgetByHash(bytes32)paths with bothkeccak256(bytes(name))andkeccak256(abi.encode(name))to confirm which encoding the registry expects.
- 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.
- 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.
- Native vs wrapped traps — if the contract has both
receive() {}and a wrapped-native asset, test that stray native arriving via direct transfer (orselfdestructpush) is recoverable into accounting. If no recovery path exists, that's a finding — write the test that demonstrates the trap.
- 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/catchskip or the test fails and the contract needs one.
- 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.
- Author: Thanasimos
- Source: Thanasimos/Thanas-flare-builders-toolkit
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.