September 2026 was the worst month for crypto security all year. Attackers pulled roughly $776 million out of 48 separate incidents, more than triple what they stole in August, according to a monthly report from Nominis. A good chunk of that total did not come from stolen private keys or phished seed phrases. It came from bugs inside the smart account wallets that are supposed to be the upgrade path beyond seed phrases altogether.

Two incidents from that month show exactly why smart contract security testing needs to change. Startale’s ERC-7579 smart accounts shipped with a transient-storage initialization flag that stayed active for an entire transaction instead of being consumed after first use. That let an attacker re-initialize roughly 330 pre-funded counterfactual accounts and drain them without a single user signature or a cent of attacker capital, based on incident data tracked by SlowMist’s hacked-wallet database. Days later, a Safe wallet holding a leveraged rsETH position got hit through an authorized module with a weak permission check, though an MEV bot called Yoink front-ran the attacker and captured the $7.73 million first.

This tutorial walks through building a Foundry test suite that catches both bug classes before they ship. You will deploy a minimal ERC-7579 account locally, reproduce the exact reinitialization flaw that hit Startale, write a proof-of-concept exploit test against it, then move on to module authorization checks modeled on the Safe/Uniswap V4 hook incident. By the end you will have a working repository, a fuzz-and-invariant harness, and a troubleshooting checklist for the errors you will almost certainly hit along the way.

Understanding the Smart Account Attack Surface

A traditional externally-owned wallet has one attack surface: the private key. Steal the key, steal the funds, and there is nothing else to check. A smart account flips that model. Ownership, spending rules, and recovery logic all live inside contract code, which is exactly what makes account abstraction powerful and exactly what gives attackers so many more doors to try. Under ERC-4337, every action starts as a UserOperation that a bundler submits through a shared EntryPoint contract, which validates the operation against your account’s own validation logic before anything executes. ERC-7579 then splits that validation logic into swappable pieces: validators decide whether a signature or rule is satisfied, executors run the actual transaction logic, and hooks run extra checks before or after execution.

That modularity is the entire point of ERC-7579. It lets a wallet add session keys, spending limits, or social recovery without redeploying the whole account. It also means smart contract security testing has to cover every module independently, plus every path a module can reach once it is installed. The Startale incident broke the account at the validator/initialization layer. The Safe incident broke it one layer higher, at the executor layer, where an installed module routed funds somewhere nobody approved. Both bugs would be invisible to a test suite that only checks “can a stranger steal the owner’s signature.” Neither attacker needed a signature at all.

Keep that distinction in mind as you work through the steps below. You are not just testing whether your account can be drained by a stolen key. You are testing whether the modular pieces around that account, the init flow, the enabled modules, and the batched calls that touch them, can be turned against the owner even when the key itself stays perfectly safe.

Each ERC-7579 module type carries its own risk profile, and your test coverage should reflect that rather than treating every module the same way.

Module TypeWhat It ControlsWhat to Test
ValidatorWhether a signature, session key, or rule satisfies account authenticationReplay protection, signature malleability, session-key expiry
ExecutorWhat the account actually does once authenticated (swaps, transfers, liquidity moves)Destination allowlists, slippage limits, multicall-batched calls
HookExtra checks that run before or after an executor callWhether the hook can be skipped, disabled, or pointed at an attacker-controlled contract
Fallback handlerHow the account responds to calls that match no other selectorWhether an unexpected selector can reach privileged logic indirectly

The Safe/Uniswap V4 incident is a textbook executor-layer failure. The Startale incident is a textbook validator-layer failure, since initialization is itself a validation step that decides who the account will trust going forward. Mapping each bug you read about back to one of these four rows makes it much easier to decide where your own test suite is thin.

What You Need Before You Start

Smart contract security testing with Foundry does not require a heavy toolchain, but version mismatches cause most of the early frustration. Install and confirm each of these before writing a single test.

  • Foundry (forge, cast, anvil): install the latest stable build with foundryup and confirm with forge --version. Foundry ships date-stamped builds rather than clean semver, so pin whatever version you land on in a README note for your team.
  • Solidity: pragma ^0.8.24 or newer. The transient-storage opcodes (TSTORE/TLOAD) that caused the Startale bug were finalized under EIP-1153 and need a compiler that supports them.
  • OpenZeppelin Contracts v5.x: for the access-control base contracts used in the fixed version of our account.
  • forge-std: bundled with Foundry, gives you Test.sol, cheatcodes, and the console logging used throughout this guide.
  • Git and a code editor with Solidity syntax highlighting.
  • A local Anvil chain only. Every exploit in this tutorial runs against a local fork or a fresh Anvil instance. Do not point any of this at mainnet or a funded wallet.

If you have already worked through our ERC-4337 paymaster exploit testing guide, the setup here will feel familiar. ERC-7579 builds on the same account abstraction foundation as ERC-4337 but adds a modular layer for validators, executors, and hooks, which is exactly where the Startale and Safe bugs lived.

One more note before you start installing packages. If your team already maintains a monorepo with multiple account-abstraction contracts, resist the urge to bolt this test suite onto an existing, cluttered test directory. Keep the exploit-reproduction tests in their own clearly named folder, separate from your regular unit tests. Security regression tests have a different purpose than feature tests. A feature test tells you the code works as intended. An exploit-reproduction test tells you the code fails safely when someone intentionally tries to break it, and mixing the two styles in one file makes it easy to accidentally delete or “simplify” a security test during an unrelated refactor.

Step 1-2: Spin Up a Local Foundry Test Environment

Start with a clean project so nothing from an existing repo leaks into your test assumptions.

forge init smart-account-security
cd smart-account-security
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge install erc7579/erc7579-implementation --no-commit
forge build

Add remappings so the compiler can find the installed libraries cleanly:

echo '@openzeppelin/=lib/openzeppelin-contracts/
erc7579/=lib/erc7579-implementation/src/' >> remappings.txt

Run forge test once with no custom tests written. A green, empty run confirms the toolchain, compiler version, and remappings all line up before you add any exploit logic. Skipping this check is how people end up debugging a remapping typo three steps later while thinking they found a new bug.

Step 3-4: Deploy a Minimal ERC-7579 Smart Account

You need an account contract simple enough to reason about but realistic enough to reproduce the actual bug class. Create src/VulnerableAccount.sol with an initialization pattern that mirrors what SlowMist’s incident notes describe for the Startale accounts: a bootstrap flag stored in transient storage that is meant to block a second initialization call.

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

contract VulnerableAccount {
    address public owner;
    mapping(address => bool) public modules;

    // Transient storage slot used as an init guard
    bytes32 constant INIT_SLOT = keccak256("account.init.flag");

    function initializeAccount(address _owner, address _bootstrapModule) external {
        bool alreadyInit;
        assembly {
            alreadyInit := tload(INIT_SLOT)
        }
        require(!alreadyInit, "already initialized");

        owner = _owner;
        modules[_bootstrapModule] = true;

        assembly {
            tstore(INIT_SLOT, 1)
        }
        // BUG: tstore only persists for the current transaction.
        // A second call to initializeAccount() in a NEW transaction
        // reads a fresh, empty transient slot and passes the check again.
    }

    function enableModule(address module) external {
        require(msg.sender == owner, "not owner");
        modules[module] = true;
    }
}

Understanding the Initialization Flow

Transient storage, introduced through EIP-1153, clears itself automatically at the end of every transaction. That property is useful for reentrancy locks because you want the guard gone once the call stack unwinds. It is the wrong tool for a one-time initialization flag, which needs to stay true forever, not just for one transaction. The Startale incident happened because a bootstrap guard that should have lived in permanent storage was written to transient storage instead, so every new transaction saw a clean slate and treated the account as never initialized.

Deploy this contract in a test file so you have a concrete target for the next step:

VulnerableAccount account = new VulnerableAccount();
account.initializeAccount(originalOwner, trustedModule);

Step 5-6: Reproduce the Transient-Storage Reinitialization Bug

Now prove the bug is exploitable rather than just theoretically present. Foundry’s vm cheatcodes let you simulate two separate transactions against the same deployed contract, which is the detail that makes this bug invisible in naive single-call tests.

function test_ReinitializationDrainsOwnership() public {
    VulnerableAccount account = new VulnerableAccount();

    // Transaction 1: legitimate setup by the real owner
    account.initializeAccount(alice, trustedModule);
    assertEq(account.owner(), alice);

    // Roll to a new block to simulate a brand-new transaction
    // context, since transient storage resets between transactions.
    vm.roll(block.number + 1);

    // Transaction 2: attacker calls initializeAccount again.
    // Transient storage has reset, so the guard passes.
    vm.prank(attacker);
    account.initializeAccount(attacker, maliciousModule);

    assertEq(account.owner(), attacker); // ownership hijacked
}

If that test passes, you have reproduced the same bug class that let someone drain roughly 330 accounts without needing a signature or any starting capital. That number alone should tell you why this class of bug is dangerous: it scales. One flawed factory contract puts every account it deploys at risk at the same time, not just a single victim.

Step 7-8: Write a Forge Test That Proves the Fix Holds

A proof-of-concept test that only confirms the bug is half the job. You also need a regression test that keeps failing until the contract is actually patched, so a future refactor cannot silently reintroduce the flaw. Fix the account by moving the init guard to permanent storage with a one-time setter pattern:

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

contract FixedAccount {
    address public owner;
    mapping(address => bool) public modules;
    bool private initialized; // permanent storage, not transient

    function initializeAccount(address _owner, address _bootstrapModule) external {
        require(!initialized, "already initialized");
        initialized = true;
        owner = _owner;
        modules[_bootstrapModule] = true;
    }

    function enableModule(address module) external {
        require(msg.sender == owner, "not owner");
        modules[module] = true;
    }
}
function test_SecondInitializationReverts() public {
    FixedAccount account = new FixedAccount();
    account.initializeAccount(alice, trustedModule);

    vm.roll(block.number + 1);
    vm.expectRevert("already initialized");
    vm.prank(attacker);
    account.initializeAccount(attacker, maliciousModule);
}

Run forge test -vv and confirm the revert fires with the exact reason string you expect. A vague “test reverted” pass is not good enough here. Match the revert reason explicitly, since a different, unrelated revert could otherwise slip through and give you a false sense of security.

Step 9-10: Audit Module Authorization the Way the Safe Incident Demands

The Startale bug was about initialization. The Safe wallet incident from September 15, 2026 was about something different and arguably more common: a correctly initialized account that authorized a module it should not have trusted this broadly. If you have already set up a multisig using our Safe multisig configuration guide, this is the companion audit step that checks what happens after setup, not during it.

The Uniswap V4 Hook Redirect, Explained

According to incident coverage from ForkLog, the attacker found a Gnosis Safe holding a leveraged rsETH position through an auxiliary module that was authorized but not tightly scoped. Using a public multicall route, the attacker redirected the wallet’s enabled Uniswap V4 liquidity module toward a pool the attacker had created with a malicious hook attached. That hook caused a wrapped aEthrsETH position to unwrap into plain rsETH, which is far easier to extract. The Safe’s core contracts were never the problem. The weakness was that an additional module had broad enough permissions to be redirected at all, and nothing checked where the liquidity module was actually pointing before it executed.

Here is a Foundry test that checks for exactly that class of weakness: does your account contract validate the target pool or hook address before routing funds through an externally swappable module?

function test_ModuleCannotBeRedirectedToUnapprovedPool() public {
    // Simulate the account's liquidity module pointing at an
    // attacker-deployed pool instead of the approved one.
    address attackerPool = address(0xBAD);

    vm.prank(attacker);
    vm.expectRevert("pool not on allowlist");
    liquidityModule.setTargetPool(attackerPool);
}

function test_MulticallCannotBypassModuleAllowlist() public {
    bytes[] memory calls = new bytes[](1);
    calls[0] = abi.encodeWithSelector(
        liquidityModule.setTargetPool.selector,
        address(0xBAD)
    );

    vm.prank(attacker);
    vm.expectRevert();
    safeAccount.multicall(calls);
}

Note what the fix actually requires: an allowlist on the destination pool or hook address, checked inside the module itself, not just a permission check on who can call the module. The original incident shows that “is this module enabled” and “is this module pointed somewhere safe” are two separate questions, and most audits only ask the first one.

Step 11-12: Fuzz and Invariant Test the Module Enable Path

Unit tests catch the bugs you already know to look for. Fuzzing and invariant testing catch the ones you have not thought of yet, which matters because the next smart account exploit will not look exactly like Startale’s or Safe’s. Add an invariant test that holds across every possible sequence of module operations.

contract ModuleInvariantTest is Test {
    FixedAccount account;
    address owner = address(0x1);

    function setUp() public {
        account = new FixedAccount();
        account.initializeAccount(owner, address(0x2));
        targetContract(address(account));
    }

    // Invariant: only the current owner can ever enable a module,
    // no matter what random sequence of calls the fuzzer tries.
    function invariant_OnlyOwnerEnablesModules() public {
        assertTrue(account.owner() == owner || account.owner() == address(0));
    }
}

Why Invariant Tests Catch What Fuzzing Alone Misses

Plain fuzz tests check one function against randomized inputs. Invariant tests let Foundry call random sequences of every public function on your contract, in random order, then check that a property you define never breaks. That matters for account contracts specifically because real exploits rarely trigger through a single malicious call. The Safe incident needed an enable call, a multicall, and a pool swap chained together. An invariant suite is far more likely to stumble onto that exact sequence than a developer manually writing out attack scenarios one at a time.

Run it with a higher-than-default run count for anything going to production:

forge test --match-contract ModuleInvariantTest -vvv --fuzz-runs 10000

How September 2026’s Incidents Map to Test Coverage

Each incident from last month points at a different gap in a typical test suite. Treat this table as a checklist against your own account contracts rather than a historical curiosity.

IncidentRoot CauseApproximate LossTest Type That Catches It
Startale ERC-7579 accountsInit guard stored in transient storage, reset every transaction~330 accounts drainedMulti-transaction unit test with vm.roll
Safe wallet / Uniswap V4 hookAuthorized module with no destination-pool allowlist$7.73M (recovered by MEV bot, not the victim)Module-redirect and multicall-bypass tests
Ethereum contract, Sept 9Missing access control on a privileged function~24.7 ETHCaller-restriction fuzz test
Bitget hot/warm walletsThird-party security product compromised, forged withdrawal commands~$387.5MSupply-chain and signer-integrity checks
Payy Ethereum bridgeBridge contract logic flaw drained full balance~$1.83MBridge balance-invariant test

The Payy row deserves a closer look because it is the one bug class this tutorial has not shown code for yet. Payy confirmed that attackers exploited its Ethereum bridge contract and drained the bridge’s entire balance around 04:21 UTC on September 24, 2026, according to incident write-ups from Cyberpress and GBHackers. The company suspended both its network and wallet services immediately afterward. A balance-invariant test, one that asserts the bridge contract’s token balance can never drop below the sum of all outstanding claims, is a different shape of test than anything covered in the steps above, but it follows the exact same principle: pick the property that must always hold, then let Foundry’s fuzzer try every combination of calls it can find to break it.

The pattern across all five rows is not “smart contracts are unsafe.” It is that each loss traces back to one specific, testable assumption nobody wrote a test for. Closing that gap is the whole point of this tutorial.

Common Pitfalls When Testing Smart Account Security

These are the mistakes that show up most often in smart account test suites, including some that would have hidden the exact bugs covered above.

  • Testing initialization within a single transaction only. If your test calls initializeAccount twice inside the same function without rolling to a new block, transient-storage bugs look safe when they are not. Always split initialization tests across simulated transactions.
  • Checking “is the module enabled” without checking “where does it point.” The Safe incident proves that permission checks and destination checks are separate concerns. Write tests for both.
  • Treating multicall as a single trusted caller. Multicall batches arbitrary calls under one transaction. Test each batched call as if it came from an untrusted source, because in a public multicall route, it effectively did.
  • Mocking the EntryPoint or bundler instead of using the real validation flow. Account abstraction bugs frequently live in the gap between what a mock assumes and what the real ERC-4337 EntryPoint actually enforces.
  • Skipping fuzz runs on CI to save time. A fuzz test that only runs a few hundred times locally and never runs thousands of times in CI will miss edge cases that only appear at higher input counts.
  • Assuming a third-party module or security product is out of scope. Bitget’s $387.5 million loss traces back to a compromised third-party security product, not a bug in Bitget’s own contracts. Your threat model needs to include every dependency your account contract trusts.
  • Forgetting to test the upgrade or migration path. A contract can be fully hardened on initial deploy and still be reopened by a sloppy upgrade function. If your account supports any form of logic upgrade, write a dedicated test that attempts the reinitialization exploit again immediately after an upgrade completes.

Troubleshooting Guide

Expect to hit most of these at least once while building out this test suite.

  • “TSTORE/TLOAD not supported” compiler error. Your Solidity version predates EIP-1153 support. Bump to 0.8.24 or newer in foundry.toml under [profile.default].
  • Reinitialization test passes even without vm.roll. Some Foundry versions clear transient storage between top-level test calls regardless. Add the roll anyway for clarity and to match real-world transaction boundaries.
  • “already initialized” never reverts in the fixed contract. Check that the initialized flag is declared outside any function and not shadowed by a local variable of the same name.
  • Invariant tests finish instantly with zero failures. That usually means targetContract was never called, so the fuzzer has nothing to attack. Confirm it is set in setUp().
  • Multicall test reverts with a generic panic instead of your custom message. Multicall often swallows the original revert reason. Decode the returned bytes manually, or test the underlying function directly first and the multicall wrapper second.
  • Remapping errors after installing erc7579-implementation. Run forge remappings to print what Foundry actually resolved and compare it line by line against your remappings.txt.
  • Fuzz runs take too long locally. Lower –fuzz-runs for local iteration and only raise it to 10,000 or more in CI, not on every local save.
  • Tests pass locally but fail in CI. Pin your Foundry version in CI with foundryup –version rather than always pulling the latest nightly, since transient-storage behavior has changed across Foundry releases.
  • “stack too deep” compiler error in a fuzz test with many parameters. Wrap related parameters into a struct rather than passing them as separate function arguments.
  • Coverage report shows 0% on a file you know you tested. This usually means the contract was compiled via an imported interface rather than the concrete implementation. Point forge coverage directly at the source file and confirm the contract name matches exactly, including case.

Advanced Tips: Monitoring, Alerts, and Continuous Fuzzing

Tests catch bugs before deployment. They cannot catch a bug in a dependency that changes after you ship. Pair your Foundry suite with a monitoring layer and re-run your fuzz and invariant tests on a schedule, not just on pull requests.

If you already run the ERC-4626 inflation attack tests or the reentrancy test suite from our other Foundry tutorials, add this smart-account suite as another job in the same CI pipeline rather than a separate project. Shared fixtures save setup time and keep your attack-test coverage in one place.

ToolBest ForRuns LocallyTypical Use in This Workflow
Foundry (forge)Unit, fuzz, and invariant testingYesPrimary test runner for every example in this guide
SlitherStatic analysis, pattern-based bug detectionYesPre-commit check for access-control and reentrancy patterns
Echidna / MedusaProperty-based fuzzing at scaleYesDeeper fuzzing pass beyond forge’s built-in fuzzer before mainnet deploys
On-chain monitoring (custom alerts)Catching anomalous same-block extraction post-deployNo, runs against live chainFlags the kind of rapid drain seen in the Startale and Safe incidents

Our paymaster exploit testing guide covers Slither configuration in more depth if you have not run it against an account-abstraction contract before. For the monitoring row, even a simple script watching for same-block, same-address withdrawals above a threshold would have flagged the Startale drain in near real time, since 330 accounts emptying within a short window is not normal wallet behavior.

Schedule your heavier fuzz and invariant runs rather than relying on pull-request CI alone. A 10,000-run invariant suite on every commit slows down review cycles, but running that same suite nightly against your main branch catches drift that accumulates over weeks of small, individually harmless-looking changes. Treat a failing nightly run with the same urgency as a failing deploy, not as background noise to check next week.

The Complete Working Project

Pulling every step together, your repository should look like this once you are done:

smart-account-security/
├── foundry.toml
├── remappings.txt
├── src/
│   ├── VulnerableAccount.sol
│   ├── FixedAccount.sol
│   └── LiquidityModule.sol
├── test/
│   ├── Reinitialization.t.sol
│   ├── ModuleAuthorization.t.sol
│   └── ModuleInvariant.t.sol
└── script/
    └── Deploy.s.sol

The deploy script matters more than it looks. It is where you wire the fixed account to its modules in the right order, and it is the easiest place to accidentally reintroduce a bug by deploying with a stale constructor argument. Keep it simple and explicit:

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

import "forge-std/Script.sol";
import "../src/FixedAccount.sol";
import "../src/LiquidityModule.sol";

contract Deploy is Script {
    function run() external {
        uint256 deployerKey = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployerKey);

        FixedAccount account = new FixedAccount();
        LiquidityModule module = new LiquidityModule(address(account));

        account.initializeAccount(msg.sender, address(module));

        vm.stopBroadcast();
        console2.log("Account deployed at:", address(account));
        console2.log("Module deployed at:", address(module));
    }
}

Run it against your local Anvil instance first, never against a funded address, with:

anvil &
PRIVATE_KEY=0x forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast

Run the full suite with coverage reporting before treating any of this as done:

forge test -vvv
forge coverage --report summary

A typical output once everything is wired up correctly looks like this:

Ran 3 test suites: 6 tests passed, 0 failed, 0 skipped

File                       % Lines       % Statements
src/FixedAccount.sol       100.00% (9/9) 100.00% (8/8)
src/LiquidityModule.sol    92.31% (12/13) 90.00% (9/10)

Anything under roughly 90% line coverage on an account contract is worth a second look before deployment. The number itself is not magic, but the uncovered lines are exactly where an attacker is most likely to look first.

Why This Keeps Happening: The Bigger Pattern Behind the Numbers

A biweekly security report from BlockSec covering September 21 through October 4, 2026 recorded eight incidents totaling roughly $418.4 million in losses across Ethereum, BNB Chain, Solana, Bitcoin, TRON, XRP Ledger, Zcash, Avalanche, and NEAR. The targets varied wildly, from bridges to exchange infrastructure to smart accounts, but the underlying failure mode kept repeating: a trust assumption that nobody tested under adversarial conditions.

A separate Ethereum breach on September 9, 2026 drained roughly 24.7 ETH after a contract was missing an access-control check on a privileged function, reported by Cryptonomist. No private key was stolen in that case. The contract simply let an unauthorized caller reach a function it should never have been able to call. That is the simplest possible bug class covered in this guide, and it is still showing up in production contracts in 2026, which says something about how often basic caller-restriction tests get skipped under deadline pressure.

The pattern is not confined to Ethereum account contracts either. Research tracked by CertiK recorded a September 6, 2026 exploit of the Liquid Network through a vulnerability in the Elements codebase used to validate confidential transactions, and a separate September 30, 2026 exploit of the HOT Omni Bridge on NEAR that produced five observed treasury payouts to BNB Chain addresses totaling roughly $3.86 million. Different chains, different languages, same root failure: a validation path that nobody adversarially tested before real money moved through it.

None of this means smart accounts are a step backward from traditional wallets. It means the testing bar for them has to be higher, since a single factory contract bug can hit hundreds of accounts at once instead of one wallet at a time. The cryptocurrency security lessons from 2026 point in one direction: treat every module, every initialization path, and every multicall route as hostile until a test proves otherwise.

A Reusable Checklist for Every Smart Account Deploy

Keep this list next to your deploy script and work through it before anything touches mainnet. It compresses the twelve steps above into the questions an auditor would actually ask.

  • Does every one-time initialization flag live in permanent storage, never transient storage?
  • Does a second call to any initializer function revert with a specific, tested reason string?
  • Does every installed module validate its own destination address or pool, not just the caller’s identity?
  • Have you tested every module-changing function as if it were called through a public multicall route?
  • Do your invariant tests run at least 10,000 fuzz runs in CI, not just the default local run count?
  • Have you run Slither or an equivalent static analyzer against every contract in the repository, including modules installed from third-party packages?
  • Does your monitoring layer alert on multiple accounts draining within the same block, the same pattern that exposed the Startale accounts?
  • Have you documented which third-party infrastructure (security products, bundlers, relayers) your account implicitly trusts, given that Bitget’s loss traced back to exactly that kind of dependency?

None of these checks replace a professional third-party audit for anything holding meaningful value. They are the floor, not the ceiling, and they are the same floor that would have caught every incident referenced throughout this guide before it reached production. Treat an audit as confirmation that your own testing was thorough, not as a substitute for doing the testing in the first place. Auditors work faster and find more when the codebase they receive already has a Foundry suite like the one built in this guide, since it tells them exactly which assumptions the team already considered and which ones still need a fresh set of eyes.

Frequently Asked Questions

What is the difference between ERC-4337 and ERC-7579?
ERC-4337 defines the base account abstraction standard, including the EntryPoint contract and how bundlers process user operations. ERC-7579 builds on top of that to standardize modular components inside an account, like validators, executors, and hooks, which is the layer where the Startale and Safe module bugs both lived.

Why did transient storage cause the Startale bug instead of preventing it?
Transient storage automatically clears at the end of every transaction. That is useful for temporary locks like reentrancy guards, but an initialization flag needs to persist across every future transaction, not just the one where it was set. Using transient storage for a permanent flag means the flag resets every time, defeating the one-time check entirely.

Could the MEV bot Yoink have been the attacker instead of a rescuer?
Functionally, yes. Yoink simply detected a profitable extraction opportunity and executed it first, the same mechanism any front-running bot uses. In this case the outcome moved funds toward recoverable custody rather than an anonymous attacker wallet, but the underlying mechanism matches a malicious front-run.

Do I need Echidna or Medusa if I already use Foundry’s built-in fuzzer?
Foundry’s fuzzer and invariant testing cover most day-to-day needs. Dedicated property-based fuzzers like Echidna or Medusa run more exhaustive campaigns and are worth adding as a pre-mainnet step for contracts holding significant value, rather than as a replacement for your Foundry suite.

How much test coverage is enough for a smart account contract?
There is no universal number, but line coverage alone is not sufficient. A contract can hit 100% line coverage while still missing the exact multi-transaction or multicall sequence that causes a real exploit. Treat coverage as a floor, then layer fuzz and invariant tests on top to probe sequences a human would not think to write by hand.

Can these tests catch a compromised third-party security product like the one behind the Bitget incident?
Not directly. Unit and fuzz tests validate your own contract logic, not the integrity of an external vendor’s infrastructure. For that risk, you need operational controls such as withdrawal rate limits, multi-party signing, and anomaly alerts on your own monitoring layer, separate from the Foundry suite covered here.

Should I test against a mainnet fork or a clean local Anvil chain?
Use a clean local Anvil chain for the exploit-reproduction tests in this guide, since you are testing logic specific to your own contracts. Switch to a mainnet fork only when you need to test integration against real deployed contracts, such as an actual Uniswap V4 pool or a live EntryPoint, and always confirm you are not sending any transaction to a real funded address.

Does the MEV protection on most wallets prevent the Safe-style module redirect described here?
No. MEV protection services like Flashbots Protect shield a transaction from being seen and front-run in the public mempool, but they do nothing to stop an authorized module from being misused after the fact. That gap has to be closed through the module allowlist and multicall tests covered in Step 9-10, not through mempool privacy.