Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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_letter

The 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 in eth_call and dead-letters instead of broadcasting.
  • Gas caps β€” limit_from_simulation + max_price (and the relayer policy.max_gas_price) abort the send before queueing if gas spikes.
  • Recheck β€” recheck: re-evaluates immediately before broadcast, so a refill the plan stamped 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-daily caps cumulative refills at 5 ether per 24h across every run, and reservations survive restarts and races. Add permissions.max_value_per_tx for a per-send ceiling too, and an approval: gate if you want a human on refills above some size.
  • Receiver allow-list β€” policy.whitelist_receivers pins the only address the funder may ever pay.
  • Reorg-safe β€” confirmations: 6 + wait_for: confirmed; on_failure: dead_letter journals a failed refill instead of losing it.

Production checklist

  1. replace the hot_wallet constant and the matching whitelist_receivers entry with the real address
  2. replace the raw dev mnemonic with a production signer, and fund the funder wallet
  3. tune the floor (0.5 ETH), the target (target_eth) and the refill-daily cap to your burn rate
  4. dry-run first: rflow test hot-wallet-refill --fixture <low-balance read>