A victim doesn’t click “Approve” when a wallet drainer strikes through a permit signature. They click “Sign,” see no gas fee, feel relieved, and lose everything anyway. That gap between what a wallet shows and what a signature actually authorizes is exactly why permit-based phishing remains one of the hardest attack classes to catch during a code review. Scam Sniffer’s 2025 year-end report put total wallet-drainer losses at $83.85 million across 106,106 victims, down sharply from 2024’s $494 million, and attributed 38% of incidents above $1 million directly to Permit or Permit2 signatures. The dollar figure dropped. The attack vector did not.
This tutorial builds a Foundry test suite that catches the permit bugs attackers actually exploit: missing deadline checks, replay-able signatures, wrong-domain signing, and unbounded Permit2 allowances. By the end you’ll have a working project that fails loudly when a contract’s permit logic is broken, and you’ll know exactly what each failing test is telling you. No prior Foundry experience with EIP-712 signing is assumed, though you should be comfortable writing basic Solidity and running forge commands.
Why permit phishing testing belongs in every smart contract audit
EIP-2612 permit exists to remove a UX headache. Instead of making a user send an on-chain approve transaction before every swap or deposit, permit lets them sign an off-chain message that a relayer later submits alongside the actual transaction. One signature, no gas, no separate approval step. It’s a genuinely good piece of engineering, which is precisely why it got adopted everywhere from Uniswap to Aave to half the token contracts deployed since 2021.
The tradeoff is that a permit signature looks harmless. A malicious dApp, a fake NFT mint page, or a drainer kit disguised as a wallet-connect prompt can ask a victim to sign a permit message that grants a spender unlimited access to their tokens, and the wallet UI often renders that request as plain hex data or a vague “Sign Message” button with no dollar amount attached. Compare that to a standard approve() call, where most wallets at least show the spender address and the token amount in a transaction preview. Scam Sniffer’s research team has tracked this gap for several years, and the 2025 numbers confirm it is still the preferred method for high-value theft even as overall drainer activity cooled.
Permit2, Uniswap’s standalone approval contract, compounds the problem differently. Rather than each token implementing its own permit logic, a user grants Permit2 one allowance, and after that, signature-based transfers through Permit2 can move tokens without any further on-chain approval. That’s efficient for legitimate protocols batching multiple token swaps, but it also means a single malicious Permit2 signature can be scoped far more broadly than a single token’s permit function ever could.
None of this means permit and Permit2 are unsafe by design. It means the contracts and integrations built around them need test coverage that specifically targets how a signature can be misused, not just whether the happy path works. That’s the gap this tutorial fills.
Prerequisites and versions
Confirm each of these before starting. Version mismatches are the single most common reason a Foundry tutorial breaks halfway through.
- Foundry: v1.8.4 or later (released October 1, 2026). Check with
forge --version. - Solidity: 0.8.24 or later, set via
foundry.toml. - OpenZeppelin Contracts: v5.x (for
ERC20PermitandERC2612reference implementations). - Node.js: v18 or later, only needed if you want to generate signatures with ethers.js instead of Foundry’s built-in cheatcodes.
- Git: any recent version, for cloning dependencies as submodules.
- Basic familiarity with ECDSA signatures and the
v, r, scomponents Solidity uses to verify them. - A terminal and a code editor. VS Code with a Solidity extension works fine for Foundry projects too.
You do not need a testnet RPC, a wallet with real funds, or a deployed contract anywhere. Everything in this tutorial runs locally against Foundry’s in-memory EVM.
Step 1: Install Foundry and confirm the version
If you don’t already have Foundry installed, run the installer and update to the latest toolchain:
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge --version
You should see version 1.8.4 or newer in the output. If forge --version reports something older, run foundryup again. Foundry ships frequent point releases, and the cheatcodes used later in this tutorial, specifically vm.sign and the typed-data signing helpers, depend on a reasonably current build.
Step 2: Scaffold the project
Create a fresh Foundry project and pull in OpenZeppelin’s contracts as a dependency.
forge init permit-phishing-tests
cd permit-phishing-tests
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge remappings > remappings.txt
Delete the default Counter.sol and Counter.t.sol files that forge init generates. They’re not relevant here and will just clutter test output.
rm src/Counter.sol test/Counter.t.sol script/Counter.s.sol
Step 3: Build a minimal ERC20Permit token to test against
Create src/TestToken.sol. This wraps OpenZeppelin’s audited ERC20Permit implementation so you’re testing real, production-grade permit logic rather than a hand-rolled version that might hide its own bugs.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {ERC20Permit} from "openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract TestToken is ERC20, ERC20Permit {
constructor() ERC20("TestToken", "TTK") ERC20Permit("TestToken") {
_mint(msg.sender, 1_000_000 ether);
}
}
This is intentionally the audited, correct version. You’ll write tests against it first to confirm your test harness works, then introduce a deliberately broken variant in a later step so you can see the same tests catch real bugs.
Step 4: Set up EIP-712 signing helpers in your test base
Testing permit requires generating valid ECDSA signatures inside Foundry, which means replicating the EIP-712 typed-data hash the token contract expects. Create test/PermitTestBase.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {TestToken} from "../src/TestToken.sol";
abstract contract PermitTestBase is Test {
TestToken token;
uint256 ownerPrivateKey = 0xA11CE;
address owner = vm.addr(ownerPrivateKey);
address spender = address(0xB0B);
bytes32 constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
function setUp() public virtual {
vm.prank(owner);
token = new TestToken();
}
function _signPermit(
uint256 privateKey,
address _owner,
address _spender,
uint256 value,
uint256 deadline
) internal view returns (uint8 v, bytes32 r, bytes32 s) {
uint256 nonce = token.nonces(_owner);
bytes32 structHash = keccak256(
abi.encode(PERMIT_TYPEHASH, _owner, _spender, value, nonce, deadline)
);
bytes32 digest = keccak256(
abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)
);
(v, r, s) = vm.sign(privateKey, digest);
}
}
The key line is the digest construction: it must match the token’s DOMAIN_SEPARATOR() exactly, down to the chain ID and contract address baked into that separator. Get this wrong and every signature you generate will fail verification, which is actually a useful sanity check that your test harness is wired correctly before you start testing attack scenarios.
Step 5: Write the baseline happy-path test
Before testing exploits, confirm a correctly formed permit works. If this test fails, nothing downstream will make sense. Create test/PermitHappyPath.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {PermitTestBase} from "./PermitTestBase.sol";
contract PermitHappyPathTest is PermitTestBase {
function test_ValidPermitSucceeds() public {
uint256 value = 1_000 ether;
uint256 deadline = block.timestamp + 1 hours;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
token.permit(owner, spender, value, deadline, v, r, s);
assertEq(token.allowance(owner, spender), value);
assertEq(token.nonces(owner), 1);
}
}
Run it:
forge test --match-path "test/PermitHappyPath.t.sol" -vv
Expected output:
Ran 1 test for test/PermitHappyPath.t.sol:PermitHappyPathTest
[PASS] test_ValidPermitSucceeds() (gas: 61234)
Suite result: ok. 1 passed; 0 failed; 0 skipped
If this fails with a signature mismatch, double-check that vm.addr(ownerPrivateKey) matches the address you’re using in the _signPermit call, and that you’re reading nonces(owner) before signing, not after.
Step 6: Test deadline enforcement
An expired permit must revert, no exceptions. This is the single most common bug a drainer exploits when a contract uses a looser check than it should, for example deadline <= block.timestamp instead of deadline < block.timestamp, which allows a permit to execute in the exact same block it expired.
function test_ExpiredPermitReverts() public {
uint256 value = 1_000 ether;
uint256 deadline = block.timestamp - 1;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
vm.expectRevert();
token.permit(owner, spender, value, deadline, v, r, s);
}
function test_PermitAtExactDeadlineSucceeds() public {
uint256 value = 1_000 ether;
uint256 deadline = block.timestamp + 1 hours;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
vm.warp(deadline);
token.permit(owner, spender, value, deadline, v, r, s);
assertEq(token.allowance(owner, spender), value);
}
function test_PermitOneSecondPastDeadlineReverts() public {
uint256 value = 1_000 ether;
uint256 deadline = block.timestamp + 1 hours;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
vm.warp(deadline + 1);
vm.expectRevert();
token.permit(owner, spender, value, deadline, v, r, s);
}
Three tests, three boundary conditions. The second one matters as much as the first: a contract that’s too strict and rejects a permit at the exact deadline second will break legitimate relayer transactions that land in the last valid block, causing a different class of production bug that shows up as confused support tickets rather than stolen funds.
Step 7: Test nonce invalidation and replay resistance
A signature that works once must never work twice. This is what stops an attacker who intercepts a valid permit signature, through a malicious RPC, a compromised relayer, or simple mempool observation, from replaying it later.
function test_PermitCannotBeReplayed() public {
uint256 value = 1_000 ether;
uint256 deadline = block.timestamp + 1 hours;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
token.permit(owner, spender, value, deadline, v, r, s);
assertEq(token.nonces(owner), 1);
vm.expectRevert();
token.permit(owner, spender, value, deadline, v, r, s);
}
function test_NonceIncrementsEachPermit() public {
uint256 deadline = block.timestamp + 1 hours;
(uint8 v1, bytes32 r1, bytes32 s1) = _signPermit(ownerPrivateKey, owner, spender, 100 ether, deadline);
token.permit(owner, spender, 100 ether, deadline, v1, r1, s1);
assertEq(token.nonces(owner), 1);
(uint8 v2, bytes32 r2, bytes32 s2) = _signPermit(ownerPrivateKey, owner, spender, 200 ether, deadline);
token.permit(owner, spender, 200 ether, deadline, v2, r2, s2);
assertEq(token.nonces(owner), 2);
}
Note that the second permit sets allowance to 200 ether, not 300. The permit() function sets an absolute allowance, it doesn’t add to the existing one. That’s a common misunderstanding worth asserting explicitly in your suite, because contracts that assume additive behavior will miscalculate user balances under the hood.
Step 8: Test cross-field signature tampering
This is where most permit phishing testing stops short. It’s not enough to test that a valid signature works and an expired one fails. You need to confirm that altering any single field in an otherwise-valid signed message causes verification to fail, because a drainer kit’s entire business model depends on tricking a user into signing something subtly different from what they think they’re signing.
function test_WrongSpenderRejected() public {
uint256 value = 1_000 ether;
uint256 deadline = block.timestamp + 1 hours;
address attackerSpender = address(0xBAD);
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
// Signature was made for `spender`, submitting with `attackerSpender` must fail
vm.expectRevert();
token.permit(owner, attackerSpender, value, deadline, v, r, s);
}
function test_WrongValueRejected() public {
uint256 signedValue = 1_000 ether;
uint256 deadline = block.timestamp + 1 hours;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, signedValue, deadline);
// Attacker tries to submit a larger value than was signed
vm.expectRevert();
token.permit(owner, spender, signedValue * 10, deadline, v, r, s);
}
function test_WrongOwnerRejected() public {
uint256 value = 1_000 ether;
uint256 deadline = block.timestamp + 1 hours;
address notOwner = address(0xC0FFEE);
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
vm.expectRevert();
token.permit(notOwner, spender, value, deadline, v, r, s);
}
Each of these tests takes a validly-signed message and submits it with one field changed. OpenZeppelin’s implementation passes all three because the signed struct hash includes every field, so any mismatch produces a different digest and a different recovered signer. If you’re testing a custom permit implementation instead of OpenZeppelin’s, this is the exact set of tests most likely to expose a homegrown bug.
Step 9: Introduce a broken permit implementation and watch the tests catch it
Theory is less convincing than watching a test fail. Create src/VulnerableToken.sol with a deliberately weakened deadline check, the kind of off-by-one mistake that happens when someone copies permit logic by hand instead of inheriting OpenZeppelin’s contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract VulnerableToken is ERC20 {
mapping(address => uint256) public nonces;
bytes32 public constant PERMIT_TYPEHASH = keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
);
bytes32 public immutable DOMAIN_SEPARATOR;
constructor() ERC20("VulnerableToken", "VTK") {
_mint(msg.sender, 1_000_000 ether);
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"),
keccak256(bytes("VulnerableToken")),
block.chainid,
address(this)
)
);
}
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
// BUG: missing deadline check entirely
bytes32 structHash = keccak256(
abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)
);
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
address signer = ecrecover(digest, v, r, s);
require(signer == owner, "invalid signature");
_approve(owner, spender, value);
}
}
Now point your deadline tests at this contract instead, or write a parallel test file. The test_ExpiredPermitReverts style test will fail immediately:
Ran 1 test for test/VulnerableTokenPermit.t.sol:VulnerableTokenPermitTest
[FAIL: call did not revert as expected] test_ExpiredPermitReverts() (gas: 58412)
Suite result: FAILED. 0 passed; 1 failed; 0 skipped
That failure is the entire point of this tutorial. A permit signed an hour ago, with a deadline that’s long since passed, still executes successfully against VulnerableToken and sets a live allowance. In a real drainer scenario, the attacker simply holds a signed permit and submits it whenever convenient, with no urgency and no expiration pressure on their side at all.
Step 10: Test Permit2-style allowance limits
If your contract integrates with Uniswap’s Permit2 rather than implementing its own permit function, the test priorities shift slightly. Permit2 supports both one-time signature transfers and longer-lived allowance grants, and the latter is where unbounded exposure creeps in.
function test_Permit2AllowanceCannotExceedSignedAmount() public {
uint256 signedAmount = 500 ether;
uint256 deadline = block.timestamp + 30 minutes;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, signedAmount, deadline);
token.permit(owner, spender, signedAmount, deadline, v, r, s);
vm.prank(spender);
vm.expectRevert();
token.transferFrom(owner, spender, signedAmount + 1);
}
function test_ExpiredAllowanceStillUsable() public {
uint256 value = 500 ether;
uint256 deadline = block.timestamp + 30 minutes;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
token.permit(owner, spender, value, deadline, v, r, s);
// Fast-forward well past any reasonable allowance lifetime
vm.warp(block.timestamp + 365 days);
// This passes on a standard ERC20 allowance, documenting a real design gap:
// the permit's deadline only gated signing, not the resulting allowance's lifetime.
vm.prank(spender);
token.transferFrom(owner, spender, value);
}
That second test is written to pass, deliberately, to illustrate a real design gap: standard ERC20 allowances set via permit don’t expire on their own once granted, even though the permit signature that created them had a deadline. The deadline only gates the signature’s one-time use in calling permit(). It does not put an expiration on the resulting allowance. If your protocol needs allowances that actually expire, that logic has to be built separately, and testing for its absence is how you catch the gap before an attacker does.
Step 11: Fuzz test the signature verification logic
Manual test cases catch the bugs you thought to look for. Fuzzing catches the ones you didn’t. Foundry’s built-in fuzzer is well suited to hammering permit logic with randomized inputs:
function testFuzz_InvalidSignatureAlwaysReverts(
uint256 value,
uint256 deadlineOffset,
uint8 vCorruption
) public {
vm.assume(deadlineOffset < 365 days);
uint256 deadline = block.timestamp + deadlineOffset;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
uint8 badV = v == 27 ? 28 : 27;
if (vCorruption % 2 == 0) badV = v; // sometimes leave it valid as a control case
if (badV != v) {
vm.expectRevert();
}
token.permit(owner, spender, value, deadline, badV, r, s);
}
function testFuzz_PermitWithRandomAmountsSetsExactAllowance(uint96 value) public {
uint256 deadline = block.timestamp + 1 hours;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
token.permit(owner, spender, value, deadline, v, r, s);
assertEq(token.allowance(owner, spender), value);
}
Run the fuzz suite with a higher run count than Foundry’s 256-run default to get real confidence, especially before merging anything touching permit logic into a production branch:
forge test --match-test testFuzz --fuzz-runs 10000 -vv
Step 12: Wire it into CI so regressions get caught automatically
A test suite that only runs on your laptop protects nobody but you. Add a GitHub Actions workflow so every pull request runs the full permit test suite before merge.
name: permit-security-tests
on: [pull_request]
jobs:
foundry-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: foundry-rs/foundry-toolchain@v1
- run: forge test --match-path "test/Permit*.t.sol" -vvv
- run: forge test --match-test testFuzz --fuzz-runs 5000
Keep the fuzz run count lower in CI than you’d use locally, 5,000 instead of 10,000, to keep pipeline times reasonable. Run the deeper fuzz pass locally or on a nightly schedule instead.
Signature malleability: the ecrecover edge case most suites miss
There’s one more signature-level bug worth testing explicitly, and it’s subtle enough that even experienced teams skip it: ECDSA signature malleability. For any valid signature (v, r, s), there’s a second mathematically valid signature (v', r, s') that recovers to the exact same signer address, where s' = secp256k1_n - s and v' flips between 27 and 28. Both signatures are cryptographically legitimate. That means a contract tracking “has this exact signature been used” rather than “has this nonce been consumed” can be tricked into accepting what looks like a second, different authorization for the same underlying permit.
OpenZeppelin’s ERC20Permit implementation sidesteps this cleanly because it tracks state through the nonce, not through a signature hash or a used-signature set. Once a nonce is consumed, incrementing it invalidates every signature tied to that nonce, malleable or not. But if you’re reviewing a custom permit implementation, or any contract that checks something like usedSignatures[keccak256(abi.encode(v, r, s))] instead of relying purely on nonce state, that pattern is a real red flag worth a dedicated test.
function test_MalleableSignatureStillConsumesSameNonce() public {
uint256 value = 1_000 ether;
uint256 deadline = block.timestamp + 1 hours;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
// secp256k1 curve order n
uint256 n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
bytes32 sMalleable = bytes32(n - uint256(s));
uint8 vMalleable = v == 27 ? 28 : 27;
token.permit(owner, spender, value, deadline, v, r, s);
assertEq(token.nonces(owner), 1);
// The malleable counterpart must NOT be independently usable —
// the nonce it would need was already consumed above.
vm.expectRevert();
token.permit(owner, spender, value, deadline, vMalleable, r, sMalleable);
}
function test_HighSValueSignatureRejectedOrNormalized() public {
uint256 value = 1_000 ether;
uint256 deadline = block.timestamp + 1 hours;
(uint8 v, bytes32 r, bytes32 s) = _signPermit(ownerPrivateKey, owner, spender, value, deadline);
uint256 n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
uint256 halfN = n / 2;
// OpenZeppelin's ECDSA library rejects s-values above n/2 outright,
// which is the stricter and safer behavior. Confirm your implementation does too.
if (uint256(s) > halfN) {
vm.expectRevert();
token.permit(owner, spender, value, deadline, v, r, s);
}
}
Neither test is likely to fail against OpenZeppelin’s contracts, and that’s the expected result. The value of running them anyway is documentation: they prove, rather than assume, that your specific implementation doesn’t fall into this trap. If you ever swap out the permit implementation for a custom one, hand it off to a contractor, or upgrade a proxy’s logic contract, these tests catch a regression that a casual code review easily misses, since the buggy version and the safe version look nearly identical at a glance.
It’s also worth noting what this class of bug does not do: on its own, signature malleability rarely lets an attacker steal funds from a well-designed contract, because the nonce (not the raw signature bytes) is what actually gates reuse. Where it becomes dangerous is in systems that layer additional logic on top of “this signature was seen before,” such as off-chain relayer infrastructure deduplicating by signature hash, or a governance system counting malleable signature variants as distinct votes. Test for it anyway. The cost of the test is a few minutes. The cost of finding out the hard way is measured in the kind of headline this tutorial opened with.
Common pitfalls when testing permit and Permit2
Every one of these has shown up in real audit findings or real support threads. Check your own suite against this list before calling it done.
- Testing against a mock instead of real DOMAIN_SEPARATOR logic. A mock that stubs out signature verification entirely will pass every test and catch zero real bugs. Always test against the actual contract.
- Forgetting that permit() sets, not adds to, allowance. Tests that assume additive behavior will give false confidence about how much a spender can actually move.
- Only testing the exact-boundary deadline case in one direction. Test both “valid at deadline” and “invalid one second later.” Contracts get the boundary wrong in both directions.
- Not testing nonce behavior under reentrancy. If your contract calls out to an external contract between reading and incrementing the nonce, a reentrant call could reuse a nonce that should already be spent.
- Assuming Permit2 behaves like ERC-2612. Permit2’s transfer and allowance structures are different enough that copy-pasted ERC-2612 tests often test the wrong thing entirely.
- Skipping cross-field tampering tests. Teams test valid and expired signatures, then stop. The wrong-spender and wrong-value tests in Step 8 catch a different and more dangerous class of bug.
- Running fuzz tests at the 256-run default and calling it sufficient. That default is fine for quick iteration, not for a security-relevant code path going to mainnet.
- Not testing what happens when ecrecover returns the zero address. A malformed signature can cause
ecrecoverto return address zero rather than reverting. A naiverequire(signer == owner)check correctly rejects that, but only if owner is never the zero address itself. Test that explicitly.
Troubleshooting guide
| Symptom | Likely cause | Fix |
|---|---|---|
| “invalid signature” revert on a test that should pass | DOMAIN_SEPARATOR mismatch between your test helper and the contract | Log both separators with console.logBytes32 and compare byte-for-byte |
| forge test can’t find vm.sign | Outdated Foundry version | Run foundryup to update to 1.8.4 or later |
| Nonce assertions fail unexpectedly | Reading nonces(owner) after signing instead of before | Always fetch the current nonce before constructing the struct hash |
| Fuzz test runs but never reverts on bad input | vm.expectRevert() placed in the wrong order relative to the call | vm.expectRevert() must immediately precede the reverting call |
| Compilation error on ERC20Permit import path | Remappings not regenerated after installing dependencies | Re-run forge remappings > remappings.txt |
| Signature valid in one test file, invalid in another | Different block.chainid values between test contexts | Pin chain ID explicitly with vm.chainId() in setUp() |
| ecrecover returns an unexpected address | Struct hash fields in wrong order or wrong types | Compare your typehash string character-for-character against the EIP-2612 spec |
| Gas usage in permit tests looks abnormally high | Redundant storage reads inside the signing helper | Cache DOMAIN_SEPARATOR() and nonces() results instead of re-reading |
| CI passes locally but fails in GitHub Actions | Submodules not checked out | Add “submodules: recursive” to the checkout step |
| Permit2 tests fail against a standard ERC20Permit mock | Testing Permit2 allowance semantics against single-token permit logic | Use Permit2’s actual contract or a verified fork, not a hand-rolled substitute |
Advanced tips for production audits
Once the baseline suite passes, a few additional checks separate a thorough audit from a passable one.
Test permit behavior against a fork of mainnet state rather than only a fresh deployment. Use forge test --fork-url $MAINNET_RPC_URL against the actual deployed token if you’re auditing an integration rather than a new token, since storage layout quirks and proxy patterns in live contracts sometimes behave differently than a freshly compiled version in isolation.
Add invariant tests, not just unit tests, for any contract that holds Permit2 allowances long-term. Foundry’s invariant testing mode can run thousands of randomized call sequences against your contract and assert that a property, for example “total allowance granted never exceeds total balance at time of signing,” holds no matter what order functions are called in.
Check your frontend’s signature request payload, not just the contract. A contract can have perfect permit logic and still ship a frontend that constructs a malicious-looking or mislabeled signing request, which is functionally the same outcome as a drainer attack if a user can’t tell what they’re signing. This isn’t something Foundry tests directly, but it belongs on the same audit checklist.
Finally, cross-reference any custom permit logic against the Smart Contract Weakness Classification registry, which catalogs known vulnerability patterns including signature malleability and replay issues that predate and inform the EIP-2612 standard itself.
The complete working project
Putting every file from this tutorial together gives you this project structure:
permit-phishing-tests/
├── foundry.toml
├── remappings.txt
├── lib/
│ └── openzeppelin-contracts/
├── src/
│ ├── TestToken.sol
│ └── VulnerableToken.sol
└── test/
├── PermitTestBase.sol
├── PermitHappyPath.t.sol
├── PermitDeadline.t.sol
├── PermitReplay.t.sol
├── PermitTampering.t.sol
├── Permit2Allowance.t.sol
└── PermitFuzz.t.sol
Run the entire suite with verbose gas reporting to get a full picture of coverage and cost:
forge test --gas-report -vv
Expected summary output against the correct TestToken implementation:
Ran 6 test suites in 412.37ms
PermitHappyPathTest: 1 passed, 0 failed
PermitDeadlineTest: 3 passed, 0 failed
PermitReplayTest: 2 passed, 0 failed
PermitTamperingTest: 3 passed, 0 failed
Permit2AllowanceTest: 2 passed, 0 failed
PermitFuzzTest: 2 passed, 0 failed
Ran 6 test suites in 412.37ms: 13 tests passed, 0 failed, 0 skipped
When you point the deadline and replay tests at VulnerableToken.sol instead, expect at least one failure, confirming the suite is actually discriminating between secure and insecure implementations rather than passing regardless of what it’s testing against.
How permit phishing fits into the 2026 DeFi threat picture
Permit phishing testing is one slice of a much larger problem. The scale of the broader issue is worth putting in context.
| Metric | 2024 | 2025 |
|---|---|---|
| Total wallet-drainer losses | ~$494 million | $83.85 million |
| Victims affected | ~332,000 | 106,106 |
| Incidents over $1 million | 30 | 11 |
| Largest single theft | Not directly comparable | $6.5 million (Sept. 2025, permit-style signature) |
| Share of $1M+ losses tied to Permit/Permit2 | Not reported in this dataset | 38% |
Year-over-year, total losses fell roughly 83% and victim counts dropped about 68%, according to Scam Sniffer’s 2025 report. That’s genuinely good news, and it reflects better wallet-level warnings and more cautious users. But the 38% figure is the number worth sitting with: even as the overall drainer ecosystem shrank, permit-based signatures kept their grip on a disproportionate share of the highest-value thefts. Q3 2025 was the worst quarter of the year by dollar amount, at roughly $31.04 million across about 40,000 victims, while December 2025 was the calmest month at around $2.04 million.
That pattern lines up with a broader shift documented across DeFi security research: signature and key-related attack vectors, not just raw code bugs, increasingly account for the largest single losses. A bug bounty or static analyzer can catch a reentrancy flaw in deployed bytecode. Neither can stop a user from signing a message they didn’t fully understand. That’s precisely the gap this tutorial’s test suite is built to narrow from the contract side, by making sure that even if a user signs something they shouldn’t have, the contract’s own deadline, nonce, and field-matching logic gives an attacker the smallest possible window to exploit it.
Static analysis tools remain useful here too, but as a complement rather than a substitute. A linter can flag an obviously missing deadline check in two seconds. It generally can’t simulate an attacker submitting a tampered signature field across a dozen boundary conditions, which is exactly the class of bug Foundry’s dynamic testing is built to catch. Run both.
If your contracts interact with Permit2 specifically, read through Uniswap’s Permit2 repository directly rather than relying solely on secondary summaries. The allowance and witness-transfer patterns it implements have enough surface area that a tutorial of this length can only cover the core cases, not every edge case Permit2’s design introduces. The official EIP-2612 specification is similarly worth reading end to end before shipping a custom implementation, and OpenZeppelin’s ERC20Permit documentation is the reference implementation this entire tutorial builds on.
Frequently asked questions
What’s the difference between testing permit and testing a normal approve() function?
A normal approve() call requires the token owner to send a transaction, which means the wallet has full context: sender, recipient, gas cost, and usually a preview of what’s being approved. Permit moves that authorization off-chain into a signed message, so your tests need to cover the signature verification layer itself, including domain separator correctness, nonce handling, and deadline enforcement, in addition to the allowance logic that approve() testing already covers.
Do I need to test against Permit2 if my token only implements ERC-2612?
Not directly, but you should still know whether any protocol your token integrates with routes through Permit2 for its approvals. A token that correctly implements ERC-2612 in isolation can still end up exposed through a Permit2 integration elsewhere in a user’s wallet history, since Permit2’s allowance is granted once and can be reused across multiple protocols.
How many fuzz runs are actually enough for permit logic?
There’s no universal number, but 10,000 or more runs for a pre-mainnet audit is a reasonable floor given how cheap each EVM call is in Foundry’s test environment. Scale up further if the contract will hold significant value or if you’re testing a custom permit variant rather than OpenZeppelin’s audited implementation.
Can Foundry tests catch frontend-level permit phishing, like a malicious signing prompt?
No. Foundry tests the contract layer, meaning what a given signature authorizes once submitted on-chain. It has no visibility into what a frontend displays to a user before they sign. Frontend-level drainer protection requires separate review of the signing UI and, ideally, wallet-level warnings for permit-type signature requests.
Why did wallet-drainer losses fall 83% in 2025 if permit phishing is still a problem?
Scam Sniffer’s 2025 report attributes part of the decline to improved wallet-level warnings and broader user awareness, not to the underlying attack vector disappearing. The report still identified 11 incidents above $1 million during the year, with Permit and Permit2 signatures tied to 38% of those losses, indicating the method remains effective against less cautious or less well-protected victims even as aggregate activity cooled.
Should every ERC-20 token implement ERC-2612 permit?
Not automatically. Permit adds genuine UX value for tokens used in frequent swaps or deposits where gasless approval matters, but it also adds signature-verification attack surface that a simpler token without permit doesn’t have. Weigh that tradeoff against actual usage patterns rather than adding permit support by default.
What Foundry version should I pin in CI for this test suite?
Pin to 1.8.4 or later, matching the version used throughout this tutorial. Older Foundry releases handle the vm.sign cheatcode slightly differently, and pinning avoids a CI pipeline silently picking up a newer or older toolchain than the one your tests were written against.
Is this test suite enough on its own to call a contract secure?
No single test suite makes that claim responsibly. This tutorial covers the permit-specific attack surface thoroughly, but a full audit also needs reentrancy, access control, and economic-logic review covering the rest of the contract. Treat this suite as one necessary layer of a broader audit process, not the whole process.




