Recipe: wallet watch
This recipe watches one address. There are two tools, and each sees different things.
| Tool | Sees | Misses | Cost |
|---|---|---|---|
| Polling Wallet activity | Curated-token transfers of any size on one EVM chain, or all signatures on Solana | Other tokens and native transfers on EVM | 3 units per poll per chain |
| A rule on the address | New stored rows of at least $100,000, at most one per cooldown | Everything below that, other tokens, most of Solana | No units |
Use a rule for large moves on Ethereum, Base and BNB Chain. Use polling for the rest. For Solana, use polling, because rules see one block in about 148 and a partial read of 7 exchange wallets.
#Poll for activity
This poll reads Ethereum every 60 seconds, which matches the cache time. At 3 units per poll, one chain costs 3 of your units per minute.
curl -s "https://farbeamlabs.com/api/v1/wallets/0x28C6c06298d514Db089934071355E5743bf21d60/activity?chain=ethereum" \
| jq '.data | {blocks_scanned, from_block, to_block, items: (.items | length)}'// watch.mjs: run with node watch.mjs
const ADDRESS = "0x28C6c06298d514Db089934071355E5743bf21d60";
const CHAIN = "ethereum";
const headers = process.env.FARBEAM_KEY ? { Authorization: `Bearer ${process.env.FARBEAM_KEY}` } : {};
const seen = new Set();
let first = true;
async function tick() {
const res = await fetch(`https://farbeamlabs.com/api/v1/wallets/${ADDRESS}/activity?chain=${CHAIN}`, { headers });
const { data, error } = await res.json();
if (error) {
console.error(error.code, error.message);
return;
}
for (const item of data.items.slice().reverse()) {
const id = `${item.tx}:${item.token_address}:${item.from}:${item.to}:${item.amount}`;
if (seen.has(id)) continue;
seen.add(id);
if (!first) console.log(item.direction, item.amount, item.token, item.from_label ?? item.from, "->", item.to_label ?? item.to, item.tx);
}
first = false;
console.log(`checked blocks ${data.from_block} to ${data.to_block}, ${data.blocks_scanned} blocks`);
}
await tick();
setInterval(() => tick().catch((err) => console.error(err.message)), 60000);# watch.py: run with python3 watch.py
import os, time
import requests
ADDRESS = "0x28C6c06298d514Db089934071355E5743bf21d60"
CHAIN = "ethereum"
key = os.environ.get("FARBEAM_KEY")
headers = {"Authorization": f"Bearer {key}"} if key else {}
seen, first = set(), True
while True:
try:
body = requests.get(
f"https://farbeamlabs.com/api/v1/wallets/{ADDRESS}/activity",
params={"chain": CHAIN},
headers=headers,
timeout=30,
).json()
if "error" in body:
print(body["error"]["code"], body["error"]["message"])
else:
data = body["data"]
for item in reversed(data["items"]):
marker = (item["tx"], item["token_address"], item["from"], item["to"], item["amount"])
if marker in seen:
continue
seen.add(marker)
if not first:
print(item["direction"], item["amount"], item["token"], item["tx"])
first = False
print("checked", data["from_block"], data["to_block"], data["blocks_scanned"])
except requests.RequestException as err:
print("error", err)
time.sleep(60)The first pass stores what exists and prints nothing, so only new items print after that.
#What to watch for in the response
blocks_scannedcan drop from 5,000 to 1,000, 200 or 40 if the RPC refuses the range. A range that is short and a list that is full (40 items, the cap) can hide items between polls on a busy wallet.- The list holds curated tokens only. See the scope field.
- Native ETH is not in this list. Compare
native.amountfrom Wallet overview between polls if you need to see it change. That costs 3 units and covers all six EVM chains.
#Solana
The same path returns the last 25 signatures. Use each new one with Transaction lookup at 2 units.
curl -s "https://farbeamlabs.com/api/v1/wallets/5tzFkiKscXHK5ZXCGbXZxdw7gTjjD1mBwuoFbhUvuAi9/activity" | jq '.data.items[:3]'A wallet with more than 25 signatures between polls will lose the extras. For a busy address, shorten the interval and watch your units.
#Add a rule for large moves
This rule sends a webhook when a stored row touches the address. Comparison ignores case, so a checksummed address works.
from == "0x28C6c06298d514Db089934071355E5743bf21d60" or to == "0x28C6c06298d514Db089934071355E5743bf21d60"Save it through the console or Create a rule. The default cooldown of 300 s drops other matches for 5 minutes, so set cooldown_seconds to 60 for a busy address. A list of addresses also works, up to the limit of 1,000 characters:
from in ["0x28C6c06298d514Db089934071355E5743bf21d60", "0x21a31Ee1afC51d94C2eFcCAa2092aD1028285549"] or to in ["0x28C6c06298d514Db089934071355E5743bf21d60", "0x21a31Ee1afC51d94C2eFcCAa2092aD1028285549"]#Limits to state in your own product
- The watch is not complete. A silent watch does not mean a silent wallet.
- Polling stops when your process stops. Webhooks do not replay. Rows of at least $100,000 can be recovered for 7 days from List large transfers with the
addressfilter. - Wallet clustering is Planned, and it is not built. Watching one address tells you nothing about other addresses the same owner may control.
Use this for addresses you have a reason to monitor, such as your own treasury or public entities. Do not use it to track private individuals. See acceptable use.