skim() and sync(): The Uniswap V2 Functions That Look Optional, Aren't
Why Uniswap V2 has skim() and sync() helpers, what donation attacks become possible without them, and why integrators reading reserves directly need to understand both functions.
TL;DR
- Uniswap V2's pair contract trusts its internal
reserve0andreserve1variables, not the raw token balances of the contract. - This separation is intentional. It is what makes the constant-product invariant
k = x * yenforceable under flash loans, callbacks, and arbitrary external calls. - The downside: anyone can transfer tokens directly to the pair via the ERC20
transfer(), bypassingmint(). The pair's actualbalanceOfis now greater thanreserve0, and those donated tokens are accounting-orphaned. skim()lets anyone sweep the surplus to a chosen address.sync()force-updates stored reserves to match actual balances, claiming the donation for existing LPs.- Forks that strip
skim()andsync()because they "look like dead code" expose themselves to silent fund-locking and, in protocols that integrate the pair as an oracle source, exploitable price manipulation.
Why this matters
When you read UniswapV2Pair.sol for the first time, two functions stand out as confusing: skim() and sync(). They look like maintenance utilities, like the kind of thing you'd add for ops convenience. They have no obvious user-facing purpose. Liquidity providers don't call them. Swappers don't call them. They don't pay fees, don't update the oracle, don't move LP tokens.
The natural reaction, especially in a fork team trying to "minimize the surface area", is to delete them. We've seen this in audit after audit. The fork drops skim() and sync(), ships it, and discovers six months later that they have a class of bugs they don't know how to diagnose.
The functions exist because of a specific design choice: the pair contract uses internal reserves as the source of truth for accounting, not raw balances. That choice has consequences, and skim() and sync() are how those consequences are managed safely.
The internal-reserves design
In the pair contract, every accounting calculation that matters is denominated in reserve0 and reserve1, the stored uint112 values in slot 0:
uint112 private reserve0;
uint112 private reserve1;
When a swap executes, the K invariant check is:
require(
balance0Adjusted * balance1Adjusted >= uint(_reserve0) * _reserve1 * (1000**2),
'UniswapV2: K'
);
When liquidity is added, the LP token math reads _reserve0 and _reserve1:
liquidity = Math.min(
amount0 * _totalSupply / _reserve0,
amount1 * _totalSupply / _reserve1
);
When the oracle accumulates prices, it does so against the stored reserves:
price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
The actual ERC20 balances of the pair, balanceOf(token0) and balanceOf(token1), are read in three places: at the start of mint(), at the start of burn(), and during swap() to verify the swap math. In each case, the balances are compared against the stored reserves to figure out what was deposited or transferred. They are never trusted directly as the pool's state.
Why? Because raw balances are influenced by external transfers. Anyone can call IERC20(token0).transfer(pair, 1000) from any contract or EOA. If the pair trusted balances directly, anyone could distort the constant-product math by donating tokens.
By keeping a separate, internal accounting state, the pair contract maintains a consistent view of "the pool" even when the actual token balances are anything from below to above what the pool thinks it holds.
What a donation actually does
Suppose a pair holds reserves of 100 token0 and 100 token1. Both reserve0 and reserve1 are 100. The pair's actual ERC20 balances of token0 and token1 are also 100.
Someone, for whatever reason, calls IERC20(token0).transfer(pair, 50). Now:
reserve0is still 100 (no_update()has been called).- The pair's actual
balanceOf(token0)is 150. reserve1andbalanceOf(token1)are both still 100.
What happens to the 50 donated tokens? Three possible futures:
1. Next swap absorbs them. The next time swap() runs, the function reads the actual balance0 (150) and computes the K-invariant check using both reserves and balances. The donated 50 effectively become part of the pool's liquidity from the moment of the next swap. Existing LPs benefit, the donor loses the donation, and the price ratio of the pool shifts proportionally to reflect the new balance.
2. Next mint absorbs them via the proportional formula. If a new LP adds liquidity before the next swap, mint() reads the current balances, computes amounts deposited as balance - reserve, and the donor's 50 tokens are credited as part of the new LP's deposit. The new LP gets credit for tokens they didn't deposit. Existing LPs are unaffected (because the math uses reserve, not balance, for the proportional calculation, but the new balance feeds into the next reserve update).
3. skim() removes them. A third party calls skim(to), which transfers balance - reserve to the chosen address. The donor's 50 tokens go to wherever the caller specifies. Reserves and balances are now back in sync.
4. sync() claims them. A third party calls sync(), which force-updates the stored reserves to match the actual balances. Now reserve0 = 150 and the donated tokens are part of the pool, benefiting all LPs in proportion to their stake.
Without skim() or sync(), only options 1 and 2 are available, and only when someone calls mint(), burn(), or swap(). If the pool sits idle, the donation sits there.
Why skim() exists
skim() is a safety valve. Specifically, it exists to handle the case where the pair's actual balance exceeds what uint112 can store.
Recall that reserves are uint112, which caps at roughly 5.19 * 10^33. If a token's supply is high enough (or if accumulated donations push the balance over the cap), _update() would revert because of this line in the original source:
require(
balance0 <= uint112(-1) && balance1 <= uint112(-1),
'UniswapV2: OVERFLOW'
);
When that revert fires, the pair is bricked. No mint, no burn, no swap can succeed because each ends with _update(). LPs cannot exit. Swappers cannot trade.
skim() is the escape hatch:
function skim(address to) external lock {
address _token0 = token0;
address _token1 = token1;
_safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
_safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}
It transfers balance - reserve for both tokens to the specified address, no questions asked. Anyone can call it. The first caller wins the donation, but more importantly, the pair returns to a state where _update() can run.
In production Uniswap V2, skim() is rarely called intentionally. It exists primarily as defense-in-depth. But the moment you delete it from a fork and a token's supply edge case pushes balances above uint112, your pool is permanently stuck.
Why sync() exists
sync() is the other side of the same problem:
function sync() external lock {
_update(
IERC20(token0).balanceOf(address(this)),
IERC20(token1).balanceOf(address(this)),
reserve0,
reserve1
);
}
It calls _update() directly with the current balances. The effect: stored reserves jump to match the actual balances. Any donation since the last _update() is now part of the pool's recognized liquidity, and the price oracle's cumulative values catch up.
sync() is most useful when a donation has happened and the pool wants to claim it for LPs without forcing someone to perform a mint or burn. It is also useful as an oracle synchronization tool: if a long time has passed since the last price-affecting interaction, calling sync() updates the oracle's blockTimestampLast and adds time-weighted price observations to the cumulatives, which downstream TWAP consumers may need.
The function is permissionless. Anyone can call it. The cost is just the gas of one storage write to slot 0 and one event emission.
The donation attack on integrators
Up to this point, donations are mostly an accounting curiosity. They affect who gets credit for tokens, but the pool itself stays solvent and the K invariant holds. Where it gets dangerous is when external protocols read the pair's reserves as a price oracle.
A protocol that uses Uniswap V2's spot price (i.e., reads getReserves() and computes reserve1 / reserve0 as the price) is vulnerable to a flash-donation attack:
- Attacker takes a flash loan of token1.
- Attacker calls
IERC20(token1).transfer(pair, hugeAmount). The pair'sbalanceOf(token1)is now massively inflated, butreserve1is unchanged because nomint(),burn(), orswap()has run. - The attacker calls
sync(). Nowreserve1jumps to the inflated balance. The pool's reported price is wildly off. - Attacker uses the integrator protocol while the price is manipulated. They borrow more than they should be able to, or get a better swap rate, or manipulate any logic gated on the spot price.
- Attacker calls
swap()orskim()to recover the donated tokens. - Attacker repays the flash loan.
This is one variant of a broader oracle-manipulation pattern that has drained protocols including Inverse Finance ($15.6M, 2022) and Mango Markets ($114M, 2022), though those exploits used different oracles. The Uniswap V2 spot-price reading pattern is exactly what protocols should not do.
The defensive pattern is to use Uniswap V2's TWAP oracle instead of spot reserves. The TWAP is computed from the cumulative price values, which only update during _update() (which is called inside mint, burn, swap, and sync). Even with sync() in the picture, the TWAP smooths out instantaneous donations across the time window. A flash-donation can only affect the spot price for one block; the TWAP stays close to the real long-term price.
Why integrators need to know about sync()
Even integrators that use the TWAP oracle correctly need to understand sync(). The reason: sync() advances blockTimestampLast and adds a contribution to the cumulative price values, but the contribution is based on the new (donated) reserves, not the original ones. If a TWAP consumer samples the cumulatives just before and just after a sync() that follows a donation, the elapsed-time slice during which the donated reserves are active gets included in the TWAP calculation.
The mitigation is to use a long enough TWAP window that any single sync() event is averaged out. Most protocols use windows of hours to days. The MakerDAO Oracle Security Module, for example, uses 1-hour windows. The Uniswap V2 community recommendation has historically been at least 30 minutes.
In Zealynx Academy's Shadow Arena, several of the documented bugs in real audit targets involve donation-related accounting drift. We have a separate post on the Compound V2 fork variant of donation attacks: The Compound V2 Fork Donation Attack: 3 of 6 Shadow Arena Targets Have It. The pattern is widespread.
Why both skim() and sync() are present
It's worth noting that skim() and sync() do opposite things with the donated tokens:
skim()removes the surplus from the pool, restoringbalance == reserveon both sides by reducing balance.sync()keeps the surplus in the pool, restoringbalance == reserveon both sides by increasing reserve.
Both are necessary because both situations come up. Sometimes you want to evacuate donated tokens (e.g., if the donor was a malicious contract trying to manipulate the price, and you want to flush the manipulation). Sometimes you want to absorb them (e.g., if a friendly party donated to the pool to subsidize LPs). Having both functions lets the protocol or its users choose, rather than baking the choice into the pair contract.
Related questions
Why don't liquidity providers get notified of donations? Because the pair contract has no way to know who donated. ERC20 transfer() calls don't go through any pair function. The first interaction (mint, burn, swap, skim, or sync) after a donation is what surfaces it.
What if the donation creates a balance > uint112(-1) situation? That's exactly what skim() is designed to handle. Without skim(), the pool is bricked because _update() reverts on the overflow check. With skim(), the surplus can be removed, bringing balances back under uint112, and _update() can run again.
Are these functions reentrancy-safe? Both have the lock modifier. They cannot be called during another lock-protected function (mint, burn, swap, skim, sync). This is critical because both transfer tokens.
Should integrators ever call sync()? Rarely. sync() advances the oracle, which can interact with TWAP consumers in non-obvious ways. The pattern is to let sync() happen naturally as part of the protocol's lifecycle (via mint/burn/swap), not to call it as an external integration.
What's the difference between Uniswap V2's skim() and other protocols' "rescue" functions? Most protocols' rescue functions are admin-gated. skim() is permissionless. Anyone can call it. The first caller gets the donated tokens. This is by design: it gives anyone watching the pool an incentive to reset the state, which means donations don't sit accumulating indefinitely.
Where to see them in the source
Both functions live in UniswapV2Pair.sol, near the bottom of the contract:
// force balances to match reserves
function skim(address to) external lock {
address _token0 = token0;
address _token1 = token1;
_safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
_safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}
// force reserves to match balances
function sync() external lock {
_update(
IERC20(token0).balanceOf(address(this)),
IERC20(token1).balanceOf(address(this)),
reserve0,
reserve1
);
}
If you find yourself reading a fork that has stripped these (or worse, replaced them with admin-gated versions), the fork has eliminated a critical piece of the original protocol's safety design. Either the pool can get bricked when balance exceeds uint112, or the donation-handling story is broken, or both.
When you implement these in Zealynx Academy's reconstruction module, the test suite includes a "donation then skim()" scenario that fails if the function is missing or buggy. The Section 6 build asks you to implement both helpers correctly before progressing to the swap module.
Tagged