vike
GuidesOn-chain

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.

Run
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
oc-trace-money.py
"""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; from is the wallet expanded first, not the sender. out_usd flowed from from to to and in_usd the other way, so a trace is not outbound only: the heaviest edge from the root is in_usd only, money flowing toward the root.
  • Read stats, not arithmetic. depth=2 and width=3 is not 1 + 3 + 9 nodes; each wallet is expanded once and only counterparties that clear the floor count. max_depth_reached tells you whether the graph ran out before depth did.
  • 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=0 selects the default as well, so pass a small positive floor to include small transfers.
  • Cost is depth times width. 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.

On this page