Skip to content

feat(checks): ERC-777 reentrancy via tokensReceived hook (closes #19)#26

Merged
abhicris merged 1 commit into
mainfrom
kcolb/erc777-reentrancy-check
May 25, 2026
Merged

feat(checks): ERC-777 reentrancy via tokensReceived hook (closes #19)#26
abhicris merged 1 commit into
mainfrom
kcolb/erc777-reentrancy-check

Conversation

@abhicris

Copy link
Copy Markdown
Contributor

Summary

Adds a check + vulnerable fixture for the ERC-777 tokensReceived reentrancy class — the same bug that drained imBTC and Uniswap V1 in April 2020 (~$340K). It is structurally identical to classic ETH reentrancy but routes through the ERC-777 recipient hook, so detectors that only watch for low-level receive() callbacks miss it.

Closes #19.

Files

  • src/checks/ERC777ReentrancyCheck.sol — abstract check, mirrors ReentrancyCheck.sol's shape; attacker reimplements IERC777Recipient.tokensReceived
  • src/examples/VulnerableERC777Vault.sol — minimal MockERC777Token (calls tokensReceived on contract recipients) + VulnerableERC777Vault (pays before settling balance)
  • test/ERC777Reentrancy.t.sol — example audit harness wiring check ↔ vault

Override hooks

Hook Purpose
getWithdrawCalldata() Calldata that triggers transfer-out from the target
getERC777Token() The token whose tokensReceived is hooked
getDepositValue() Default 1 ether
performDeposit(address, uint256) Match your target's deposit flow

CI behavior

ExampleERC777ReentrancyAudit matches the existing ^(Example|TestERC4626) exclusion in .github/workflows/audit.yml. The example is a demo-of-detection (it calls fail() when the vulnerability is detected — that is the check working), not a runnable invariant. forge build still validates compilation.

Test plan

  • CI: forge build succeeds
  • CI: forge test --no-match-contract '^(Example|TestERC4626)' stays green
  • Local: forge test --match-contract ExampleERC777ReentrancyAudit -vvv shows the vulnerability detection log
  • Override the check against a non-vulnerable ERC-777 vault (with nonReentrant) → check passes

Adds a check + vulnerable fixture for the ERC-777 tokensReceived
reentrancy class — the same vulnerability that drained imBTC and
Uniswap V1 in April 2020 ($340K) and several smaller venues since.

The bug class is identical to classic ETH reentrancy, but routes
through the ERC-777 recipient hook instead of receive(). Tools that
look only for low-level receive() callbacks miss it.

New files:
- src/checks/ERC777ReentrancyCheck.sol — abstract check + attacker
- src/examples/VulnerableERC777Vault.sol — fixture: mock ERC-777
  token (hook-on-transfer) + vault that pays out before settling
- test/ERC777Reentrancy.t.sol — example wiring (excluded from CI
  forge test per ci.yml --no-match-contract '^(Example|...)';
  this is a demo, not an invariant — drained-balance > deposit
  triggers fail() so the run surfaces the bug)

Override hooks parallel ReentrancyCheck.sol:
  getWithdrawCalldata()  / getERC777Token() / getDepositValue() /
  performDeposit(address, uint256)
Comment on lines +77 to +85
function withdraw() external {
uint256 owed = deposits[msg.sender];
require(owed > 0, "no deposit");

// External call (with hook) BEFORE state update — vulnerable.
token.transfer(msg.sender, owed);

deposits[msg.sender] = 0; // Too late — attacker already re-entered.
}

/// @dev Attacker contract. Re-enters `withdraw` via the tokensReceived hook.
contract ERC777ReentrantAttacker is IERC777Recipient {
address public target;
address public target;
bytes public payload;
uint256 public reentries;
uint256 public maxReentries = 2;
/// (recipient hook before settlement) are identical.
contract MockERC777Token {
mapping(address => uint256) public balanceOf;
string public name = "Mock 777";
contract MockERC777Token {
mapping(address => uint256) public balanceOf;
string public name = "Mock 777";
string public symbol = "M777";
Comment on lines +77 to +85
function withdraw() external {
uint256 owed = deposits[msg.sender];
require(owed > 0, "no deposit");

// External call (with hook) BEFORE state update — vulnerable.
token.transfer(msg.sender, owed);

deposits[msg.sender] = 0; // Too late — attacker already re-entered.
}
@abhicris abhicris merged commit 50eb23e into main May 25, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[good first issue] Add ERC-777 reentrancy test template (tokensReceived hook)

2 participants