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:
- Which contracts are in scope? Copy the addresses verbatim.
-
Which of them are proxies? Run
cast codesizeon every address. Anything under ~1KB is a proxy; you need the implementation. -
Who controls authority? Call
owner(),admin(),getOwners(),getThreshold(). Then callcast codesizeon that address. If it returns0, the owner is an EOA — log it, but do not chase it. Bounty programs almost universally exclude "attacks requiring privileged addresses." -
What does the protocol hold? For each contract, call
balanceOfon 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)
Every line with < or > is a candidate. Three outcomes:
| Diff shape | What it means |
|---|---|
| No diff in a function body | Dead — it's canonical |
| 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
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");
}
For a Uniswap V3 pool, the invariants are well-documented:
-
feeGrowthGlobal0X128andfeeGrowthGlobal1X128are non-decreasing over time -
slot0.sqrtPriceX96is within[MIN_SQRT_RATIO, MAX_SQRT_RATIO] -
slot0.unlocked == trueafter any completed transaction
For a MasterChef-style farm:
-
totalSupplyof the receipt token equalsbalanceOfof the staking contract - Sum of
user.rewardDebtequalstotalRewardsDistributed -
emergencyWithdrawdoes not leave residual claims
For a proxy:
-
implementation.slot0is initialized (i.e.,_disableInitializers()was called) -
implementation.owner()is either non-zero orupgradeTorequiresonlyProxy
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:
- Call
initialize()on the implementation directly - Become the implementation's owner
- Call
upgradeToAndCall(malicious, "")on the implementation -
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
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:
- Is there real value at risk right now, on-chain? Not "could be in the future."
- Does the exploit work in a single transaction, on a fork, with the current state?
- Is the impact measurable in tokens held by the protocol, not just in emitted events?
- 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)
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 (0)