Recipe 5 of 32
Follow the money: trace a wallet's funds N hops
Follow a wallet's money two hops from the root at three counterparties per hop, then list the heaviest edges with their labels and direction.
/wallets/{address}/trace expands from a root wallet to its top
counterparties by USD volume, hop by hop. The recipe traces the Binance 14 hot wallet over 30 days with depth=2
and width=3, then prints the five heaviest edges.
export VIKE_API_KEY=...
python oc-trace-money.py # real run: needs the key
python oc-trace-money.py --dry-run # prints the requests it would make, calls nothing"""Follow an exchange wallet's money two hops out and read who it reached.
Run: VIKE_API_KEY=... python oc-trace-money.py (--dry-run lists the requests and makes none)
"""
import os
import sys
from urllib.parse import urlencode
NAME = "oc-trace-money"
BASE = "https://data.vike.io"
DRY = "--dry-run" in sys.argv
ROOT = "0x28C6c06298d514Db089934071355E5743bf21d60" # the Binance 14 hot wallet
def fail(why):
sys.exit(f"{NAME}: {why}")
def call(path, method="GET", **params):
url = BASE + path + ("?" + urlencode(params) if params else "")
if DRY:
print(method, url)
return None
key = os.environ.get("VIKE_API_KEY") or fail("set VIKE_API_KEY")
import requests
r = requests.request(method, url, headers={"X-API-Key": key}, timeout=60)
if r.status_code != 200:
fail(f"{method} {path} answered HTTP {r.status_code}: {r.text[:200]}")
return r.json()
def main():
# Cost grows with depth x width, so this is the heaviest route in the namespace: keep both small.
# min_usd defaults to 10,000 when omitted; the graph holds only transfers that clear it.
graph = call(f"/v1/onchain/wallets/{ROOT}/trace", depth=2, width=3, window="30d")
if DRY:
return
nodes = graph["nodes"]
if len(nodes) < 2:
fail("the trace reached nobody beyond the root")
names = {n["address"]: f"{n.get('label') or n['address'][:10]} ({n.get('wallet_type') or '?'})" for n in nodes}
print(f"{graph['stats']['node_count']} wallets, {graph['stats']['edge_count']} edges, "
f"{graph['stats']['max_depth_reached']} hop(s) reached, floor ${graph['min_usd']:,.0f} per transfer")
# An edge is a pair of wallets, not a one-way payment: out_usd and in_usd split the volume by direction.
for e in sorted(graph["edges"], key=lambda e: -e["volume_usd"])[:5]:
print(f" {names.get(e['from'], e['from'][:10])} <-> {names.get(e['to'], e['to'][:10])} "
f"out ${e['out_usd']:,.0f} in ${e['in_usd']:,.0f} {e['tx_count']} txs")
if __name__ == "__main__":
try:
main()
except Exception as e: # a shape the recipe does not expect fails by name, not by traceback
fail(f"{type(e).__name__}: {e}")Read it this way
- Edges are pairs, not payments. One edge per pair of wallets;
fromis the wallet expanded first, not the sender.out_usdflowed fromfromtotoandin_usdthe other way. The recipe takes the defaultdirection=both, so it follows money arriving as well as leaving: the heaviest edge from the root isin_usdonly, money flowing toward the root. Passdirection=outto follow only where the money went, orinfor where it came from. - Read
stats, not arithmetic.depth=2andwidth=3is not 1 + 3 + 9 nodes; each wallet is expanded once and only counterparties that clear the floor count.max_depth_reachedtells you whether the graph ran out beforedepthdid. - The floor is $10,000 per transfer when you omit
min_usd, and the response echoes the floor it used. The script prints it.min_usd=0selects the default as well, so pass a small positive floor to include small transfers. - Cost is
depthtimeswidth. It is the heaviest route in the namespace, which is why the recipe keeps both small.
A misspelled window is a 200 with the wrong question
An unrecognised window reads 30 days without complaint, while an unrecognised chain is a 400. Spell
window from the enum on the endpoint page.