Uniswap V3 DCA
Dollar-cost averages from the Safe. On each run the task checks that the Safe holds amountIn of tokenIn, asks the Uniswap V3 quoter what that swap returns, and computes a minimum output from your slippage setting. It then returns two calls: an approve of amountIn to the router, and an exactInputSingle swap that sends tokenOut back to the Safe. Flow sends both as one atomic batch. If the pool returns less than the minimum, the swap reverts and nothing moves.
Flow has no built-in swap routing. This template calls one Uniswap V3 pool directly, which suits a pair with deep liquidity at a known fee tier.
Task
import { defineTask, z } from '@thyme-labs/sdk'
import { encodeFunctionData, erc20Abi, formatUnits, parseAbi, parseUnits } from 'viem'
const quoterAbi = parseAbi([
'struct QuoteExactInputSingleParams { address tokenIn; address tokenOut; uint256 amountIn; uint24 fee; uint160 sqrtPriceLimitX96; }',
'function quoteExactInputSingle(QuoteExactInputSingleParams params) returns (uint256 amountOut, uint160 sqrtPriceX96After, uint32 initializedTicksCrossed, uint256 gasEstimate)',
])
const routerAbi = parseAbi([
'struct ExactInputSingleParams { address tokenIn; address tokenOut; uint24 fee; address recipient; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; }',
'function exactInputSingle(ExactInputSingleParams params) payable returns (uint256 amountOut)',
])
export default defineTask({
schema: z.object({
tokenIn: z.address(),
tokenOut: z.address(),
fee: z.number().int(),
amountIn: z.string().regex(/^\d+(\.\d+)?$/),
slippageBps: z.number().int().min(1).max(1000),
router: z.address(),
quoter: z.address(),
}),
async run(ctx) {
const { tokenIn, tokenOut, fee, slippageBps, router, quoter } = ctx.args
const decimalsIn = await ctx.client.readContract({
address: tokenIn,
abi: erc20Abi,
functionName: 'decimals',
})
const amountIn = parseUnits(ctx.args.amountIn, decimalsIn)
const balance = await ctx.client.readContract({
address: tokenIn,
abi: erc20Abi,
functionName: 'balanceOf',
args: [ctx.account],
})
if (balance < amountIn) {
return { canExec: false, message: 'The Safe does not hold enough to swap' }
}
const { result } = await ctx.client.simulateContract({
address: quoter,
abi: quoterAbi,
functionName: 'quoteExactInputSingle',
args: [{ tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96: 0n }],
})
const quoted = result[0]
const minOut = (quoted * BigInt(10_000 - slippageBps)) / 10_000n
const decimalsOut = await ctx.client.readContract({
address: tokenOut,
abi: erc20Abi,
functionName: 'decimals',
})
ctx.logger.info(
`Swapping ${formatUnits(amountIn, decimalsIn)} for at least ${formatUnits(minOut, decimalsOut)} (quoted ${formatUnits(quoted, decimalsOut)})`,
)
return {
canExec: true,
calls: [
{
to: tokenIn,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'approve',
args: [router, amountIn],
}),
},
{
to: router,
data: encodeFunctionData({
abi: routerAbi,
functionName: 'exactInputSingle',
args: [
{
tokenIn,
tokenOut,
fee,
recipient: ctx.account,
amountIn,
amountOutMinimum: minOut,
sqrtPriceLimitX96: 0n,
},
],
}),
},
],
}
},
})router is Uniswap's SwapRouter02 and quoter is QuoterV2 for your chain. Uniswap publishes both addresses in its deployment docs, and they differ between chains. fee is the pool's fee tier in hundredths of a basis point, such as 500 or 3000. slippageBps is the tolerated shortfall in basis points, so 50 is 0.5%.
The minimum output comes from the same pool, at the time the task runs. It protects against the price moving between the quote and the transaction. It does not protect against a pool that was already manipulated when the task ran. For large amounts, add a check against an independent price source, such as an on-chain price feed, before returning canExec: true.
Arguments
{
"tokenIn": "0x...",
"tokenOut": "0x...",
"fee": 500,
"amountIn": "100",
"slippageBps": 50,
"router": "0x...",
"quoter": "0x..."
}Permissions
{
"calls": [
{ "target": { "arg": "tokenIn" }, "function": "approve(address,uint256)" },
{
"target": { "arg": "router" },
"function": "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))"
}
]
}Your Safe profile must allow both. Allowing approve is the sensitive one: a role allowed to call it can approve any spender for any amount, whatever the Safe holds of tokenIn at the time, so the exposure includes funds deposited later. An allowance also stays in place after the role is removed; revoke it from the Safe by approving zero. The router call can also choose any recipient and any amountOutMinimum. Keep only the funds you intend to swap in the Safe, and check that router is Uniswap's real router. See what the role can and cannot limit.
Schedule
A weekly cron at 19:30 UTC on Sundays:
{ "type": "cron", "cronspec": "30 19 * * 0" }Run thyme run uniswap-dca --simulate against your chain before you schedule it. That checks the quote and the swap against live pool state without sending anything.