ERC-6909 in production: what the spec doesn't tell you
· 11 min read
- ERC-6909
- token accounting
- production engineering
Uniswap v4 put ERC-6909 at the core of its PoolManager and pushed billions in volume through it before the standard reached Final. Before launch, OpenZeppelin's audit uncovered a specification discrepancy in transferFrom that forced the EIP itself to be rewritten. Cyfrin identified an accounting mismatch between ERC-20 and ERC-6909 that could drain entire pools. The standard pitched as minimal and gas efficient turned out to have production failure modes that no spec review would catch. After shipping ERC-6909 as the accounting layer of the 15 contract settlement protocol I built at Portal to Bitcoin, here's what the specification doesn't prepare you for, and what I learned the hard way about building on a standard that was still being written.
The Promise and the Gap
ERC-6909 looked like the obvious upgrade from ERC-1155. Strip the mandatory callbacks, remove batch operations, replace the single operator scheme with granular per token allowances, and the result is a multitoken standard that compiles smaller and executes cheaper. The design rationale is sound. JT Riley and the coauthors identified real pain in ERC-1155, and the fix is genuinely elegant: three mappings, a handful of functions, and an interface that does exactly what multitoken management requires, nothing more.
The gap between the specification and production is where it gets interesting.
ERC-1155's callbacks were annoying, but they served a purpose: contracts receiving tokens got notified. onERC1155Received was the mechanism that let vault contracts, staking pools, and any contract based recipient react to incoming deposits. ERC-6909 removed callbacks entirely. The "safe" prefix disappeared from transfer function names because, as the authors correctly noted, calling arbitrary external code during a transfer isn't safe at all.
But this creates a problem. Any contract that previously relied on transfer callbacks for accounting now needs an alternative notification mechanism. We hit this on testnet: our staking contract silently accepted ERC-6909 deposits without updating internal reward tracking. The tokens arrived. The state didn't update. The fix was straightforward, requiring explicit deposit functions instead of relying on transfer hooks, but the failure mode was completely silent. No revert, no error, no indication that accounting was broken until we caught it in our reconciliation checks.
The transferFrom Bug That Rewrote the Spec
The most revealing production failure in ERC-6909's short history isn't a vulnerability in anyone's implementation. It's a flaw in the specification itself.
OpenZeppelin audited Uniswap v4's core contracts in 2024. One finding stood out: the ERC-6909 specification stated that transferFrom MUST revert when the caller is not an operator for the sender and the caller's allowance is insufficient. Read literally, this means if msg.sender calls transferFrom with themselves as the sender, and they haven't set themselves as an operator, the call should revert, even though a user transferring their own tokens is the most basic operation imaginable.
Uniswap's implementation did the sensible thing: it skipped the operator and allowance checks when sender == msg.sender. The specification said this was wrong. The implementation was right.
The resolution tells you everything about the current state of ERC-6909: the EIP was updated to match the implementation, not the other way around. The team reached out to the EIP-6909 creators, and the specification was rewritten. A draft standard deployed in production, audited by one of the industry's top firms, found to conflict with its own reference implementation, and the spec was patched.
This is the reality of building on a draft EIP. The specification is a living document, and production deployments are the actual test suite.
Three Silent Accounting Failures
The deeper production risks in ERC-6909 aren't in the standard itself. They emerge at the boundary between ERC-6909 and the systems it integrates with.
The ERC-20/ERC-6909 Mismatch
Cyfrin's security analysis of Uniswap v4 hooks identified a critical severity finding (C-05) rooted in mixed accounting. When a hook uses both raw ERC-20 transfers and ERC-6909 claim tokens to track the same underlying value, the two accounting systems can diverge. An attacker can use LP tokens from one pool, stored in the target contract, as currency in a recursive malicious pool. The result: draining the underlying pool currency.
The attack works because ERC-6909 and ERC-20 track balances independently. One asset, two ledgers. The ERC-20 contract tracks who holds the actual tokens, the ERC-6909 layer tracks claims against them, and nothing in either standard forces the two to agree. A malicious pool that accepts the victim pool's LP tokens as its own currency lets the attacker recycle value the target contract already holds: each pass through the recursive pool mints fresh ERC-6909 claims against ERC-20 balance that was never actually deposited, and the attacker exits through whichever ledger is now overstated. As Cyfrin noted, hooks running custom accounting "take control of the underlying liquidity and any bug in the business logic of these hooks, or other related contracts that store or handle ERC-6909 claim tokens, is likely to be catastrophic."
In our own protocol, I made the architectural call early: never mix ERC-20 and ERC-6909 accounting for the same asset within the same contract. Every token either flows through ERC-6909 or through direct ERC-20 transfers, never both. The constraint cost us flexibility in hook design, but it eliminated an entire class of accounting vulnerabilities. The rejected alternative, unified flexible accounting, is exactly the surface C-05 exploits.
The Native Token Double Count
OpenZeppelin found the single most dangerous ERC-6909 adjacent vulnerability in the entire Uniswap v4 codebase: a critical severity double counting exploit on chains where the native token also has an ERC-20 representation, Celo being the canonical example.
The attack: after syncing balances, an attacker calls settle with msg.value > 0 to increase the delta for the native token. Then they call settle with the ERC-20 token's address. Because the ERC-20 balance already increased from the native token deposit, the attacker inflates the delta without transferring actual tokens. They withdraw more than they deposited. Pool drained.
OpenZeppelin's audit surfaced this critical double count. The fix (PR #779) enforced strict sync then settle ordering so the double credit path reverts, but the vulnerability illustrates a production reality: ERC-6909's flash accounting model creates new attack surfaces that don't exist in traditional token transfer patterns. The delta resolution system, where every positive and negative must net to zero before the transaction completes, is elegant in theory. In production, it's a constraint that adversarial actors probe from every angle.
The Dust That Accumulates
Flash accounting requires every delta to resolve to zero. In practice, rounding introduces dust, tiny residual amounts that fail to zero out. A single transaction might leave 1 wei unresolved. Across millions of transactions, that dust accumulates into a reconciliation problem.
The specification doesn't address rounding behavior. Each implementation handles it differently, and the differences compound. In our protocol, we built explicit dust tolerance thresholds into the settlement logic after discovering that strict zero resolution was causing reverts on trades involving tokens with nonstandard decimal configurations.
The Permission Model Roulette
ERC-6909's hybrid permission system is its most underappreciated footgun.
The standard defines two permission paths: per token allowances (like ERC-20) and global operator status. When transferFrom executes, it checks whether the caller is an operator for the sender. If yes, the transfer proceeds without touching allowances. If no, it checks and decrements the per token allowance.
The spec leaves the check ordering unconstrained. When an account has both operator status AND a per token allowance, the behavior depends on implementation. OpenZeppelin's implementation, Solady's implementation, and Solmate's implementation make different choices about which check takes priority. The same user action produces different allowance state across libraries.
This matters in production when contracts compose. A contract built against OpenZeppelin's check ordering deployed alongside a contract built against Solady's will produce inconsistent permission states. The allowance for a given token ID might be decremented in one context and preserved in another, depending on which library the downstream contract uses.
I designed our permission layer to treat operator status as a complete bypass, never touching allowance state when the caller is an operator. This matches the intuition that operators are "super approvers," but it's a design choice, not a specification requirement. Any integrating contract that assumes allowances are always decremented on transferFrom will have a silent accounting bug when interacting with our implementation.
The Tooling Void
Deploying ERC-6909 to production reveals an infrastructure gap that no specification review prepares you for.
Indexing: No standard subgraph templates exist for ERC-6909. The Graph's ecosystem has mature ERC-20 and ERC-1155 subgraph templates. For ERC-6909, you write custom indexing from scratch. The event signatures differ from both ERC-20 and ERC-1155: the Transfer event includes a caller field (not indexed) alongside the indexed sender, receiver, and id. Every offchain system that tracks token movements needs custom event parsing.
Wallets: ERC-6909 isn't backward compatible with ERC-1155. Wallets that support ERC-1155 can't automatically recognize ERC-6909 tokens. MetaMask, Coinbase Wallet, and Rabby display ERC-6909 balances as generic contract interactions, not as token holdings. Users don't see their balances. For a standard designed to manage multitoken positions, the inability to render those positions in any major wallet is a significant production constraint.
Block Explorers: Etherscan and its derivatives treat ERC-6909 contracts as generic smart contracts. Token transfers don't appear in the "Token Transfers" tab. Balance lookups require direct contract reads. The UX gap between "your token works" and "your users can see their tokens" is entirely unaddressed by the specification.
Metadata: The vanilla ERC-6909 implementation has no decimals, no name, no symbol. Metadata is an optional extension (ERC6909Metadata) that must be explicitly implemented. OpenZeppelin, Solady, and Solmate handle this differently: OpenZeppelin implements metadata as a separate extension, Solady hardcodes it into the base, and Solmate doesn't include metadata contracts at all. Offchain systems that query token names, symbols, or decimals get inconsistent results depending on which library the contract uses, if they get results at all. The decimals issue is particularly sharp: query decimals for a token ID that doesn't exist and, because the spec never defines the behavior, most implementations return 0, and any share accounting downstream that scales by token decimals will silently misprice positions.
We built custom indexing infrastructure, custom wallet display logic, and custom explorer integration for our ERC-6909 deployment. The engineering cost was substantial, roughly equivalent to the smart contract development itself.
Draft Standard, Production Reality
ERC-6909 went Final in September 2025. The order of events matters more than the status. The spec was created in April 2023 and sat in Draft while Uniswap built v4 on top of it. The audit driven transferFrom rewrite landed in June 2024. The spec moved to Review that October. Uniswap v4 launched on mainnet while the spec was still in Review, and Final arrived months after billions in volume had already flowed through the standard. Finalization didn't validate the spec for production. Production validated the spec for finalization.
Today Uniswap v4's PoolManager runs ERC-6909 on Ethereum mainnet, Arbitrum, Base, Optimism, Polygon, Avalanche, and a growing list of other chains. The Compact, an ownerless ERC-6909 contract for crosschain resource locks, is deployed on mainnet, Unichain, Base, and Arbitrum, settling crosschain intents on UniswapX.
Every team that adopted ERC-6909 before Final faced the same question I did: wait for finalization, or ship? The status field was never the real signal. Uniswap commissioned nine independent audits from firms including OpenZeppelin, Spearbit, Trail of Bits, Certora, ABDK, and Pashov Audit Group. Over 500 researchers participated in a $2.35M security competition. A $15.5M bug bounty remains active. By the time the EIP went Final, the deployment was already among the most audited codebases in DeFi history. The standards process ratified what production had proven.
For our protocol, the decision was pragmatic. ERC-6909 enabled multitoken position management inside the onchain matching engine at gas costs ERC-1155 couldn't match. Pre Final status added engineering overhead, spec tracking and defensive coding patterns, but the alternative was ERC-1155, long finalized and carrying gas inefficiencies that undermined the protocol's economic viability.
Where That Leaves You
ERC-6909 is the right standard for multitoken management in DeFi. The design decisions are sound, the gas improvements are real, and the production data from Uniswap v4 validates the architecture at scale. The Compact demonstrates that ERC-6909's utility extends beyond its original claim token use case into crosschain resource locks, intent settlement, and composable commitment systems.
The standard's rough edges, the permission ambiguity, the tooling void, the accounting pitfalls at ERC-20 boundaries, aren't reasons to avoid ERC-6909. They're reasons to deploy it with the defensive depth that any production system demands. The specification tells you how the standard works. Production tells you how it breaks. The gap between those two is where engineering judgment lives.
ERC-6909 isn't minimal because it's simple. It's minimal because it pushes complexity to the implementation layer, and every team that deploys it discovers a different set of edges. The question isn't whether your implementation will hit something the spec doesn't cover. The question is whether you'll find it in testing or in production.