Monthly Payout
Runs recurring payments. On each run the task checks that this month has not been paid, that the Safe holds the total, and then returns one transfer per recipient. Flow sends the calls as one atomic batch, so if any call reverts, none of them takes effect. That holds for a token whose transfer reverts on failure. A token that returns false instead does not revert the batch, so the payout could be partial. Use this template only with a token you have checked.
Typical uses are contributor payments, grants and subscriptions paid in a stablecoin.
Task
import { defineTask, z } from '@thyme-labs/sdk'
import { encodeFunctionData, erc20Abi, formatUnits, parseUnits } from 'viem'
export default defineTask({
schema: z.object({
token: z.address(),
payouts: z
.array(
z.object({
to: z.address(),
amount: z.string().regex(/^\d+(\.\d+)?$/),
}),
)
.min(1)
.max(32),
}),
async run(ctx) {
const { token, payouts } = ctx.args
const period = new Date().toISOString().slice(0, 7)
if (ctx.storage.lastPaid === period) {
return { canExec: false, message: `Already paid for ${period}` }
}
const decimals = await ctx.client.readContract({
address: token,
abi: erc20Abi,
functionName: 'decimals',
})
const amounts = payouts.map((payout) => parseUnits(payout.amount, decimals))
const total = amounts.reduce((sum, amount) => sum + amount, 0n)
const balance = await ctx.client.readContract({
address: token,
abi: erc20Abi,
functionName: 'balanceOf',
args: [ctx.account],
})
if (balance < total) {
return {
canExec: false,
message: `The Safe holds ${formatUnits(balance, decimals)} but ${formatUnits(total, decimals)} is due`,
}
}
ctx.storage.lastPaid = period
ctx.logger.info(`Paying ${payouts.length} recipients for ${period}`)
return {
canExec: true,
calls: payouts.map((payout, index) => ({
to: token,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [payout.to, amounts[index]],
}),
})),
}
},
})The lastPaid guard means a second run in the same UTC month, such as an accidental Execute now, skips. Storage is committed when the transaction confirms and discarded if submission fails, so a failed payout is retried on the next run.
A run that starts while another is still working is skipped, because each executable holds a storage lock for the length of a run. The lock expires after about 10 minutes. lastPaid is a checkpoint, not a lock: if a payment confirms but the storage commit then fails, or the lock expires before confirmation, lastPaid is missing and a later run in the same month would pay again. After any storage-commit error, check the Safe's transfers before you run it again. See persistent storage.
A run can return up to 32 calls, so the list holds at most 32 recipients.
Arguments
{
"token": "0x...",
"payouts": [
{ "to": "0x...", "amount": "2500" },
{ "to": "0x...", "amount": "1200.50" }
]
}Permissions
{
"calls": [
{ "target": { "arg": "token" }, "function": "transfer(address,uint256)" }
]
}Your Safe profile must allow the same token and transfer(address,uint256). A role allowed to call transfer can send any amount to any address, not only the listed recipients. Fund the Safe shortly before each payout rather than holding a large balance. See what the role can and cannot limit.
Schedule
A cron at 09:00 UTC on the first of each month:
{ "type": "cron", "cronspec": "0 9 1 * *" }