Documentation · Integrate
Swapping
How to swap ETH and GENE in the canonical Uniswap v4 pool through the Universal Router, with the PoolKey to use and the fees a swap pays.
This page covers GENE's one canonical Uniswap v4 pool: native ETH against GENE, with GeneHook as the pool's hook. GENE is a plain ERC-20, so other pools can list it; their trades never reach GeneHook (GeneToken.sol:6-10). HOOK_ADDRESS and the token address are listed on addresses. The snippets also run against a fork or devnet where you deploy the frozen source.
#The PoolKey
| Field | Value | Source |
|---|---|---|
currency0 |
address(0), native ETH |
GeneHook.sol:237, 967 |
currency1 |
the GENE token, GeneHook.token() |
GeneHook.sol:238, 968 |
fee |
10000 (1.00%), static |
GeneHook.sol:239; Genome.sol:16 |
tickSpacing |
60 |
GeneHook.sol:240; Genome.sol:17 |
hooks |
HOOK_ADDRESS |
GeneHook.sol:241 |
Read it from the hook instead of typing it: GeneHook.poolKey() returns this key (GeneHook.sol:965-973), and poolId() is its hash (GeneHook.sol:243-244). Pass empty hookData. A buy of GENE is zeroForOne = true (ETH in); a sell is zeroForOne = false (GeneHook.sol:385).
beforeSwap reverts WrongPool for any key whose currencies, fee or tick spacing differ (GeneHook.sol:360-363). Since beforeInitialize always reverts, no other pool can use this hook (GeneHook.sol:332-334).
#Fees a swap pays
- The 1% LP fee. Uniswap v4 charges the static 1% LP fee on the input token: ETH on buys, GENE on sells. It accrues to the hook's positions, because the hook is the pool's only liquidity provider (GeneHook.sol:337-343).
- No hook fee.
beforeSwapreturns a zero delta and a fee override of 0;afterSwapreturns a delta of 0 (GeneHook.sol:365, 389). The pool has no return-delta permission (GeneHook.sol:40-45). Nothing in GeneHook can change the fee: the key is static and the hook never callsupdateDynamicLPFee(GeneHook.sol:25-27; invariants FEE1 and FEE2). - The Uniswap protocol fee is governed by Uniswap and is outside GENE's control. v4 core caps it at 0.1% per direction, and none of it reaches GENE's positions (ProtocolFeeLibrary.sol:8). Read the current value from
slot0.protocolFee(read state).
#Routes tested
The fork suite runs against Ethereum mainnet state with the real PoolManager, Universal Router 2.0 and 2.1.2, Permit2, V4Quoter and multi-hop routes. Exact-in and exact-out buys and sells succeed, and a route that hits the pool twice in one unlock succeeds (invariants RT1 and RT2). The invariant suite also sells a small and a very large GENE balance after each step of its runs (GenePermanence.t.sol:167-185). GeneHook contains no rule that rejects a swap by size, direction or sender (GeneHook.sol:355-390). Audit scope note: revert paths in the swap callbacks across all states (see Security).
The two router versions take different parameter structs for v4 swaps: 2.1.2 adds a minHopPriceX36 field (GeneFork.t.sol:55-100). Use the struct that matches the router you call.
Measured gas for a swap through the Universal Router: 126,054 for the first swap in a block, 114,902 for a later swap in the same block, 203,942 for the swap that closes an epoch. The first swap of each block records the block-start price (GeneHook.sol:364).
Routing is not a GENE property. Whether the Uniswap interface, an aggregator or a solver routes trades to this pool is decided off-chain by those services, outside GENE's control. The constructor rejects hook addresses whose first byte is 0x91, citing a Uniswap routing policy (GeneHook.sol:227-228); that policy belongs to Uniswap. Calling the Universal Router directly, as below, does not depend on an interface or aggregator choosing this pool.
#Buy GENE with viem (exact input)
The command sequence matches the fork tests: one V4_SWAP command (0x10) with the actions SWAP_EXACT_IN_SINGLE (0x06), SETTLE_ALL (0x0c) and TAKE_ALL (0x0f), then SWEEP (0x04) for leftover ETH (GeneFork.t.sol:214-245). The parameter layout below is the Universal Router 2.0 layout.
// buy.mjs (Node 18+, npm install viem)
// env: RPC_URL, PRIVATE_KEY, HOOK_ADDRESS (after deployment),
// UNIVERSAL_ROUTER_ADDRESS and V4_QUOTER_ADDRESS (from Uniswap's deployment list)
import { createPublicClient, createWalletClient, http, parseAbi, parseEther, encodeAbiParameters } from "viem";
import { privateKeyToAccount } from "viem/accounts";
const { RPC_URL, PRIVATE_KEY, HOOK_ADDRESS, UNIVERSAL_ROUTER_ADDRESS, V4_QUOTER_ADDRESS } = process.env;
const ETH_IN = parseEther("0.1");
const SLIPPAGE_BPS = 100n; // 1% below the quote
const POOL_KEY = {
type: "tuple",
components: [
{ name: "currency0", type: "address" },
{ name: "currency1", type: "address" },
{ name: "fee", type: "uint24" },
{ name: "tickSpacing", type: "int24" },
{ name: "hooks", type: "address" },
],
};
const EXACT_IN_SINGLE = {
type: "tuple",
components: [
{ name: "poolKey", ...POOL_KEY },
{ name: "zeroForOne", type: "bool" },
{ name: "amountIn", type: "uint128" },
{ name: "amountOutMinimum", type: "uint128" },
{ name: "hookData", type: "bytes" },
],
};
const hookAbi = parseAbi(["function poolKey() view returns ((address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks))"]);
const quoterAbi = parseAbi([
"function quoteExactInputSingle(((address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks) poolKey, bool zeroForOne, uint128 exactAmount, bytes hookData) params) returns (uint256 amountOut, uint256 gasEstimate)",
]);
const routerAbi = parseAbi(["function execute(bytes commands, bytes[] inputs, uint256 deadline) payable"]);
const account = privateKeyToAccount(PRIVATE_KEY);
const pub = createPublicClient({ transport: http(RPC_URL) });
const wallet = createWalletClient({ account, transport: http(RPC_URL) });
const key = await pub.readContract({ address: HOOK_ADDRESS, abi: hookAbi, functionName: "poolKey" });
const { result: [quotedOut] } = await pub.simulateContract({
address: V4_QUOTER_ADDRESS, abi: quoterAbi, functionName: "quoteExactInputSingle",
args: [{ poolKey: key, zeroForOne: true, exactAmount: ETH_IN, hookData: "0x" }],
});
const minOut = (quotedOut * (10_000n - SLIPPAGE_BPS)) / 10_000n;
const actions = "0x060c0f"; // SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL
const params = [
encodeAbiParameters([EXACT_IN_SINGLE], [{ poolKey: key, zeroForOne: true, amountIn: ETH_IN, amountOutMinimum: minOut, hookData: "0x" }]),
encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [key.currency0, ETH_IN]), // SETTLE_ALL: ETH in
encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [key.currency1, minOut]), // TAKE_ALL: GENE out
];
const inputs = [
encodeAbiParameters([{ type: "bytes" }, { type: "bytes[]" }], [actions, params]),
encodeAbiParameters([{ type: "address" }, { type: "address" }, { type: "uint256" }], [key.currency0, account.address, 0n]), // SWEEP leftover ETH
];
const deadline = BigInt(Math.floor(Date.now() / 1000) + 300);
const hash = await wallet.writeContract({
address: UNIVERSAL_ROUTER_ADDRESS, abi: routerAbi, functionName: "execute",
args: ["0x1004", inputs, deadline], value: ETH_IN,
});
console.log("buy tx", hash, "min GENE out", minOut);#Sell GENE
A sell uses the same actions with zeroForOne = false, SETTLE_ALL on currency1 (GENE) and TAKE_ALL on currency0 (ETH), no value and no SWEEP. The router pulls GENE through Permit2, so approve first (GeneFork.t.sol:229-233, 259-267):
// sell-approve.mjs, continues buy.mjs; env: PERMIT2_ADDRESS (from Uniswap's deployment list)
const erc20Abi = parseAbi(["function approve(address spender, uint256 amount) returns (bool)"]);
const permit2Abi = parseAbi(["function approve(address token, address spender, uint160 amount, uint48 expiration)"]);
const GENE_IN = parseEther("1000");
const expiration = Math.floor(Date.now() / 1000) + 3600;
await wallet.writeContract({ address: key.currency1, abi: erc20Abi, functionName: "approve", args: [process.env.PERMIT2_ADDRESS, GENE_IN] });
await wallet.writeContract({
address: process.env.PERMIT2_ADDRESS, abi: permit2Abi, functionName: "approve",
args: [key.currency1, UNIVERSAL_ROUTER_ADDRESS, GENE_IN, expiration],
});Wait for each approval to be mined before sending the swap.
#Before you trade
- The pool's only liquidity is protocol-owned Permanent Mass. Protocol bids sit at or below the all-time low, so in a falling market a sell near spot can meet no protocol depth; size the trade from the quote (known limitations).
- Check
slot0and the quote at the block you trade; execution follows the pool state when your transaction is included.
Related: permissions and PoolKey, fee anatomy, pool and hook.
Sources (13)
- src/GeneHook.sol:25-27
- src/GeneHook.sol:40-45
- src/GeneHook.sol:227-228
- src/GeneHook.sol:236-244
- src/GeneHook.sol:355-390
- src/GeneHook.sol:965-973
- src/Genome.sol:16-17
- test/fork/GeneFork.t.sol:39-49
- test/fork/GeneFork.t.sol:55-100
- test/fork/GeneFork.t.sol:214-277
- test/invariant/GenePermanence.t.sol:167-185
- src/GeneToken.sol:6-10
- lib/v4-core/src/libraries/ProtocolFeeLibrary.sol:8
Paths are relative to the GENE repository at tag rc4-audit-candidate (aca5fcd).