DEV Community

Cover image for Delta Analysis: How to Audit a Uniswap Fork Without Wasting a Week
Coins N Coinsequences
Coins N Coinsequences

Posted on Edited on

Delta Analysis: How to Audit a Uniswap Fork Without Wasting a Week

Delta Analysis: How to Audit a Uniswap Fork Without Wasting a Week

Every DeFi protocol on an EVM chain is, statistically, a fork of something. A Uniswap V2 fork here, a PancakeSwap V3 fork there, a Compound fork in the lending corner. Bug bounty programs list these contracts as critical targets, and researchers — reasonably — point their tools at them.

Then nothing happens. Slither is silent. The audit report from 2021 says "no critical findings." The code has been live for years, processing billions in volume, with no exploit.

The mistake is treating an audited fork as a fresh codebase. The productive approach is delta analysis: identify exactly what the fork changed, and aim only at the delta. Everything else is a waste of time.

This post walks through the methodology I use, with public examples.


Why "just run Slither" fails

Static analysis tools are pattern matchers. They catch reentrancy, unchecked math, unsafe external calls — the classes of bug that have existed since 2016. On an audited fork, those patterns have already been flagged and resolved.

The bugs that survive audits are not pattern-detectable. They live in:

  • The fork's own additions (a reward mechanism, a migration path, a proxy layer)
  • Interactions between two audited contracts
  • Economic invariants that break under specific, legal user behavior
  • Access control that is present but wrong

Slither cannot find these. You have to read the code — but you have to read the right code.


The five-phase methodology

Phase 1 — Recon and scope mapping

Before any review, answer these questions in writing:

  1. Which contracts are in scope? Copy the addresses verbatim.
  2. Which of them are proxies? Run cast codesize on every address. Anything under ~1KB is a proxy; you need the implementation.
  3. Who controls authority? Call owner(), admin(), getOwners(), getThreshold(). Then call cast codesize on that address. If it returns 0, the owner is an EOA — log it, but do not chase it. Bounty programs almost universally exclude "attacks requiring privileged addresses."
  4. What does the protocol hold? For each contract, call balanceOf on the reward or value token. That number is your maximum damage estimate, and it tells you where to spend time.

Output: a SCOPE.md with all of the above. This document travels with you through the rest of the review.

Phase 2 — Delta analysis against the canonical source

For each contract, identify its upstream parent:

  • Uniswap V2 → Uniswap/v2-core, Uniswap/v2-periphery
  • PancakeSwap V3 → pancakeswap/pancake-v3-contracts
  • Compound → compound-finance/compound-protocol
  • WETH9 → gnosis/weth9 (canonical reference: etherscan.io/address/0xC02aaA39...)

Clone both the fork and the parent, then diff the function signatures:

diff <(grep -E "^    function" fork/Contract.sol) \
     <(grep -E "^    function" parent/Contract.sol)
Enter fullscreen mode Exit fullscreen mode

⚠️ Critical Caveat: This fast-pass only checks function signatures, not the code inside the function body. A fork can keep the exact same function names but change a single number inside the logic, and this grep command will return zero differences.

A perfect historical example is **Uranium Finance* (April 2021, ~$50M loss). The developers changed the fee multiplier inside the swap() function from 1000 to 10000, but left the K-invariant check at 1000**2. Because the function signature didn't change, signature-only diffing missed it entirely.*

For core math functions (swap, mint, burn), you must perform a full file diff or manually read the function bodies. Also, never forget to compare the INIT_CODE_PAIR_HASH (the pairFor init code hash) used in the Router—if that doesn't match the Factory, routing will break or be exploitable.

Every line with < or > in a full diff is a candidate. Three outcomes:

Diff shape What it means
No diff in function signatures Likely canonical — but verify the math inside swap/mint/burn
Renamed function, identical body Dead — probably a rebrand
New function, or changed logic This is the target

For a slower but more thorough pass, run git log on the parent repo to find the exact commit that matches the fork's deployment date, then diff full file contents. For an even faster first look, extract the deployed function selectors:

cast code 0x<CONTRACT> --rpc-url $RPC | \
  grep -oE "63[0-9a-f]{8}" | sed 's/^63//' | sort -u | \
while read sel; do
  echo -n "0x$sel -> "
  cast 4byte "0x$sel" 2>/dev/null || echo "(custom)"
done
Enter fullscreen mode Exit fullscreen mode

Selectors that 4byte-decodes to known functions tell you what the contract claims to be. Selectors that come back as (custom) are either compiler error strings pushed into the bytecode (a common source of false positives — look for byte patterns like 0x61 0x6c 0x6c which spell "all" ASCII) or genuine fork additions.

Phase 3 — Invariant testing on a Foundry fork

This is where most of the value is. Canonical code has known invariants. Your fork may break them.

Test pattern:

function test_Invariant() public {
    vm.createSelectFork(vm.envString("RPC"), BLOCK_NUMBER);
    // Assert something that should always be true for a correct implementation
    assertLe(protocol.value(), protocol.maxValue(), "invariant broken");
}
Enter fullscreen mode Exit fullscreen mode

For a Uniswap V3 pool, the invariants are well-documented:

  • feeGrowthGlobal0X128 and feeGrowthGlobal1X128 are non-decreasing over time
  • slot0.sqrtPriceX96 is within [MIN_SQRT_RATIO, MAX_SQRT_RATIO]
  • slot0.unlocked == true after any completed transaction

For a MasterChef-style farm:

  • totalSupply of the receipt token equals balanceOf of the staking contract
  • Sum of user.rewardDebt equals totalRewardsDistributed
  • emergencyWithdraw does not leave residual claims

For a proxy:

  • implementation.slot0 is initialized (i.e., _disableInitializers() was called)
  • implementation.owner() is either non-zero or upgradeTo requires onlyProxy

When an invariant fails, you have a lead. When all invariants pass, the canonical claim holds and you can move on.

Phase 4 — Proxy and authority analysis

Modern contracts are almost always behind a proxy. Three patterns exist:

Pattern How to detect Attack surface
TransparentUpgradeableProxy EIP-1967 admin slot ≠ 0 Admin is a contract (timelock/multisig) or EOA
UUPS (ERC-1822) EIP-1967 admin slot = 0, impl has proxiableUUID Implementation initialization — CVE-2021-41251
Beacon EIP-1967 beacon slot ≠ 0 Beacon upgrade gate

The UUPS case is the most dangerous because the implementation holds the initializer state. If _disableInitializers() was not called in the constructor, an attacker can:

  1. Call initialize() on the implementation directly
  2. Become the implementation's owner
  3. Call upgradeToAndCall(malicious, "") on the implementation
  4. malicious.selfdestruct() → implementation has no code → proxy bricked

This is CVE-2021-41251, and it has destroyed production protocols.

Test:

cast call $IMPL "owner()(address)" --rpc-url $RPC      # should be 0x0
cast storage $IMPL 0 --rpc-url $RPC                   # should be 0x...ff
cast call $IMPL "initialize()" --rpc-url $RPC         # should revert
Enter fullscreen mode Exit fullscreen mode

If initialize() succeeds on the implementation, stop everything and write the PoC.

Phase 5 — The discipline of stopping

This is the phase no blog post talks about, and it is the most important one.

The instinct after 20 dead ends is to lower the bar: "well, this receipt-token bug could matter if someone deployed a market tomorrow." That is how researchers waste submissions on findings the triager rejects in 30 seconds.

The bar is not "is this technically a bug?" The bar is:

  1. Is there real value at risk right now, on-chain? Not "could be in the future."
  2. Does the exploit work in a single transaction, on a fork, with the current state?
  3. Is the impact measurable in tokens held by the protocol, not just in emitted events?
  4. Does it fall outside the program's explicit exclusions? Most programs exclude "privileged addresses," "devaluing protocol incentive rewards," and "design issues." Read those clauses before writing the PoC, not after.

If any of those four answers is "no," the finding is a portfolio piece, not a submission.


Two public case studies

Case study 1: The four.meme pool pre-creation (BSC, 2025)

A launchpad migrated liquidity to PancakeSwap V3 via createAndInitializePoolIfNecessary. This function only initializes the pool if slot0.sqrtPriceX96 == 0. An attacker pre-created the pool and initialized it at an extreme price. The migration succeeded silently, and the attacker drained the pool for ~$183,000.

The delta: the launchpad's code assumed "create" meant "initialize at a fair price." The canonical function only meant "initialize if empty."

The lesson: when a fork calls a canonical function, check what the canonical function actually guarantees. The fork author's mental model is not the source of truth.

Case study 2: UUPS uninitialized implementation (CVE-2021-41251)

Multiple protocols deployed their UUPS implementations without calling _disableInitializers() in the constructor. Attackers called initialize() on the implementation directly, became the owner, and upgraded to a selfdestructing contract — bricking the proxy and locking all funds.

The delta: nothing in the proxy was wrong. The bug was in the implementation's constructor, which is invisible from the proxy address.

The lesson: audit both layers of every proxy. The proxy's bytecode is short and easy to verify, but the implementation is where the actual state lives — including the initializer.


A small tooling checklist

# Recon
cast codesize <addr> --rpc-url $RPC             # proxy detection
cast call <addr> "owner()(address)" --rpc-url $RPC
cast storage <addr> 0 --rpc-url $RPC             # slot-0 fingerprint
cast code <addr> --rpc-url $RPC | \
  grep -oE "63[0-9a-f]{8}" | sed 's/^63//' | \
  xargs -I{} cast 4byte "0x{}"                   # selector extraction

# Fork testing
forge test --match-contract <Name> -vvvv         # with trace

# Diff
diff <(grep -E "^    function" a.sol) <(grep -E "^    function" b.sol)
Enter fullscreen mode Exit fullscreen mode

Foundry's -vvvv trace is the single most useful tool in the workflow. It shows every SSTORE, CALL, and LOG in a call. A function that "succeeds" for a random caller but has zero SSTORE is a no-op, not a bug. The trace proves it in five seconds.


Conclusion

Most of a smart contract review is proving what is not vulnerable. The findings are a byproduct of the process, not the process itself.

The five phases — scope, delta, invariants, proxies, discipline — are not novel. They are the same approach professional audit firms use. What separates a productive hunt from a frustrating one is resisting the urge to test code you know is clean. Aim at the delta. Test the invariants. Check the proxies. Then stop.

If nothing is vulnerable, you have still produced a documented security review — which is worth more to your portfolio than a single rejected submission.

Methodology refined over multiple bounty programs. Public examples referenced: the four.meme incident (February 2025), CVE-2021-41251, and the canonical Uniswap V2 / PancakeSwap V3 source repositories.

Top comments (2)

Collapse
 
bloqarl profile image
Carlos | Zealynx •

Good write-up, phase 5 is the part most bounty posts skip.

One thing on the fast pass in phase 2: grepping only the function lines can't see a change inside a body, and one of the best known Uniswap V2 fork bugs was exactly that. Uranium Finance (April 2021, about $50M) changed 1000 to 10000 in the two balance adjustments in swap() and left the K check at 1000**2, so a tiny input could take almost all of the other token. Same signatures as the parent, so that diff prints nothing. For V2 forks I'd go straight to the full diff of swap, mint and burn and every constant, the fee numbers and the init code hash in pairFor included.

Collapse
 
vaultvult profile image
vault vult •

Carlos, this is an excellent catch and a perfect example. You are absolutely right—grepping for function signatures is only useful for a quick triage to see what was added or removed; it completely misses internal logic changes like the Uranium Finance 1000 to 10000 swap constant.

I'm going to update the Phase 2 section to explicitly warn about this and recommend a full body diff for critical functions like swap, mint, burn, and checking the pairFor init code hash. Thanks for the great addition!

⌂ 换网址