A Uniswap-V2-style AMM in Solidity / Foundry — built from scratch as a portfolio project, with a focus on faithful reproduction of V2 mechanics and on-chain invariant safety.
Stack: Solidity
^0.8.29· Foundry · OpenZeppelin Status: actively developed · core protocol (Pool + Factory + Router + WETH) implemented and tested · 86 tests passing
BetterSwap implements the core building blocks of a Uniswap-V2-style decentralized exchange:
- A constant-product AMM pool with the canonical 0.3% trading fee, a TWAP price oracle, and an optional protocol fee
- A factory that deploys and tracks one pool per token pair and controls the protocol-fee switch
- A router for adding/removing liquidity, multi-hop swaps, and native-ETH swaps via WETH
- A fuzz test that asserts the
k = x · yinvariant survives random swap sequences
This is a learning project written and tested as a "live product" with a realistic commit history. It is not production-ready and should not be deployed with real value without a third-party audit.
┌──────────────► WETH (Wrapped Ether)
│ deposit / withdraw
Router (periphery) ───┤
add/remove liquidity │
multi-hop swaps └──► PoolFactory ──createPool(A,B)──► LiquidityPool (one per pair)
ETH <-> token swaps │ │
│ feeTo / feeToSetter ├── ERC-20 LP token (BSW-LP)
│ (protocol-fee switch) ├── reserves (packed) + TWAP accumulators
└─────────────────────────────────┴── swap / mint / burn
| Contract | Role | Status |
|---|---|---|
src/core/LiquidityPool.sol |
Constant-product pool: swap, mint/burn, TWAP, protocol fee | implemented |
src/core/PoolFactory.sol |
Deploys & registers pools, controls protocol-fee switch | implemented |
src/periphery/Router.sol |
Liquidity + multi-hop swaps + ETH swaps | implemented |
src/token/WETH.sol |
Minimal Wrapped Ether (deposit/withdraw) | implemented |
src/token/DEXToken.sol |
BSW token — ERC-20 + AccessControl, capped supply |
implemented |
src/libraries/UQ112x112.sol |
Fixed-point math for TWAP accumulators | implemented |
src/interfaces/IWETH.sol |
WETH interface used by the Router | implemented |
src/mocks/MockERC20.sol |
ERC-20 mock | test-only |
- Constant-product curve (
x · y = k) - 0.3% trading fee via the canonical Uniswap V2 formula:
amountInWithFee = amountIn * 997 amountOut = (amountInWithFee * reserveOut) / (reserveIn * 1000 + amountInWithFee) - First mint: geometric mean —
Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY MINIMUM_LIQUIDITY = 1000burned toaddress(1)on first mint — protection against the first-LP inflation attack- Subsequent mints: proportional via
Math.min(...) - TWAP price oracle:
price0CumulativeLast/price1CumulativeLastaccumulate time-weighted prices (UQ112.112 fixed point). Reserves andblockTimestampLastare packed into a single storage slot (uint112, uint112, uint32); the accumulator update is done in anuncheckedblock, matching Uniswap V2's intentional timestamp/price overflow behavior. - Optional protocol fee via
_mintFee()andkLast: whenfactory.feeTo()is set, ~1/6 of the growth insqrt(k)is minted tofeeToas LP tokens on the nextmint/burn— the same mechanism as Uniswap V2's fee switch. - Inline post-swap invariant check inside
swap():The same fee-adjusted k-check Uniswap V2 uses.if (balance0Adjusted * balance1Adjusted < uint256(reserve0) * uint256(reserve1) * 1000 * 1000) { revert LiquidityPool__InvariantBroken(); }
- Reserve overflow guard:
_updatereverts withLiquidityPool__Overflowif a balance exceedstype(uint112).max - ERC-20 LP token (
BSW-LP) inheriting OpenZeppelin ERC-20 - ReentrancyGuard on
mint,burn,swap - SafeERC20 for all token transfers
- Anti-griefing: swap reverts if
to == token0,to == token1, orto == address(0) - Custom errors:
LiquidityPool__ZeroAddress,LiquidityPool__CantBeZero,LiquidityPool__PoolIsVoid,LiquidityPool__AddressToken,LiquidityPool__InvariantBroken,LiquidityPool__Overflow - Events:
LiquidityAdded,LiquidityRemoved,Swap
createPool(tokenA, tokenB)sorts the pair and deploys a newLiquidityPoolgetPools[tokenA][tokenB]returns the pool (symmetric mapping) oraddress(0)allPoolsarray enumerates all created pools- Protocol-fee switch:
feeTo/feeToSetter, changeable only by the currentfeeToSetter - Errors:
PoolFactory__ZeroAddress,PoolFactory__AlreadyExist,PoolFactory__TokensAreEqual,PoolFactory__ForbiddenAddress - Events:
PoolAdded,FeeToChanged,FeeToSetterChanged
- Liquidity:
addLiquidity/removeLiquiditywith optimal-amount quoting,amountMinslippage bounds, and adeadlineguard - Swaps:
swapExactTokensForTokenswith multi-hop routing through an arbitrarypath - Native ETH:
swapExactETHForTokens,swapExactTokensForETH,addLiquidityETH(refunds unused ETH via aRefundevent),removeLiquidityETH— all wrapping/unwrapping through WETH ensure(deadline)modifier on every state-changing entry pointreceive()restricted to the WETH contract (Router__OnlyWETH)- Errors:
Router__PoolNotFound,Router__ZeroAddress,Router__CantBeZero,Router__InsufficientAAmount,Router__InsufficientBAmount,Router__Expired,Router__InvalidPath,Router__OnlyWETH,Router__TransferFailed
Utility/reward token, separate from the LP token:
- ERC-20 + OpenZeppelin AccessControl
MINTER_ROLEgates minting;MAX_SUPPLY = 100,000,000 * 1e18is enforced on every mint- Holder-initiated
burn
forge test # all tests (86 passing)
forge test -vvv # verbose
forge test --match-contract LiquidityPoolFuzzTest # fuzz only86 tests passing, 0 failing across:
| Suite | Focus |
|---|---|
test/unit/LiquidityPool.t.sol |
Minting, burning, swaps, reserves, invariant reverts |
test/unit/PoolFactory.t.sol |
Pool creation, token sorting, fee-switch access control |
test/unit/Router.t.sol |
add/remove liquidity, single- & multi-hop swaps, ETH paths, slippage & deadline reverts |
test/unit/WETH.t.sol |
deposit, withdraw, receive, failed-transfer path |
test/unit/DEXToken.t.sol |
mint role, max-supply cap, burn |
test/fuzz/LiquidityPool.fuzz.t.sol |
testFuzz_swapInvariant — k-invariant holds across random swaps · testFuzz_cumulatives_linearInTime — TWAP accumulator grows linearly in time |
The fuzz suite is the most interesting part: across many random trade sequences it verifies that the constant-product invariant is never violated, and that the TWAP accumulators advance proportionally to elapsed time.
GitHub Actions (.github/workflows/test.yml) runs on every push / PR:
forge fmt --check → forge build --sizes → forge test -vvv.
git clone https://github.com/kiko21213/BetterSwap
cd BetterSwap
forge install
forge build
forge testBuild settings (foundry.toml): via_ir = true, optimizer_runs = 200.
Implemented Uniswap-V2-style protections:
- ReentrancyGuard on all state-changing pool functions
- SafeERC20 for every transfer
- Inline fee-adjusted post-swap k-invariant check
MINIMUM_LIQUIDITYfirst-mint burn (anti first-LP attack)- Reserve overflow guard against the
uint112reserve bound - Anti-griefing checks on the swap recipient
- Deadline + slippage bounds on all Router entry points
- Role-gated protocol-fee switch and token minting
Useful for any reviewer before assuming production readiness:
- Not professionally audited. Portfolio code; do not deploy with real value without a third-party audit.
- Not deployed. No deployment/migration scripts yet (there is no
script/directory) — the project is exercised through tests only. - No fee-on-transfer / rebasing token support — pools assume standard ERC-20 transfer semantics.
- No flash swaps —
swap()does not invoke a callback on the recipient, so V2-style flash swaps are not supported. - No V3 mechanics — concentrated liquidity is intentionally out of scope.
- No cross-chain / bridge integration — intentionally out of scope.
MIT — SPDX identifiers are present in every source file. (A standalone LICENSE file has not been added to the repo root yet.)
0xkiko — Solidity developer, security-focused
- GitHub: @kiko21213
- Telegram: @engineer_web3