Uniswap V1
Summary
ExpandThe Uniswap V1 codebase implements a decentralized exchange protocol composed of a factory contract (`uniswap_factory.vy`) that deploys exchange instances via `create_with_code_of`, and an exchange contract (`uniswap_exchange.vy`) that implements a constant-product automated market maker (x*y=k) for ETH/ERC20 token pairs. Each exchange doubles as an ERC20 LP token and supports ETH-to-token, token-to-ETH, and cross-token swaps routed through ETH, with both exact-input and exact-output variants exposing user-specified slippage bounds and deadline parameters. The factory enforces a one-exchange-per-token invariant, and the `setup()` initializer can only be called once, preventing reinitialization attacks. The constant-product pricing function correctly applies a 0.3% fee, and `addLiquidity` rounds token amounts up (`+ 1`) to protect existing liquidity providers against rounding-based dilution.
The audit identified 11 findings across the codebase: 1 Critical, 5 High, 4 Medium, and 1 Low. The dominant risk theme is the complete absence of reentrancy protection, a consequence of the contracts being written in early Vyper (pre-0.2.0) before the `@nonreentrant` decorator was available. The Critical finding (C-1) identifies an exploitable reentrancy vector in the token-to-ETH swap path where ETH is sent before tokens are collected, allowing ERC777-compatible tokens to re-enter mid-execution — a pattern confirmed exploitable in the real-world imBTC/Uniswap incident of April 2020, which resulted in approximately $300,000 in losses. Additional reentrancy vectors exist in the ETH refund path (H-3), cross-token swap routing (M-1), and liquidity removal (L-1). A second systemic risk area involves the four instances of unchecked `send()` return values (H-2, M-2, M-3, M-4), where failed ETH transfers silently succeed at the contract level, potentially causing permanent loss of funds. The protocol also lacks any accommodation for fee-on-transfer tokens, which break both liquidity accounting (H-4) and swap pricing (H-5) by creating discrepancies between expected and actual token balances. The default fallback function (H-1) executes a swap with no slippage protection and `block.timestamp` as the deadline, effectively offering no protection against frontrunning or stale execution.
Despite these findings, the protocol demonstrates solid fundamental design. The constant-product math is correctly implemented, fee application is consistent, and the rounding strategy in `addLiquidity` properly favors existing depositors. Deadline parameters are present on all explicit swap functions, and the factory's one-exchange-per-token constraint prevents fragmentation of liquidity. The codebase is compact, logically organized, and free of unnecessary complexity — reflecting a design philosophy that prioritizes simplicity and auditability.
Findings
10 issues identified
ERC777 Reentrancy in Token-to-ETH Swap Functions
uniswap_exchange.vy:140
View Details
Description
In tokenToEthInput and tokenToEthOutput, ETH is sent to the recipient via send() before tokens are collected via transferFrom(). If the traded token is ERC777-compatible, the transferFrom triggers a tokensToSend hook on the buyer with full gas. At this point ETH has left the pool but token reserves are unchanged, allowing the attacker to reenter ethToTokenSwapInput at a favorable rate (tokens appear cheap relative to the reduced ETH reserve). This was exploited in the imBTC/Uniswap V1 attack (April 2020, ~$300k loss).
Impact
Direct theft of ETH from the exchange pool. Any exchange paired with an ERC777-compatible token can be drained by an attacker who reenters during the tokensToSend callback to buy tokens at a discounted rate.
Recommendation
Reorder operations to follow checks-effects-interactions: collect tokens via transferFrom before sending ETH via send. Since send() only forwards 2300 gas, placing it after the token transfer eliminates the reentrancy vector.
Fix
By collecting tokens before sending ETH, any ERC777 tokensToSend hook fires while the pool reserves are still consistent (ETH hasn't left yet). The subsequent send() only provides 2300 gas, preventing meaningful reentrancy.
contracts/uniswap_exchange.vy
Default fallback swap has no slippage protection and passes block.timestamp as deadline
contracts/uniswap_exchange.vy:__default__
View Details
Description
The __default__ fallback function calls self.ethToTokenInput with min_tokens=1 (effectively zero slippage protection) and deadline=block.timestamp (which always passes since the miner sets block.timestamp). This means any ETH sent directly to the contract will execute a swap that can be freely sandwich-attacked by MEV bots, extracting nearly all value from the sender.
Impact
An attacker can sandwich any transaction that sends ETH directly to the exchange contract (triggering the fallback). The attacker front-runs with a large buy to move the price, the victim’s swap executes at the manipulated price with min_tokens=1 providing no protection, and the attacker back-runs to profit. The victim receives far fewer tokens than the fair market rate. The deadline=block.timestamp check is also meaningless since miners/validators control block.timestamp, so a miner could hold the transaction indefinitely and execute it when the price is most unfavorable.
Recommendation
Remove the __default__ fallback swap or require users to use ethToTokenSwapInput where they can specify meaningful min_tokens and deadline parameters. If the fallback must be kept, it should revert or only accept ETH for liquidity purposes, not perform swaps.
Fix
Unchecked `send()` in `removeLiquidity` can cause loss of ETH
contracts/uniswap_exchange.vy:83
View Details
Description
The send(msg.sender, eth_amount) call in removeLiquidity does not check its return value. In Vyper 0.1.x, send() compiles to a raw EVM CALL with a 2300 gas stipend and does NOT revert on failure. If the ETH transfer fails (e.g., recipient is a contract with a fallback that exceeds 2300 gas, or the call simply fails), execution continues. The user’s UNI liquidity tokens have already been burned (balance decremented, totalSupply reduced) on lines 80-81, so the user permanently loses their liquidity position without receiving the ETH portion.
Impact
A liquidity provider calling removeLiquidity could have their UNI tokens burned while receiving no ETH back if the send() fails silently. The token transfer on the next line is checked with assert, but the ETH is already lost. This results in permanent loss of funds for the user.
Recommendation
Use raw_call with a return value check, or wrap the send in an assert to revert the entire transaction on failure.
Fix
ERC777 reentrancy in ethToTokenOutput via tokensReceived hook on refund path
contracts/uniswap_exchange.vy:142-153
View Details
Description
In ethToTokenOutput, an ETH refund is sent to the buyer (line 149) before the token transfer to the recipient (line 150). If the token is ERC777, the token.transfer at line 150 triggers a tokensReceived hook on the recipient, who can re-enter the exchange. Additionally, the ETH refund send at line 149 allows the buyer to re-enter before the token transfer occurs. The contract uses live self.balance and self.token.balanceOf(self) for price calculations, so any re-entrant call will operate against manipulated reserves.
Impact
An attacker can re-enter during the ETH refund or during the ERC777 tokensReceived hook to execute additional swaps against reserves that do not yet reflect the in-progress transaction, potentially extracting value from the pool.
Recommendation
Perform token transfers before ETH refunds, or add a reentrancy mutex to prevent re-entrant calls.
Fix
Fee-on-transfer tokens allow liquidity providers to mint excess shares
contracts/uniswap_exchange.vy:addLiquidity (lines ~36-60)
View Details
Description
In addLiquidity, when total_liquidity > 0, liquidity_minted is calculated as msg.value * total_liquidity / eth_reserve and token_amount is derived from the ratio. The contract then calls self.token.transferFrom(msg.sender, self, token_amount) but never verifies that the full token_amount was actually received. With a fee-on-transfer token, fewer tokens arrive than expected, but the LP still receives the full liquidity_minted shares. This dilutes existing LPs. The initial liquidity case (total_liquidity == 0) has the same issue — token_amount = max_tokens is used without balance verification.
Impact
Liquidity providers deposit fewer real tokens than expected but receive full LP shares, effectively stealing value from existing liquidity providers by diluting their share of the token reserve.
Recommendation
Measure actual tokens received via balance difference and compute liquidity shares based on the actual received amount.
Fix
Fee-on-transfer tokens break swap accounting, allowing reserve drainage
contracts/uniswap_exchange.vy:tokenToEthInput (lines ~108-115)
View Details
Description
In tokenToEthInput, the contract calculates eth_bought based on the nominal tokens_sold value, then sends that ETH to the recipient. Only afterward does it call self.token.transferFrom(buyer, self, tokens_sold). If the token charges a fee on transfer, the contract receives fewer tokens than tokens_sold, but it already computed and sent ETH based on the full amount. This means the contract overpays ETH relative to the tokens it actually received, gradually draining the ETH reserve. The same pattern exists in tokenToEthOutput, tokenToTokenInput, and tokenToTokenOutput.
Impact
An attacker can repeatedly swap a fee-on-transfer token for ETH, receiving more ETH than the tokens actually deposited into the contract warrant. Over time this drains the ETH reserves, causing losses for liquidity providers.
Recommendation
Measure actual token balance change rather than trusting the transfer amount. Record balanceOf(self) before the transferFrom, then compute received = balanceOf(self) - balanceBefore and use received for the price calculation.
Fix
Reentrancy via ERC777 token callbacks in tokenToTokenInput
contracts/uniswap_exchange.vy:tokenToTokenInput (lines 197-207)
View Details
Description
In tokenToTokenInput, tokens are pulled from the buyer via self.token.transferFrom(buyer, self, tokens_sold) before making an external call to Exchange(exchange_addr).ethToTokenTransferInput(...). If the destination exchange’s token is ERC777-compatible, the tokensReceived hook on the recipient fires during that external exchange call, allowing reentrancy back into this exchange while the ETH transfer from this contract to the other exchange is still in-flight. The lack of a reentrancy guard means the attacker could manipulate reserve ratios.
Impact
Cross-contract reentrancy could allow an attacker to exploit price discrepancies between the two exchanges during the intermediate state.
Recommendation
Add a reentrancy mutex to all swap functions, or ensure all state changes and token transfers complete before any external exchange calls.
Unchecked `send()` in `ethToTokenOutput` can lose ETH refund
contracts/uniswap_exchange.vy:121
View Details
Description
In ethToTokenOutput, when the actual ETH cost is less than max_eth (i.e., msg.value), the excess ETH is refunded via send(buyer, eth_refund). This return value is not checked. If the refund fails silently, the buyer overpays for the tokens and the excess ETH remains stuck in the contract (absorbed into the liquidity pool, benefiting LPs at the buyer’s expense).
Impact
Users performing exact-output ETH-to-token swaps may lose their ETH refund if send() fails. The refund amount can be significant since users send msg.value as maximum and only a portion may be needed.
Recommendation
Check the return value of send() or use an assert to revert the transaction if the refund fails.
Fix
Unchecked `send()` in `tokenToEthInput` can cause loss of ETH proceeds
contracts/uniswap_exchange.vy:155
View Details
Description
In tokenToEthInput, after computing the ETH to send for a token-to-ETH swap, send(recipient, wei_bought) is called without checking the return value. If the send fails, the user’s tokens are still transferred into the exchange (via transferFrom on the next line which IS checked), but the recipient never receives the ETH proceeds.
Impact
A user selling tokens for ETH could lose the ETH proceeds entirely. Their tokens are taken but the ETH payment silently fails, leaving the ETH in the contract pool.
Recommendation
Assert on the send return value to ensure the transaction reverts if ETH delivery fails.
Fix
Unchecked `send()` in `tokenToEthOutput` can cause loss of ETH proceeds
contracts/uniswap_exchange.vy:182
View Details
Description
In tokenToEthOutput, send(recipient, eth_bought) is called without checking the return value. If it fails silently, the user’s tokens are still taken (via transferFrom on the next line), but the recipient receives no ETH.
Impact
Same as tokenToEthInput — users can lose their tokens without receiving ETH in return if the send fails.
Recommendation
Assert on the send return value.
Fix
Conclusion
ExpandThe Uniswap V1 codebase exhibits clean, well-structured AMM design with correct constant-product mathematics, proper fee mechanics, and thoughtful LP protections. The code is minimal and readable, which is itself a security property. However, the contracts carry systemic risks rooted in the limitations of early Vyper and in design assumptions about token behavior that do not hold for ERC777 or fee-on-transfer tokens. The absence of reentrancy guards is not a single bug but a pervasive architectural gap that surfaces across multiple entry points — swaps, refunds, liquidity removal, and cross-token routing — producing six of the eleven findings in this report.
The most pressing concerns are the confirmed-exploitable reentrancy in token-to-ETH swaps (C-1) and the unchecked send() calls that can silently lose ETH (H-2, M-2, M-3, M-4). The fee-on-transfer token incompatibilities (H-4, H-5) represent a separate class of risk that cannot be mitigated at the exchange level without balance-checking logic. Together, these findings mean the protocol is safe only under a narrow set of assumptions: that paired tokens are standard ERC20 implementations without transfer hooks or fee mechanics — an assumption the factory cannot enforce.
For deployment, the C-1 reentrancy fix — reordering operations to follow checks-effects-interactions — should be applied immediately as a minimal, high-impact remediation. All send() calls should be replaced with checked transfer patterns to address the four unchecked-return findings. A manual reentrancy lock (storage-based mutex) would provide defense-in-depth against the remaining cross-function reentrancy vectors identified in H-3, M-1, and L-1. Pools trading standard ERC20 tokens without transfer hooks are not affected by the reentrancy findings, but given the protocol’s permissionless nature, documentation should explicitly warn against listing ERC777 or fee-on-transfer tokens until these mitigations are in place.
Legal Disclaimer: This report covers the code submitted for analysis. It does not account for infrastructure, deployment configuration, third-party dependencies, or changes made after the audit date. Automated analysis may produce false positives or miss context-dependent vulnerabilities. audited.xyz provides this report “as is” without warranty of any kind.