When we say "ERC-20 token," we assume a contract that behaves according to the EIP-20 specification. But assume is the operative word. In February 2026, a non-standard transferFrom implementation in the DGLD gold-backed token let attackers mint 100 million unbacked tokens on Base — against a legitimate circulating supply of just 70.8 tokens on that chain.
The vulnerability wasn't in the bridge. It wasn't in the L2. It was in a transferFrom function that returned true without actually moving tokens. And it had passed two independent audits.
This article isn't about DGLD specifically — it's about the ERC-20 compliance gap that exists across thousands of deployed tokens, and the concrete audit techniques that catch it.
The ERC-20 Trust Contract
Every protocol that integrates external tokens makes implicit assumptions:
transferFrom(from, to, amount) returns true
→ "amount" tokens moved from "from" to "to"
→ balanceOf(from) decreased by amount
→ balanceOf(to) increased by amount
The EIP-20 spec says transferFrom SHOULD throw on failure. It says nothing about what happens when a contract returns true but doesn't enforce the transfer. This ambiguity has created at least three distinct exploit patterns since 2022.
Pattern 1: Silent Success (DGLD, Feb 2026)
The DGLD Ethereum contract inherited legacy code where transferFrom could execute successfully — returning true to the caller — without enforcing that the token balance actually changed. The Ethereum↔Base bridge (built on OP Stack) interpreted that true as confirmation that tokens were locked on L1, and minted the corresponding representation on L2.
The attack flow:
Attacker → DGLD.transferFrom(attacker, bridge, 100M)
→ Returns true (no revert)
→ But attacker's balance: unchanged
→ Bridge sees: "100M DGLD deposited"
→ Base mints: 100M unbacked DGLD
→ Attacker dumps on Aerodrome DEX → drains USDC pools
The bridge did nothing wrong by its own logic. It called a standard ERC-20 function, got a success response, and acted accordingly. The fault was in the semantic gap between what transferFrom promised and what it delivered.
Pattern 2: Fee-on-Transfer Deflation
Tokens like USDT and STA apply transfer fees, meaning balanceOf(to) increases by less than amount. Protocols that don't check post-transfer balances end up with phantom accounting:
// VULNERABLE: Assumes full amount arrived
function deposit(address token, uint256 amount) external {
IERC20(token).transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount; // May be more than actually received
}
Pattern 3: Return Value Amnesia
Some tokens (notably older USDT on Ethereum) don't return a boolean at all. Calling them with IERC20(token).transferFrom(...) reverts at the ABI decoding level if the contract expects a bool return. This is why OpenZeppelin's SafeERC20 exists — but many protocols still don't use it.
The Audit Gap: Why This Keeps Slipping Through
The DGLD vulnerability passed two independent audits and an external review in Q4 2025. How?
1. Inherited Code Blind Spots
The vulnerable transferFrom logic came from a Consensys implementation deployed in February 2022. Four years of production use created a false sense of security. Auditors focused on newer code (the Base deployment logic) and treated the ERC-20 base as "battle-tested."
Lesson: Legacy code isn't safe code. Every audit should include the token's core transfer functions, regardless of how long they've been deployed.
2. Specification vs. Implementation Drift
The EIP-20 spec is deliberately minimal. It says:
-
transferFromSHOULD throw if the sender doesn't have enough tokens - It MUST fire a
Transferevent
It does NOT say:
-
transferFromMUST revert on insufficient balance (SHOULD ≠ MUST) - The return value MUST accurately reflect whether tokens moved
- The function MUST be atomic (balance changes happen or they don't)
Any contract that exploits these gaps is "ERC-20 compliant" by the letter of the spec while being functionally broken.
3. Integration Context Matters
A transferFrom that returns true without moving tokens is harmless in isolation. It only becomes exploitable when another contract trusts that return value to gate a consequential action (minting, unlocking, accounting). Auditors reviewing the token contract alone might flag it as "non-standard but not exploitable." Auditors reviewing the bridge alone see a standard ERC-20 integration. Neither catches the composition.
The 7-Point ERC-20 Compliance Audit Checklist
After analyzing every ERC-20 integration exploit from 2022-2026, here's the minimum verification set:
1. Balance Delta Verification
Never trust the return value. Check actual balance changes:
function _safeTransferIn(
address token,
address from,
uint256 amount
) internal returns (uint256 received) {
uint256 balBefore = IERC20(token).balanceOf(address(this));
IERC20(token).safeTransferFrom(from, address(this), amount);
received = IERC20(token).balanceOf(address(this)) - balBefore;
require(received > 0, "Zero transfer");
}
This single pattern prevents phantom deposits, fee-on-transfer accounting errors, and rebasing token surprises.
2. Return Value Handling
Use SafeERC20 for all external token calls. It handles:
- Tokens that return
bool(standard) - Tokens that return nothing (USDT-style)
- Tokens that revert on failure
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
// Handles all three return patterns
token.safeTransferFrom(from, to, amount);
3. Allowance Behavior Testing
Some tokens (USDT again) require allowance to be reset to 0 before setting a new value. Test for:
function testAllowanceReset() public {
token.approve(spender, 100);
// Some tokens revert here if allowance != 0
token.approve(spender, 200);
}
Use safeIncreaseAllowance / forceApprove to handle this.
4. Reentrancy via Transfer Hooks
ERC-777 tokens and some ERC-20 tokens with hooks (like stETH's rebase) can trigger reentrancy through tokensReceived callbacks:
// VULNERABLE to ERC-777 reentrancy
function deposit(address token, uint256 amount) external {
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
// Attacker's tokensReceived() hook fires HERE
// State not yet updated
balances[msg.sender] += amount;
}
// SAFE: CEI pattern
function deposit(address token, uint256 amount) external nonReentrant {
balances[msg.sender] += amount; // State update first
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
// Hook fires after state is consistent
}
5. Decimals Assumption Testing
Not all tokens use 18 decimals. USDC uses 6. Some use 2 or 8. Test arithmetic with varying decimal counts:
function testFuzz_decimalVariance(uint8 decimals) public {
vm.assume(decimals <= 24);
MockToken token = new MockToken(decimals);
uint256 amount = 1000 * 10**decimals;
// Verify your math doesn't overflow/underflow
protocol.deposit(address(token), amount);
assertEq(protocol.getBalance(address(token)), amount);
}
6. Max Supply / totalSupply Manipulation
Rebasing tokens (stETH, aTokens) change balances without transfers. Elastic supply tokens can inflate totalSupply. Test that your protocol doesn't depend on:
-
totalSupplybeing constant -
balanceOfnot changing between transactions - Transfer amounts equaling balance deltas
7. Blocklist / Pausability Awareness
USDC, USDT, and many tokens can freeze individual addresses or pause all transfers. Your protocol should handle:
-
transferFromreverting unexpectedly - Funds becoming temporarily locked
- Withdrawal functions that don't brick when one token is paused
Automated Detection: Foundry Integration Tests
Here's a reusable Foundry test suite for ERC-20 compliance verification:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
abstract contract ERC20ComplianceTest is Test {
IERC20 public token;
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
// Override in concrete test to set the token
function _setupToken() internal virtual;
function setUp() public {
_setupToken();
}
function test_transferFromActuallyMovesTokens() public {
uint256 amount = 1000;
deal(address(token), alice, amount);
vm.prank(alice);
token.approve(bob, amount);
uint256 aliceBefore = token.balanceOf(alice);
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
bool success = token.transferFrom(alice, bob, amount);
if (success) {
assertEq(
token.balanceOf(alice),
aliceBefore - amount,
"transferFrom returned true but sender balance unchanged"
);
assertGe(
token.balanceOf(bob),
bobBefore,
"transferFrom returned true but receiver lost tokens"
);
}
}
function test_transferFromExceedingBalanceReverts() public {
deal(address(token), alice, 100);
vm.prank(alice);
token.approve(bob, type(uint256).max);
uint256 aliceBal = token.balanceOf(alice);
vm.prank(bob);
try token.transferFrom(alice, bob, aliceBal + 1) returns (bool success) {
assertFalse(
success,
"CRITICAL: transferFrom succeeded with insufficient balance"
);
assertEq(token.balanceOf(alice), aliceBal, "Balance changed on failed transfer");
} catch {
// Reverting is the expected safe behavior
}
}
function test_feeOnTransferDetection() public {
uint256 amount = 10000;
deal(address(token), alice, amount);
vm.prank(alice);
token.approve(bob, amount);
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
token.transferFrom(alice, bob, amount);
uint256 received = token.balanceOf(bob) - bobBefore;
if (received < amount) {
emit log_named_uint("FEE-ON-TRANSFER DETECTED. Fee", amount - received);
}
}
}
Running Against Live Tokens
# Fork mainnet and test against real USDT
forge test --match-contract USDTComplianceTest \
--fork-url $ETH_RPC_URL \
--fork-block-number 19500000 -vvv
Bridge-Specific Defenses
The DGLD exploit specifically targeted the L1→L2 bridge trust boundary. For bridge developers:
Defense 1: Balance Delta at the Bridge Level
function depositERC20(
address token,
uint256 amount,
uint32 destChainId
) external {
uint256 balBefore = IERC20(token).balanceOf(address(this));
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
uint256 actualDeposited = IERC20(token).balanceOf(address(this)) - balBefore;
// Mint on L2 based on ACTUAL deposit, not claimed amount
_sendCrossChainMessage(destChainId, msg.sender, token, actualDeposited);
}
Defense 2: Token Allowlisting with Compliance Verification
struct TokenConfig {
bool allowed;
bool feeOnTransfer;
bool rebasable;
uint256 maxSingleDeposit;
uint256 dailyDepositCap;
}
mapping(address => TokenConfig) public tokenConfigs;
modifier onlyCompliantToken(address token) {
require(tokenConfigs[token].allowed, "Token not allowlisted");
_;
}
Defense 3: Cross-Chain Balance Reconciliation
Run continuous reconciliation between L1 locked balances and L2 minted supply:
# Simplified reconciliation check
def reconcile(token_l1, token_l2, bridge_l1):
locked_l1 = token_l1.balanceOf(bridge_l1)
minted_l2 = token_l2.totalSupply()
delta = abs(locked_l1 - minted_l2)
threshold = locked_l1 * 0.001 # 0.1% tolerance for timing
if delta > threshold:
alert(f"RECONCILIATION FAILURE: L1={locked_l1}, L2={minted_l2}")
pause_bridge()
DGLD's monitoring detected the anomaly within ~2.5 hours. With real-time reconciliation, this window could have been minutes.
Key Takeaways
Never trust ERC-20 return values alone. Always verify with balance deltas. The
transferFrom → true → tokens movedassumption has failed repeatedly.Audit inherited code with the same rigor as new code. DGLD's vulnerability was 4 years old, passed 2 audits, and lived in "battle-tested" Consensys code. Legacy ≠ safe.
Composition creates vulnerabilities that isolation doesn't reveal. A non-standard
transferFromis harmless alone but catastrophic when a bridge trusts its return value. Audit at the integration boundary, not just the component level.Every token integration is a trust boundary. When your contract calls
transferFromon an external token, you're trusting that token's implementation. Verify, don't trust.Balance delta checks are the single highest-ROI defensive pattern in DeFi. They defend against phantom deposits, fee-on-transfer errors, rebasing surprises, and non-standard implementations — all in one pattern.
This research is part of the DeFi Security Research series by DreamWork Security. Follow for weekly deep dives into smart contract vulnerabilities, audit techniques, and defensive patterns.
Top comments (0)