Trace where an exchange wallet's money goes
Follow a wallet's money two hops out 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, so a trace is not outbound only: the heaviest edge from the root isin_usdonly, money flowing toward the root. - 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.
Find who is buying a token, and who they are
Screen Ethereum tokens for smart-money inflow, take the top token's biggest buyers and label them in one batch call, flagging the ones that are not exchanges, protocols or bots.
Page through large reads
The three ways the /v1 routes page, a cursor, an offset and a moving time window, each walked in one script with its stop condition.