Foundry Invariant Tests: How to Catch the Batch Bypass
On the pre-fix ERC-7943 reference code, two randomized invariant tests and one regression test failed. How does the handler catch the bypass?

IN 30 SECONDS
A Foundry invariant test is a test where a tool fires random calls at a contract and checks your rules after each one. On the pre-fix ERC-7943 reference code, my run’s failures shrank to a handful of calls.
KEY TAKEAWAYS · 3
- The handler uses just two token ids, so repeats come up often.
- It asks canTransfer about each id’s total, as the sender, and flags any batch that still went through.
- On the reference contract before the fix, all three tests fail. With PR #2045 applied, all three pass.
On this page
How to fuzz a token’s transfer rules with Foundry
IN SHORT A small handler sends random batches over just two token ids and flags any transfer that went through after canTransfer said no.
WORDS TO KNOW
- Invariant test
- A test where a tool fires random calls at a contract and checks a rule after each one.
- Handler
- The test contract that makes the calls for the tool.
- Ghost flag
- A test-only variable that remembers what the contract doesn’t record.
An Invariant test A test where a tool fires random calls at a contract and checks a rule after each one. See the glossary checks two rules after every call. Nothing executes that canTransfer refused for an id’s total, though execution may still be stricter.
WATCH OUT
Second rule: holder transfers never dig into frozen tokens, if the balance covered them at the start. A freeze may exceed the balance: then they must not widen that gap. Forced transfers and privileged burns need their own rule.
The Handler The test contract that makes the calls for the tool. See the glossary makes the calls, as the holder.
STEP BY STEP
Step 1. Set up. Ids 1 and 2 each start at 100, with 60 frozen.
- Set up Ids 1 and 2 each start at 100, with 60 frozen.
- Build a batch One to four entries, ids drawn from 1 and 2, amounts from 0 to 100, sent to another account or back to the holder.
- Ask the view As the holder, ask canTransfer about each id’s total. The standard lets its answer depend on the caller (msg.sender). So the view and the send use the same one.
- Send Send the batch inside try/catch, so a refusal doesn’t stop the run.
- Record If the batch went through after the view said no, set a flag.
- Check After every call, Foundry checks both rules.
IN PLAIN WORDS
For this post (not on client code), I ran the file from the assets/erc-7943 folder of the ERCs repository, with its Foundry config (solc 0.8.29, OpenZeppelin v5.1.0). Against master, the main branch, on 7 October 2026, all three tests fail.
Foundry then shrinks each failing sequence. In this run (sequences vary by seed), 225 calls became five for the frozen-balance rule (“frozen tokens left: 59 < 60”). For the view rule, 70 calls became one.
128,000
calls per invariant, PR #2045 applied
Source: my run of 7 Oct 2026, PR unmerged then.
With PR #2045’s changes applied (unmerged as of 7 October 2026), all three pass, over 256 runs per invariant. So do the folder’s existing tests (222 with the PR’s own additions).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.29;
import {Test} from "forge-std/Test.sol";
import {uRWA1155} from "../contracts/uRWA1155.sol";
contract BatchHandler is Test {
uRWA1155 public immutable token;
address public immutable holder;
address public immutable dest;
bool public viewSaidNoButExecuted; // ghost variable
constructor(uRWA1155 token_, address holder_, address dest_) {
token = token_;
holder = holder_;
dest = dest_;
}
function batch(uint256 len, bool toSelf, uint256[4] calldata seedIds, uint256[4] calldata seedValues) external {
address to = toSelf ? holder : dest;
uint256 n = bound(len, 1, 4);
uint256[] memory ids = new uint256[](n);
uint256[] memory values = new uint256[](n);
uint256[3] memory total;
for (uint256 i; i < n; ++i) {
ids[i] = 1 + (seedIds[i] % 2);
values[i] = bound(seedValues[i], 0, 100);
total[ids[i]] += values[i];
}
vm.startPrank(holder);
bool allowed1 = token.canTransfer(holder, to, 1, total[1]);
bool allowed2 = token.canTransfer(holder, to, 2, total[2]);
try token.safeBatchTransferFrom(holder, to, ids, values, "") {
if (!allowed1 || !allowed2) viewSaidNoButExecuted = true;
} catch {}
vm.stopPrank();
}
}
contract FrozenBalanceInvariantTest is Test {
uRWA1155 internal token;
BatchHandler internal handler;
address internal admin = makeAddr("admin");
address internal holder = makeAddr("holder");
address internal dest = makeAddr("dest");
function setUp() public {
token = new uRWA1155("ipfs://meta/{id}.json", admin);
vm.startPrank(admin);
token.changeSendWhitelist(holder, true);
token.changeReceiveWhitelist(holder, true);
token.changeReceiveWhitelist(dest, true);
for (uint256 id = 1; id <= 2; ++id) {
token.mint(holder, id, 100);
token.setFrozenTokens(holder, id, 60);
}
vm.stopPrank();
handler = new BatchHandler(token, holder, dest);
targetContract(address(handler));
}
function invariant_viewMatchesExecution() public view {
assertFalse(handler.viewSaidNoButExecuted(), "batch executed what canTransfer refused");
}
function invariant_frozenTokensStayPut() public view {
for (uint256 id = 1; id <= 2; ++id) {
assertGe(token.balanceOf(holder, id), token.getFrozenTokens(holder, id), "frozen tokens left");
}
}
function test_duplicateIdsCannotSpendFrozenTokens() public {
uint256[] memory ids = new uint256[](2);
uint256[] memory values = new uint256[](2);
ids[0] = 1;
ids[1] = 1;
values[0] = 40;
values[1] = 40;
assertFalse(token.canTransfer(holder, dest, 1, 80));
vm.prank(holder);
vm.expectRevert();
token.safeBatchTransferFrom(holder, dest, ids, values, "");
}
}ITEM GET
Few ids, same caller, compare with the view
WATCH OUT
The pattern carries over to other ERC-7943 multi-tokens, such as one built on ERC-6909. Adapt the types, the setup and the transfer call. Who may whitelist, mint and freeze varies by implementation.
Sources: Foundry invariant testing, ERCs PR #2045 (open as of 7 Oct 2026), uRWA1155 reference contract.
FAQ
Use a handler that sends random batches over a few token ids, then assert after every call that nothing ran which canTransfer refused.
Not when I checked on 7 October 2026: PR #2045 was open and unmerged, with no reviews yet. Check the PR page for its current state.
STAGE CLEAR
What you now know
- Why the view and the transfer must run under the same caller.
- What a failing run on the old code and a passing run with the fix look like.
Check yourself
Tap or click a question to see the answer.
You let the fuzzer draw any 256-bit token id. Why might the test stay green on the buggy code?
Repeats would be rare, so a batch with the same id twice might never come up during the run.
You finished every stage published so far in this series. Back to the series map
Former CTO of Integra. Named author (1 of 5) of ERC-3643, first author of ERC-6960, co-author of ERC-7410, and co-author of ERC-8203, which is still a draft. Building production AI and regulated Web3 systems.
Have a project to ship or a role to fill?
Discuss your project or roleFollow the engineering notes
Get new articles on production AI and smart contracts by email.