Hot wallet refill
Recipe (no bundled template) Β· category: treasury Β· risk: money_moving
Keep a hot wallet topped up automatically: a read trigger polls the wallet's
native balance every minute and, on the falseβtrue crossing under a floor,
tops it back up to a target from a funding wallet β pre-flight simulated,
gas-capped, dropped if stale, and behind a durable daily
budget so a bad trigger can never drain the funder.
Closest bundled starting points: the
relayer low-balance alert template
(the same Multicall3.getEthBalance read, alert-only) and the
treasury sweep template (the
simulate + gas-cap + confirm money path). This page is the money-moving
combination of the two.
When to use it
- keep a keeper/relayer/paymaster wallet funded without a human topping it up
- refill a gas tank when it dips below a floor, up to a target
- any "watch a native balance, send when it crosses a threshold" auto-funder
The config
The read trigger fires on the crossing under the floor; a command: step
plans the exact top-up and stamps a freshness deadline; the send is native,
simulated, gas-capped, recheck-guarded and budget-capped.
# recipe: full
rflow_version: 1
name: hot-wallet-refill
config:
port: 3947
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETH_RPC}
# a refill only counts once it is 6 blocks deep
confirmations: 6
# DEV ONLY raw mnemonic (from .env) - `rflow new` generates a fresh dev seed.
# Swap in a production signer before real funds ride on this config.
signer:
raw:
mnemonic: ${RAW_DANGEROUS_MNEMONIC}
relayers:
# the funding wallet - ITS native balance tops up the hot wallet
funder:
networks: [ethereum]
speed: FAST
# belt-and-braces: this relayer may only ever pay the hot wallet
policy:
max_gas_price: "80 gwei"
whitelist_receivers:
- "0x000000000000000000000000000000000000dEaD"
contracts:
# Multicall3 is deployed at the same address on 250+ chains; its
# getEthBalance(address) view is the cheapest native-balance read
Multicall3:
abi: ./abis/multicall3.json
addresses:
ethereum: "0xcA11bde05977b3631167028862bE2a173976CA11"
constants:
# the hot wallet to keep funded - replace the placeholder (and the
# whitelist_receivers entry above with the same address)
hot_wallet: "0x000000000000000000000000000000000000dEaD"
budgets:
# blast-radius cap: at most 5 ETH of refills in any 24h window, no matter
# how often the balance dips - a bad trigger cannot drain the funder
refill-daily:
window: 24h
max_native_value: "5 ether"
scope:
relayers: [funder]
networks: [ethereum]
workflows:
hot-wallet-refill:
trigger:
# edge-triggered: ONE refill on the false->true crossing under 0.5 ETH,
# not one per poll while the balance sits low
read:
contract: Multicall3
network: ethereum
function: "getEthBalance(address)"
args: ["${{ constants.hot_wallet }}"]
every: 1m
condition: "${{ output < wei('0.5', 18) }}"
mode: threshold
# one refill in flight at a time - later dips are skipped, not stacked
concurrency:
group: "hot-wallet-refill"
on_conflict: skip
# every send in this workflow charges the daily cap
budgets: [refill-daily]
steps:
# your script tops the wallet back up TO a target and stamps a short
# freshness deadline: { "top_up_wei": "...", "valid_until": "<iso>" }
- id: plan
command:
run: "node ./scripts/plan-refill.js"
timeout: 10s
output: json
input:
balance_wei: "${{ trigger.args.output }}"
target_eth: "1"
now: "${{ now() }}"
# the funder must be able to cover the top-up (plus its own gas)
- id: funder_balance
read:
contract: Multicall3
network: ethereum
function: "getEthBalance(address)"
args: ["${{ relayers.funder.address }}"]
assert: "${{ output >= steps.plan.output.top_up_wei }}"
# the money step: native transfer, pre-flight simulated, gas-capped,
# dropped if the plan went stale, then confirmed
- id: refill
send_transaction:
network: ethereum
relayer: funder
to: "${{ constants.hot_wallet }}"
value: "${{ steps.plan.output.top_up_wei }}"
assert_sim:
- "${{ sim.ok }}"
gas:
limit_from_simulation: true
multiplier: 1.2
max_price: "80 gwei"
# don't fire a refill the plan stamped more than ~2m ago
recheck: "${{ now() < steps.plan.output.valid_until }}"
wait_for: confirmed
on_failure: dead_letterThe plan-refill.js command
The plan step shells out to your script (stdin = the input: map as JSON,
stdout = one JSON object). A minimal implementation:
// scripts/plan-refill.js
const chunks = [];
process.stdin.on('data', (c) => chunks.push(c));
process.stdin.on('end', () => {
const { balance_wei, target_eth, now } = JSON.parse(chunks.join(''));
const target = BigInt(target_eth) * 10n ** 18n;
const top_up = target - BigInt(balance_wei);
process.stdout.write(
JSON.stringify({
top_up_wei: top_up > 0n ? top_up.toString() : '0',
// short freshness window: the refill is dropped if it hasn't broadcast
// within ~2 minutes (queue delay, retries, restarts). Unix seconds, so
// `now() < valid_until` compares like-for-like in the recheck.
valid_until: Number(now) + 120,
}),
);
});Required env vars
DATABASE_URL, ETH_RPC, RAW_DANGEROUS_MNEMONIC. Generate a dev signer with
rflow new and swap in a production signer before real funds ride on this
config.
Safety notes β money-moving
This workflow moves native value on every fire, so it carries the full money-path guard set:
- Simulation β
assert_sim: ["${{ sim.ok }}"]pre-flight simulates the transfer; a send the funder cannot cover reverts ineth_calland dead-letters instead of broadcasting. - Gas caps β
limit_from_simulation+max_price(and the relayerpolicy.max_gas_price) abort the send before queueing if gas spikes. - Recheck β
recheck:re-evaluates immediately before broadcast, so a refill theplanstamped more than ~2m ago (queue delay, retries, a restart) is dropped rather than fired stale. - Budget (not approval) β an unattended refiller should not gate on a
human, so the blast-radius cap here is a durable
budget:
refill-dailycaps cumulative refills at5 etherper 24h across every run, and reservations survive restarts and races. Addpermissions.max_value_per_txfor a per-send ceiling too, and anapproval:gate if you want a human on refills above some size. - Receiver allow-list β
policy.whitelist_receiverspins the only address the funder may ever pay. - Reorg-safe β
confirmations: 6+wait_for: confirmed;on_failure: dead_letterjournals a failed refill instead of losing it.
Production checklist
- replace the
hot_walletconstant and the matchingwhitelist_receiversentry with the real address - replace the raw dev mnemonic with a production signer, and fund the
funderwallet - tune the floor (
0.5 ETH), the target (target_eth) and therefill-dailycap to your burn rate - dry-run first:
rflow test hot-wallet-refill --fixture <low-balance read>