Swap Recipes (Uniswap v4)
Practical snippets for swapping through the tGBP/wstGBP v4 backstop pool
with viem . Quotes come from the on-chain WsgemQuoter (or the adapter’s
own quote views), so no WAD math is required — but the same
preview math works off-chain if you prefer.
export const TGBP = '0x27f6c8289550fCE67f6B50BeD1F519966aFE5287'
export const WSTGBP = '0x57C3571f10767E49C9d7b60feb6c67804783B7aE'
export const HOOK = '0xfE36B48c9c0240991E4CEf006a2445F2ff524888'
export const SWAP_ROUTER = '0x21734507fDca48A3b4e8C496280b63a37D3bD0C8'
export const QUOTER = '0x9B409f87aeaADBE912632b1E4de855B6aFCc71Ee'
export const DIRECT_ADAPTER = '0xBE402d34f31133B1Dc00277f24F8ce2d975CBe23'
// The canonical PoolKey — hardcode it; never accept a route-supplied key.
export const POOL_KEY = {
currency0: TGBP,
currency1: WSTGBP,
fee: 0,
tickSpacing: 1,
hooks: HOOK,
} as const
export const POOL_ID =
'0xdb21c31f461611ebeeab8af1280c77a82bb81725e1bf9d6093fbbc207a375ce5'Curated ABIs
const poolKey = {
type: 'tuple',
components: [
{ name: 'currency0', type: 'address' },
{ name: 'currency1', type: 'address' },
{ name: 'fee', type: 'uint24' },
{ name: 'tickSpacing', type: 'int24' },
{ name: 'hooks', type: 'address' },
],
} as const
export const quoterAbi = [
{ type: 'function', name: 'quoteExactInput', stateMutability: 'view',
inputs: [{ name: 'zeroForOne', type: 'bool' }, { name: 'amountIn', type: 'uint256' }],
outputs: [{ name: 'amountOut', type: 'uint256' }] },
{ type: 'function', name: 'quoteExactOutput', stateMutability: 'view',
inputs: [{ name: 'zeroForOne', type: 'bool' }, { name: 'amountOut', type: 'uint256' }],
outputs: [{ name: 'amountIn', type: 'uint256' }] },
{ type: 'function', name: 'previewSwap', stateMutability: 'view',
inputs: [{ name: 'zeroForOne', type: 'bool' }, { name: 'amountSpecified', type: 'int256' }],
outputs: [
{ name: 'amountIn', type: 'uint256' },
{ name: 'amountOut', type: 'uint256' },
{ name: 'executable', type: 'bool' },
{ name: 'reason', type: 'string' },
] },
] as const
export const swapRouterAbi = [
{ type: 'function', name: 'swapExactInput', stateMutability: 'nonpayable',
inputs: [
{ ...poolKey, name: 'key' },
{ name: 'zeroForOne', type: 'bool' },
{ name: 'amountIn', type: 'uint256' },
{ name: 'minAmountOut', type: 'uint256' },
{ name: 'recipient', type: 'address' },
{ name: 'deadline', type: 'uint256' },
],
outputs: [{ name: 'amountOut', type: 'uint256' }] },
{ type: 'function', name: 'swapExactOutput', stateMutability: 'nonpayable',
inputs: [
{ ...poolKey, name: 'key' },
{ name: 'zeroForOne', type: 'bool' },
{ name: 'amountOut', type: 'uint256' },
{ name: 'maxAmountIn', type: 'uint256' },
{ name: 'recipient', type: 'address' },
{ name: 'deadline', type: 'uint256' },
],
outputs: [{ name: 'amountIn', type: 'uint256' }] },
] as const
export const directAdapterAbi = [
{ type: 'function', name: 'swapExactInput', stateMutability: 'nonpayable',
inputs: [
{ name: 'tokenIn', type: 'address' },
{ name: 'amountIn', type: 'uint256' },
{ name: 'minAmountOut', type: 'uint256' },
{ name: 'recipient', type: 'address' },
{ name: 'deadline', type: 'uint256' },
],
outputs: [{ name: 'amountOut', type: 'uint256' }] },
{ type: 'function', name: 'swapExactOutput', stateMutability: 'nonpayable',
inputs: [
{ name: 'tokenIn', type: 'address' },
{ name: 'amountOut', type: 'uint256' },
{ name: 'maxAmountIn', type: 'uint256' },
{ name: 'recipient', type: 'address' },
{ name: 'deadline', type: 'uint256' },
],
outputs: [{ name: 'amountIn', type: 'uint256' }] },
{ type: 'function', name: 'quoteExactInput', stateMutability: 'view',
inputs: [{ name: 'tokenIn', type: 'address' }, { name: 'amountIn', type: 'uint256' }],
outputs: [{ name: 'amountOut', type: 'uint256' }] },
{ type: 'function', name: 'quoteExactOutput', stateMutability: 'view',
inputs: [{ name: 'tokenIn', type: 'address' }, { name: 'amountOut', type: 'uint256' }],
outputs: [{ name: 'amountIn', type: 'uint256' }] },
] as constQuote and check executability
previewSwap returns the quote and whether it would execute right now — market
closed, dust threshold, capacity, wrapper underfunded, redeem cooldown, and a paused
oracle are reported as a reason string instead of a revert. amountSpecified follows
the PoolManager.swap convention: negative = exact-input, positive = exact-output.
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { quoterAbi } from './swapAbis'
import { QUOTER } from './swapAddresses'
const pub = createPublicClient({ chain: mainnet, transport: http() })
// Buy wstGBP with exactly 1,000 tGBP (zeroForOne = true, negative = exact input)
const [amountIn, amountOut, executable, reason] = await pub.readContract({
address: QUOTER,
abi: quoterAbi,
functionName: 'previewSwap',
args: [true, -1_000n * 10n ** 18n],
})
if (!executable) throw new Error(`swap blocked: ${reason}`)Buy wstGBP through the v4 router
Approve the router for the input token, then swap with the quoted output as the slippage
floor. Quoter and execution match exactly within a block; if your transaction may land
later, subtract your own tolerance from minAmountOut — prices only ratchet up, so a
buy’s output can only shrink between quote and execution. Never send minAmountOut = 0.
import { erc20Abi } from 'viem'
import { swapRouterAbi, quoterAbi } from './swapAbis'
import { POOL_KEY, SWAP_ROUTER, QUOTER, TGBP } from './swapAddresses'
const amountIn = 1_000n * 10n ** 18n // 1,000 tGBP
// 1. approve tGBP to the router
await wallet.writeContract({
address: TGBP,
abi: erc20Abi,
functionName: 'approve',
args: [SWAP_ROUTER, amountIn],
})
// 2. quote, then swap with the quote as the floor
const [, amountOut, executable, reason] = await pub.readContract({
address: QUOTER, abi: quoterAbi, functionName: 'previewSwap',
args: [true, -amountIn],
})
if (!executable) throw new Error(reason)
const { request } = await pub.simulateContract({
address: SWAP_ROUTER,
abi: swapRouterAbi,
functionName: 'swapExactInput',
args: [
POOL_KEY,
true, // zeroForOne: true = buy wstGBP
amountIn,
(amountOut * 9_990n) / 10_000n, // minAmountOut: quote - 10bps tolerance
'0x0000000000000000000000000000000000000000', // recipient 0 => msg.sender
BigInt(Math.floor(Date.now() / 1000) + 300), // deadline: now + 5 min
],
account,
})
const hash = await wallet.writeContract(request)Selling is the same flow mirrored: approve wstGBP to the router, pass
zeroForOne = false, and quote with previewSwap(false, -amountIn). Sells require
wstGBP.cooldown() == 0 and enough tGBP in the wrapper — both reported by previewSwap.
Swap through the direct adapter (aggregators & solvers)
The adapter needs no PoolKey and no settle-first plumbing — approve it for the input
token and call it like any swap contract. tokenIn picks the direction: tGBP buys,
wstGBP sells.
import { erc20Abi } from 'viem'
import { directAdapterAbi } from './swapAbis'
import { DIRECT_ADAPTER, TGBP } from './swapAddresses'
const amountIn = 1_000n * 10n ** 18n
await wallet.writeContract({
address: TGBP,
abi: erc20Abi,
functionName: 'approve',
args: [DIRECT_ADAPTER, amountIn],
})
const amountOut = await pub.readContract({
address: DIRECT_ADAPTER,
abi: directAdapterAbi,
functionName: 'quoteExactInput',
args: [TGBP, amountIn],
})
const { request } = await pub.simulateContract({
address: DIRECT_ADAPTER,
abi: directAdapterAbi,
functionName: 'swapExactInput',
args: [
TGBP, // tokenIn: tGBP = buy wstGBP
amountIn,
(amountOut * 9_990n) / 10_000n, // minAmountOut: quote - 10bps tolerance
'0x0000000000000000000000000000000000000000',
BigInt(Math.floor(Date.now() / 1000) + 300),
],
account,
})
const hash = await wallet.writeContract(request)Exact-output works the same way on both venues: call swapExactOutput with
maxAmountIn as the ceiling (quote it first via quoteExactOutput, then add your
tolerance up). The router refunds surplus input; the adapter pulls only the
computed exact input. Both also offer Permit2 variants
(swapExactInputPermit2 / swapExactOutputPermit2) that replace the approval with a
signed PermitTransferFrom.
Monitor the venue in one multicall
Batch the swap-gating reads so your UI can explain why a swap is blocked rather than
just failing (for a size-specific check, previewSwap already returns the reason):
import { erc20Abi } from 'viem'
import { wstgbpAbi } from './wstgbpAbi' // from the Contract Reference
import { WSTGBP, TGBP } from './swapAddresses'
const wst = { address: WSTGBP, abi: wstgbpAbi } as const
const [mintcost, burncost, mintable, burnable, cooldown, capacity, totalSupply, depth] =
await pub.multicall({
allowFailure: false,
contracts: [
{ ...wst, functionName: 'mintcost' },
{ ...wst, functionName: 'burncost' },
{ ...wst, functionName: 'mintable' },
{ ...wst, functionName: 'burnable' },
{ ...wst, functionName: 'cooldown' },
{ ...wst, functionName: 'capacity' },
{ ...wst, functionName: 'totalSupply' },
// sell-side depth: tGBP held by the wrapper
{ address: TGBP, abi: erc20Abi, functionName: 'balanceOf', args: [WSTGBP] },
],
})
const buyHeadroom = capacity - totalSupply // wstGBP still mintable
const sellDepth = depth // tGBP available for redemptionsFor what each read means (and the full venue overview — pool id, canonical PoolKey, trust notes), see Uniswap v4 & Aggregators. For direct mint/redeem — the cheapest route for your own funds at the identical price — see Mint & Redeem.