Tutorial: add a 0.10% protocol fee on swap output using Uniswap V3 SwapRouter02's built-in sweepTokenWithFee and unwrapWETH9WithFee — no audited contract required.
How to Add a Protocol Fee to a Uniswap V3 Frontend (No Custom Contract)
When I started building BasedSwap — a multichain DEX frontend — the first revenue model question was: how do I take a small fee on every swap without deploying my own audited contracts?
It turns out Uniswap's V3 SwapRouter02 has this exact mechanism built in, and it's underdocumented. This post walks through how it works and how to integrate it.
The mechanism: sweepTokenWithFee and unwrapWETH9WithFee
Uniswap V3's SwapRouter02 supports a "multicall" pattern: you can chain multiple operations into a single transaction. Two of those operations are sweepTokenWithFee (for ERC-20 outputs) and unwrapWETH9WithFee (for native ETH outputs), which split the output between the recipient and a fee recipient.
Function signatures:
function sweepTokenWithFee(
address token,
uint256 amountMinimum,
address recipient,
uint256 feeBips,
address feeRecipient
) external payable;
function unwrapWETH9WithFee(
uint256 amountMinimum,
address recipient,
uint256 feeBips,
address feeRecipient
) external payable;feeBips is the protocol fee in basis points, capped at 100 (1.0%). The function transfers amount * feeBips / 10000 to feeRecipient and the rest to recipient.
Why this is great for indie projects
- No custom contract. You don't need an audited contract. SwapRouter02 has been battle-tested with billions in volume.
- No liquidity. You're using Uniswap's existing pools.
- Same address across chains. SwapRouter02 has the same deployment address on most major chains, so your code is portable.
- Built-in cap. 100 bips max means you can't accidentally charge an absurd fee — Uniswap rejects anything higher.
The catch
You can't use exactInputSingle directly with this pattern. The trick is:
- Call
exactInputSinglewithrecipient = ADDRESS_THIS(a special sentinel meaning "router holds the tokens") - In the same multicall, call
sweepTokenWithFeeto disburse the held tokens
If your exactInputSingle sends the tokens directly to the user, the router doesn't have them anymore and sweepTokenWithFee reverts.
The integration code
Here's the pattern in viem (TypeScript):
import { encodeFunctionData } from "viem";
const FEE_BIPS = 10n; // 0.10%
const FEE_RECIPIENT = "0xYourFeeWallet";
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
// Step 1: encode the swap, with router as recipient
const swapCall = encodeFunctionData({
abi: SWAP_ROUTER_ABI,
functionName: "exactInputSingle",
args: [{
tokenIn,
tokenOut,
fee: poolFeeTier,
recipient: ADDRESS_THIS, // ← router holds tokens
amountIn,
amountOutMinimum,
sqrtPriceLimitX96: 0n,
}],
});
// Step 2: encode the disbursement with fee
const sweepCall = encodeFunctionData({
abi: SWAP_ROUTER_ABI,
functionName: "sweepTokenWithFee",
args: [
tokenOut,
amountOutMinimum,
userAddress,
FEE_BIPS,
FEE_RECIPIENT,
],
});
// Step 3: bundle into one transaction
await writeContract({
address: SWAP_ROUTER_02,
abi: SWAP_ROUTER_ABI,
functionName: "multicall",
args: [[swapCall, sweepCall]],
value: tokenIn === ETH_SENTINEL ? amountIn : 0n,
});Native token (ETH) outputs
If the user is swapping into native ETH, use unwrapWETH9WithFee instead. The swap produces WETH; the unwrap call:
- Unwraps WETH → ETH
- Splits the ETH between user and fee recipient
const unwrapCall = encodeFunctionData({
abi: SWAP_ROUTER_ABI,
functionName: "unwrapWETH9WithFee",
args: [
amountOutMinimum,
userAddress,
FEE_BIPS,
FEE_RECIPIENT,
],
});Verification
After a swap, you can verify the fee was taken correctly by checking the transaction's internal transactions on a block explorer. You should see:
- WETH → SwapRouter02 (output of swap)
- SwapRouter02 → feeRecipient (the fee, equal to
output × feeBips / 10000) - SwapRouter02 → recipient (the rest,
output × (10000 - feeBips) / 10000)
If the math doesn't add up to exactly feeBips/10000, something is wrong with your encoding.
Common mistakes
Forgetting `ADDRESS_THIS` in the swap call. If you set recipient: userAddress in exactInputSingle, the user gets all the tokens and the sweep call reverts. Must be ADDRESS_THIS.
Wrong sentinel. ADDRESS_THIS is 0x...02, not 0x...01 (which is MSG_SENDER). Easy to mix up.
Charging `feeBips > 100`. SwapRouter02 reverts. Maximum is 1.0%.
Ignoring decimals on `amountMinimum`. The amountMinimum argument to the sweep call should match what the swap is expected to produce, not the gross-of-fee number.
Address reference
SwapRouter02 deployment addresses (verified from official Uniswap docs):
| Chain | SwapRouter02 |
|---|---|
| Ethereum | 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 |
| Optimism | 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 |
| Arbitrum | 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 |
| Polygon | 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 |
| Base | 0x2626664c2603336E57B271c5C0b26F421741e481 |
| BNB Chain | 0xB971eF87ede563556b2ED4b1C0b0019111Dd85d2 |
| Avalanche | 0xbb00FF08d01D300023C629E8fFfFcb65A5a578cE |
The economics
At 0.10% (10 bips) — what BasedSwap charges — a $1000 swap generates $1 in protocol fee. Reasonable benchmarks for what's possible:
- 100 swaps/day × $200 avg = $20/day
- 1,000 swaps/day × $200 avg = $200/day
- 10,000 swaps/day × $500 avg = $5,000/day
Uniswap V3's own protocol fee (when enabled) is structured the same way internally, just at a different rate. The mechanism is mature.
Why I wrote this
I spent two days on the Uniswap docs and Discord trying to figure out the right multicall pattern. The official docs cover exactInputSingle and the sweep functions separately, but never together as a fee-taking pattern. I'm hoping this saves the next builder some time.
If you're building a DEX frontend, you probably also want to track per-wallet activity (for future airdrops to your users). I cover that in my next post.
Try the pattern live on [BasedSwap](https://basedswap-azure.vercel.app) — works across 7 chains. Source code on [GitHub](https://github.com/kingboss568/basedswap).
Try BasedSwap
Multichain DEX across 7 networks · daily check-in points · routed through Uniswap V3
Open the App