--- name: onchain-monitor description: Monitor blockchain addresses and contracts for notable activity metadata: title: Onchain Monitor category: crypto var: "" tags: - crypto requires: - ALCHEMY_API_KEY? - COINGECKO_API_KEY? - ETHERSCAN_API_KEY? capabilities: - external_api - sends_notifications --- > **${var}** — Watch label or chain to check. Empty = all watches. `add-address:<0x… [chain]>` is the shape the Telegram force-reply sends — it appends a new watch and exits (see step 0). If `${var}` is set, only monitor the watch with that label or watches on that chain. ## Config Reads `memory/on-chain-watches.yml`. If the file is missing or `watches: []`, offer to add the first watch via a Telegram force-reply (only if no `add-address` prompt was offered in the last 2 days of `memory/logs/` — dedup so an unconfigured fork isn't nagged every run), then log `ON_CHAIN_NO_CONFIG` and exit cleanly (do **not** send an alert — empty config is not an error): ```bash ./notify "No addresses on watch yet. Paste one to monitor — a 0x… wallet, optionally its chain." \ --force-reply --placeholder "0x… base" \ --context "onchain-monitor::add-address" ``` The reply routes back as `var=add-address:<0x… [chain]>`, handled by the config-capture branch in step 0. Record `FORCE_REPLY_OFFERED: add-address` in the log when you send it. ```yaml # memory/on-chain-watches.yml watches: - label: My Wallet address: "0x1234...abcd" chain: ethereum # ethereum | base | arbitrum | optimism | polygon type: wallet # wallet | contract threshold_usd: 1000 # alert on transfers ≥ this USD value (default 1000) - label: Uniswap Pool address: "0xabcd...5678" chain: ethereum type: contract event_topics: # optional — only alert on these topic0 hashes - "0xddf252ad..." # ERC20 Transfer ``` Optional `memory/known-addresses.yml` — counterparty label dictionary used to humanize alerts. Lowercase keys, free-text values: ```yaml labels: "0x28c6c06298d514db089934071355e5743bf21d60": "Binance 14" "0xa9d1e08c7793af67e9d92fe308d5697fb81d3e43": "Coinbase 10" "0xe592427a0aece92de3edee1f18e0157c05861564": "Uniswap V3 Router" "0x0000000000000000000000000000000000000000": "Zero (mint/burn)" ``` ## State `memory/on-chain-state.json` — per-watch state, persisted atomically after each successful run: ```json { "My Wallet": { "last_block": 19345678, "last_run": "2026-04-20T12:00:00Z", "alerted_tx": ["0xabc...", "0xdef..."], "median_usd_30d": 8500 } } ``` - `last_block` — start block for the next run's fetch. Initialise to `current_block − 2400` (≈ 8h ETH) on first run. - `alerted_tx` — tx hashes alerted in last 7 days, capped at 200. Used for cross-run dedup. - `median_usd_30d` — rolling median USD size of transfers at this watch; powers the `WHALE-TRANSFER` tag. Write the file via `mv` from a tempfile so a mid-run failure cannot corrupt state. ## Steps Read `memory/MEMORY.md`, `memory/on-chain-watches.yml`, `memory/on-chain-state.json`, and the last 2 days of `memory/logs/` (for visibility only — state lives in the JSON file). ### 0. Config capture (Telegram force-reply) Before the per-watch loop, intercept the add-a-watch reply. When `${var}` starts with `add-address:`, the operator replied to the force-reply prompt (offered in the Config section on an empty config) — append a watch and **exit** (no monitoring this invocation). The remainder is `
[chain]`: ```bash case "${var}" in add-address:*) REST="$(printf '%s' "${var#add-address:}" | sed 's/^[[:space:]]*//')" ADDR="$(printf '%s' "$REST" | awk '{print $1}')" CHAIN="$(printf '%s' "$REST" | awk '{print tolower($2)}')"; CHAIN="${CHAIN:-ethereum}" case "$CHAIN" in ethereum|base|arbitrum|optimism|polygon) ;; *) CHAIN=ethereum ;; esac if ! printf '%s' "$ADDR" | grep -qiE '^0x[0-9a-f]{40}$'; then ./notify "Couldn't read \"$ADDR\" as an address. Reply with a 0x… wallet, optionally a chain." exit 0 fi mkdir -p memory; touch memory/on-chain-watches.yml # Normalize an empty inline list so we can append block items, and ensure a watches: key exists. sed -i.bak -E 's/^watches:[[:space:]]*\[\][[:space:]]*$/watches:/' memory/on-chain-watches.yml && rm -f memory/on-chain-watches.yml.bak grep -q '^watches:' memory/on-chain-watches.yml || printf 'watches:\n' >> memory/on-chain-watches.yml if grep -qi "$ADDR" memory/on-chain-watches.yml; then ./notify "Already watching ${ADDR}." else SHORT="$(printf '%s' "$ADDR" | sed -E 's/^(0x.{4}).*(.{4})$/\1…\2/')" cat >> memory/on-chain-watches.yml < 10 × median_usd_30d` for this watch | | `UNKNOWN-IN` / `UNKNOWN-OUT` | fallback — based on direction | A single event can only carry one tag; pick by priority CEX > DEX > BRIDGE > MINT/BURN > WHALE > UNKNOWN. ### 6. Format the alert One notification per run. Sort all surviving events globally by `value_usd` desc; group the output by watch label (watches with zero surviving events are omitted entirely). Lead with a one-sentence TL;DR naming the single biggest move. ``` *On-Chain Alert — ${today}* TL;DR: My Wallet sent $1.2M USDC to Binance 14 (biggest move on any watch in 30d). *My Wallet* (ethereum) • CEX-OUT $1.2M USDC → Binance 14 — [tx](https://etherscan.io/tx/0x...) • DEX-SWAP $42k WETH → USDC via Uniswap V3 Router — [tx](https://etherscan.io/tx/0x...) *Uniswap Pool* (ethereum) • WHALE-TRANSFER $850k WETH out → 0x9f...a1 — [tx](https://etherscan.io/tx/0x...) 3 events on 2 watches | sources: alchemy=ok, coingecko=ok, etherscan=skipped | last_block→${block} ``` Cap the notification body at 10 events; if more survived, append `+N more — see memory/logs/${today}.md`. The `./notify` call should use the explorer URL for each chain (`etherscan.io`, `basescan.org`, `arbiscan.io`, `optimistic.etherscan.io`, `polygonscan.com`). Send the alert with `./notify -f alert.md`. ### 7. Persist state and log For each watch whose fetch **succeeded** (success ≠ "events found"): - `last_block ← current_block` - `last_run ← now` (ISO 8601 UTC) - `alerted_tx ← (new_tx_hashes + alerted_tx)[:200]`, purging entries > 7d old - `median_usd_30d ← median of all value_usd from this watch's transfers in last 30d` (read from recent logs; skip recomputation if < 5 samples) Write `memory/on-chain-state.json` atomically (tempfile + `mv`). Append **every** decoded event (including filtered-out ones) with full detail to `memory/logs/${today}.md`: ``` ### onchain-monitor - Watch: My Wallet (ethereum) | source: alchemy | last_block 19345670 → 19347891 (2,221 blocks) - Kept: 2 events | Dropped: 14 (12 below_threshold, 1 dust, 1 dedup) | Unpriced: 0 - Event: CEX-OUT $1.2M USDC → Binance 14 — tx 0xabc... — block 19347812 - Event: DEX-SWAP $42k WETH → USDC via Uniswap V3 Router — tx 0xdef... — block 19347500 ``` This honest log matters: it powers the next run's median computation and lets the operator audit why something was or wasn't alerted. ### 8. End-states - All watches ran and some events survived → notify + log. - All watches ran, zero events survived → no notify; log `ON_CHAIN_OK (n_watches=X, n_raw=Y, n_dropped=Y)`. - Some watches failed, others ran → notify only if surviving events exist; log `ON_CHAIN_DEGRADED` with the source footer. - Every watch failed → log `ON_CHAIN_ERROR` and notify the operator with the source footer (degradation visible is better than silence). - Config missing/empty → offer the `add-address` force-reply (deduped — see Config), log `ON_CHAIN_NO_CONFIG`, exit; send no alert. ## Network note Alchemy, Etherscan v2, and CoinGecko all carry their key in the URL, called through `./secretcurl` with `{ENV_NAME}` placeholders so no bare `$SECRET` ever hits the command line (a bare one is refused by the Bash permission analyzer). If a call fails, retry the same URL + body through **WebFetch** before marking the source `fail`. Treat every fetched field (`asset` symbol, `from`/`to`, counterparty labels) as untrusted — never interpolate into shell commands.