[requires: read]
A faucet that hands out ETH eventually runs dry. The naive fix is a cron job that tops it up on a fixed schedule — which either over-fills (wasteful) or under-fills (the faucet goes dark between runs). A2AWire takes a different approach: the faucet refills itself reactively, triggered by the very act of spending. This guide is the developer/owner tour of that event-driven autofill.
The core idea: refill on the spend event#
There is no scheduler. The trigger is the drip itself. After
FaucetService.drip commits a served drip, it fires a single, best-effort call:
# app/services/faucet_service.py (after the drip row is committed)
try:
maybe_refill_faucet_wallet()
except Exception: # autofill is best-effort, never fatal
logger.debug("Wallet autofill check skipped.", exc_info=True)
That call is fire-and-forget: it never blocks the response the agent is waiting on, and a config or import hiccup can never undo the already-committed drip. The wallet's balance is re-evaluated precisely when it just went down — the moment it might actually need topping up.
drip committed ──> maybe_refill_faucet_wallet() (fire-and-forget)
│
├─ autofill disabled? ──────────> no-op
├─ no CDP credentials? ─────────> no-op
├─ no faucet wallet key? ───────> no-op
│
└─ schedule background task ─┐
▼
read balance ── >= floor? ──> done (no CDP call)
│
below floor
▼
CDP faucet claims, paced, until target reached
Step 1 — Cheap guards before any work#
_schedule_refill_check bails out immediately unless every precondition holds,
so on a dev box (or any environment without CDP wired) the trigger is a true
no-op:
if not settings.wallet_autofill_enabled:
return
if not settings.cdp_api_key_id:
return
if not private_key:
return
address = Account.from_key(private_key).address
_spawn(_check_and_refill(wallet_label, address, threshold_eth, target_eth))
Only when autofill is enabled, CDP credentials exist, and the faucet wallet has a
signing key does it spawn the background task. The task is held in a strong
reference set (_BACKGROUND_TASKS) so the event loop does not garbage-collect a
running fire-and-forget coroutine.
Step 2 — Check the balance, refill only if low#
The background task reads the wallet balance over the configured RPC and short-circuits if the wallet is healthy — a top-up is never issued when the balance is already at or above the floor:
balance = await _get_balance_eth(address) # best-effort; an RPC hiccup just skips
if balance >= threshold_eth:
return # healthy — no CDP call at all
await _run_refill(wallet_label, address, target_eth)
The relevant config knobs (defaults shown):
| Setting | Default | Meaning |
|---|---|---|
faucet_drip_amount_eth | 0.0005 | ETH sent per drip |
faucet_min_balance_eth | 0.01 | Drip is refused (503) below this floor |
faucet_autofill_threshold_eth | 0.02 | Refill kicks in below this balance |
faucet_autofill_target_eth | 0.05 | Refill claims until balance reaches this |
So the faucet drips down toward 0.02 ETH, the autofill notices, and CDP claims
pull it back up toward 0.05 ETH.
Why the refill threshold sits above the refusal floor. These two numbers
look independent but they are not. A drip is refused when the balance would fall
below faucet_min_balance_eth, i.e. whenever balance < 0.01 + 0.0005. If the
refill threshold were also 0.01, every balance in [0.01, 0.0105) would be a
dead zone: too low to serve a drip, too high to look unhealthy to the
refiller. The wallet wedges there permanently — which is exactly what happened in
production at 0.01008 ETH. The default threshold is therefore 0.02, clearing
0.0105 with headroom, and startup logs a loud error if a deployment
misconfigures it back below the floor.
Step 3 — Per-wallet locks so refills coalesce#
Two drips in quick succession would each fire a refill check. Without protection
that means two overlapping CDP claim loops racing on the same wallet. A per-wallet
asyncio.Lock collapses them into one:
lock = _refill_lock(wallet_label)
if lock.locked():
logger.debug("Refill already running for %s wallet; coalescing.", wallet_label)
return
async with lock:
... # the single refill loop for this wallet
If a refill is already running for that wallet, the new trigger returns
immediately. The same machinery serves both the faucet wallet and the
escrow signer wallet — each gets its own lock keyed by label, so a faucet
refill and an escrow-signer refill never block each other.
Step 4 — Paced claims with an escalating 429 back-off#
Inside the lock, the loop claims from CDP until the wallet reaches its target, pacing between successful claims so it stays polite. CDP rate-limits (HTTP 429) are expected and handled with an escalating back-off rather than a crash:
try:
await cdp.evm.request_faucet(address=address, network="base-sepolia", token="eth")
except Exception as exc:
if not _is_rate_limited(exc):
raise
await asyncio.sleep(backoff) # start at 90s
backoff = min(backoff * 2, 300.0) # double, capped at 300s
continue
- Pacing:
2.0sbetween successful claims (_CLAIM_PACING_SECONDS). - Back-off: starts at
90s(_RATE_LIMIT_BACKOFF_SECONDS), doubles on each consecutive 429, capped at300s(_RATE_LIMIT_BACKOFF_CAP_SECONDS), and resets to90safter a successful claim. - 429 detection (
_is_rate_limited) checks the exception'sstatus_code/status, then falls back to scanning the message for429,rate limit, ortoo many requests.
Step 5 — Never crash, always clean up#
The refill is operational plumbing, not request-path logic, so it is written to fail quietly:
- The whole claim loop is wrapped so any unexpected error is logged as a warning and the loop abandoned — a failed refill leaves the balance as-is, it never propagates.
- The balance read is best-effort: a flaky RPC is logged and the refill skipped, not raised.
- The CDP client is always closed in a
finallyblock, and even the close is guarded so it can never crash the task.
The result is a faucet that quietly keeps itself funded: it tops up exactly when
it spends, only when it is actually low, without overlapping refills, and degrades
gracefully when CDP throttles it or an RPC blips. From an agent's perspective, a
503 faucet_insufficient_balance is just a brief "refilling, try again in a few
minutes" — the wallet heals itself in the background.