Accessing & Working with Public On-Chain Data
Pull real XRP Ledger data yourself: the public servers and explorer APIs, the four methods you need, how to page through history without getting rate limited, and four Python exercises that end with a payments dataset and a value-flow graph ready for modelling.
What you'll learn
- Choose a public Mainnet server or explorer API and explain the trade-offs between them.
- Fetch a full ledger and an account's history with xrpl-py, paging correctly with markers.
- Download data responsibly: pinned ranges, caching, and back-off when a server is busy.
- Normalise raw transactions into a table using delivered_amount and TransactionResult.
- Build a directed, weighted value-flow graph and read basic fan-in, fan-out and pass-through statistics from it.
Overview
At the hackathon you'll pull the data yourself. There is no dataset handed to you, and that's the point: the XRP Ledger's entire history is public, and anyone can read it for free. This module shows you where to read it from, how to download it without getting rate limited, and how to turn raw transactions into a table and a graph you can feed to a model.
This is the hands-on module. In the live session you'll run two short Python scripts against Mainnet (the real ledger, read-only, no wallet or XRP needed), Exercises 1 and 3, and finish with a CSV of payments and a value-flow graph. Exercise 2 is optional: do it after the session, it's the one you'll need for tracing on the day.
What you need
- Python 3.10 or later, locally or in Google Colab (prefix the install line with
!). - The official Python library, xrpl-py, plus the usual data stack:
pip install xrpl-py==5.2.0 pandas networkx scikit-learn
Prefer JavaScript? The same methods exist in xrpl.js (npm install xrpl), and every request below has the same name and fields there. The examples here use Python because most data and ML teams will.
Reading the ledger needs no account, no seed and no XRP. Never paste a secret key into a notebook for this hackathon: you don't need one.
Where the data lives
You talk to the ledger through servers, over JSON-RPC (HTTP) or WebSocket. Several operators run free public servers:
| Network | Server | JSON-RPC URL | Notes |
|---|---|---|---|
| Mainnet | Ripple, full history | https://s2.ripple.com:51234/ |
Every ledger since the beginning |
| Mainnet | Ripple, general | https://s1.ripple.com:51234/ |
Recent history only |
| Mainnet | InFTF, full history | https://xrplcluster.com/ |
Enforces a per-minute usage quota |
| Testnet | Ripple | https://s.altnet.rippletest.net:51234/ |
Test network, not real activity |
Use Mainnet. Testnet is for building apps; it has no real-world behaviour to detect.
Indexed explorer APIs sit on top of the raw ledger and add convenience, most usefully labels:
- XRPScan API (
https://api.xrpscan.com/api/v1): account transactions, account info, and a list of well-known account names (exchanges, issuers, services) at/names/well-known. The free tier has a daily request limit. - Bithomp API: rich account data and usernames; needs a free API key.
Labels are off-ledger attribution made by the explorer. They're extremely useful for features ("sent to a known exchange") but treat them as evidence, not truth.
The four methods you'll use
| Method | Question it answers | Paging |
|---|---|---|
ledger (with transactions and expand) |
Everything that happened in ledger N | One call per ledger |
account_tx |
Everything that touched account X | Uses a marker |
account_info |
Balance, flags and sequence of account X right now | None |
account_lines |
Which tokens account X holds | Uses a marker |
Two download strategies follow from this:
- Ledger sweep: fetch every ledger in a range and keep all payments. Best for building a network-wide graph of a time window.
- Account crawl: start from a seed account and follow
account_txoutward, hop by hop. Best for tracing where specific funds went.
Be a good citizen: rate limits
Public servers are shared by everyone, including every other team in the room on the same Wi-Fi. They protect themselves with quotas: when you exceed them, the server answers with a tooBusy or slowDown error instead of data. Deep history scans are expensive, so a single careless loop can lock your team out for minutes.
- Pin your range to fixed ledger numbers instead of "all history". It's cheaper and it makes your dataset reproducible.
- Cache everything to disk (CSV or Parquet) and build your model from the cache, never from live calls.
- Back off and retry when the server says it's busy. Every script below does this.
- Spread the load: different team members can use different servers.
- Start small (20 ledgers), check the output, then scale up.
Exercise 1: snapshot one ledger
Fetch the latest validated ledger with every transaction expanded and count what's inside.
"""Exercise 1: take a snapshot of one validated Mainnet ledger.
Run: python ex1_ledger_snapshot.py
Needs: pip install xrpl-py==5.2.0
"""
import os
from collections import Counter
from xrpl.clients import JsonRpcClient
from xrpl.models.requests import Ledger, ServerInfo
# Public servers are shared and rate limited: be polite, cache what you
# download, and never hammer them in a tight loop.
# Public full-history Mainnet servers: https://s2.ripple.com:51234/ (Ripple) or
# https://xrplcluster.com/ (InFTF). Override with: export XRPL_RPC=<url>
client = JsonRpcClient(os.environ.get("XRPL_RPC", "https://s2.ripple.com:51234/"))
# 1. Which ledger is the latest validated one?
info = client.request(ServerInfo()).result["info"]
latest = info["validated_ledger"]["seq"]
print(f"Latest validated ledger: {latest}")
print(f"Reserves: base {info['validated_ledger']['reserve_base_xrp']} XRP, "
f"owner {info['validated_ledger']['reserve_inc_xrp']} XRP per object")
# 2. Fetch that ledger with every transaction expanded (full JSON, not just hashes).
ledger = client.request(
Ledger(ledger_index=latest, transactions=True, expand=True)
).result["ledger"]
txs = ledger["transactions"]
print(f"Ledger {ledger['ledger_index']} closed at {ledger.get('close_time_iso')} "
f"with {len(txs)} transactions")
# 3. In API v2 each entry holds the signed fields in `tx_json` and the outcome in `meta`.
types = Counter()
results = Counter()
for entry in txs:
tx = entry.get("tx_json", entry)
meta = entry.get("meta") or entry.get("metaData") or {}
types[tx["TransactionType"]] += 1
results[meta.get("TransactionResult", "?")] += 1
print("\nTransaction types:")
for name, count in types.most_common():
print(f" {name:<22} {count}")
print("\nResults:")
for name, count in results.most_common():
print(f" {name:<22} {count}")
You should see a few hundred transactions, mostly Payment and OfferCreate, and a handful of tec failures. Notice that the current reserves come straight from the server: never hard-code them.
Exercise 2 (optional, after the session): download an account's history
Page through account_tx for one account and normalise each transaction into a row. The two lines that matter most are reading delivered_amount (not Amount) and pinning the ledger range.
"""Exercise 2: download an account's history and turn it into a table.
Run: python ex2_account_history.py rACCOUNT [max_pages] [ledger_window]
Needs: pip install xrpl-py==5.2.0 pandas
"""
import os
import sys
import time
import pandas as pd
from xrpl.clients import JsonRpcClient
from xrpl.models.requests import AccountTx, ServerInfo
# Public full-history Mainnet servers: https://s2.ripple.com:51234/ (Ripple) or
# https://xrplcluster.com/ (InFTF). Override with: export XRPL_RPC=<url>
client = JsonRpcClient(os.environ.get("XRPL_RPC", "https://s2.ripple.com:51234/"))
if len(sys.argv) < 2:
sys.exit("usage: python ex2_account_history.py rACCOUNT [max_pages] [ledger_window]\n"
"Tip: pick an active account from the output of ex3_flow_graph.py or from an explorer.")
ACCOUNT = sys.argv[1]
MAX_PAGES = int(sys.argv[2]) if len(sys.argv) > 2 else 5
# About 20,000 ledgers is roughly one day at 3 to 5 seconds per ledger.
LEDGER_WINDOW = int(sys.argv[3]) if len(sys.argv) > 3 else 20_000
def request(req, retries=5):
"""Send a request, backing off and retrying when the public server is busy."""
for attempt in range(retries):
response = client.request(req)
if response.is_successful():
return response.result
print(f" server said {response.result.get('error')}, retrying...")
time.sleep(2 ** attempt)
raise RuntimeError(f"request failed: {response.result}")
def parse_amount(amount):
"""Return (value, asset) for any XRPL amount shape."""
if amount is None or amount == "unavailable":
return None, None
if isinstance(amount, str): # XRP, expressed in drops
return int(amount) / 1_000_000, "XRP"
if "mpt_issuance_id" in amount: # Multi-Purpose Token
return float(amount["value"]), f"MPT:{amount['mpt_issuance_id']}"
# Issued token: the asset is currency code AND issuer, never the code alone
return float(amount["value"]), f"{amount['currency']}.{amount['issuer']}"
def to_row(entry):
tx = entry.get("tx_json", entry.get("tx", {}))
meta = entry.get("meta", {})
# delivered_amount is what actually arrived. Never trust Amount / DeliverMax:
# a partial payment can succeed while delivering far less.
value, asset = parse_amount(meta.get("delivered_amount"))
return {
"hash": entry.get("hash", tx.get("hash")),
"ledger_index": entry.get("ledger_index", tx.get("ledger_index")),
"time": entry.get("close_time_iso"),
"type": tx.get("TransactionType"),
"result": meta.get("TransactionResult"),
"account": tx.get("Account"),
"destination": tx.get("Destination"),
"destination_tag": tx.get("DestinationTag"),
"source_tag": tx.get("SourceTag"),
"delivered_value": value,
"delivered_asset": asset,
"fee_xrp": int(tx.get("Fee", 0)) / 1_000_000,
}
# Pin the range to fixed ledger numbers. A bounded window is cheaper for the server
# (deep history scans burn your rate-limit quota fast) and makes your dataset
# reproducible: the same query returns the same rows tomorrow.
latest = request(ServerInfo())["info"]["validated_ledger"]["seq"]
first = latest - LEDGER_WINDOW
print(f"Fetching {ACCOUNT} from ledger {first} to {latest}")
rows, marker, page = [], None, 0
while page < MAX_PAGES:
result = request(AccountTx(
account=ACCOUNT,
ledger_index_min=first,
ledger_index_max=latest,
limit=200,
forward=False, # newest first
marker=marker,
))
rows += [to_row(e) for e in result["transactions"]]
page += 1
marker = result.get("marker")
print(f"page {page}: {len(rows)} transactions so far")
if marker is None: # no marker means there is nothing left
break
time.sleep(0.5) # be polite to the shared public server
if not rows:
sys.exit("No transactions in this window. Try a larger ledger_window or a busier account.")
df = pd.DataFrame(rows)
df["time"] = pd.to_datetime(df["time"])
df.to_csv(f"{ACCOUNT}_history.csv", index=False)
payments = df[(df["type"] == "Payment") & (df["result"] == "tesSUCCESS")]
incoming = payments[payments["destination"] == ACCOUNT]
outgoing = payments[payments["account"] == ACCOUNT]
print(f"\nSaved {len(df)} rows to {ACCOUNT}_history.csv")
print(f"Successful payments: {len(incoming)} in, {len(outgoing)} out")
print(f"Distinct destination tags on incoming payments: "
f"{incoming['destination_tag'].nunique()}")
print(f"Failed (tec) transactions still recorded on ledger: "
f"{(df['result'].str.startswith('tec')).sum()}")
print(df[["time", "type", "result", "account", "destination",
"delivered_value", "delivered_asset"]].head(10).to_string(index=False))
How pagination works: when a response contains a marker, there's more. Send the exact same query again with that marker, and stop when no marker comes back. Markers expire after a few minutes, so don't save them for later.
Exercise 3: build a value-flow graph
Sweep a range of ledgers, keep the successful XRP payments, and build a directed, weighted graph where each node is an account and each edge is the total XRP sent from one account to another.
"""Exercise 3: build a value-flow graph from a range of ledgers.
Run: python ex3_flow_graph.py [number_of_ledgers]
Needs: pip install xrpl-py==5.2.0 pandas networkx
"""
import os
import sys
import time
import networkx as nx
import pandas as pd
from xrpl.clients import JsonRpcClient
from xrpl.models.requests import Ledger, ServerInfo
# Public full-history Mainnet servers: https://s2.ripple.com:51234/ (Ripple) or
# https://xrplcluster.com/ (InFTF). Override with: export XRPL_RPC=<url>
client = JsonRpcClient(os.environ.get("XRPL_RPC", "https://s2.ripple.com:51234/"))
N_LEDGERS = int(sys.argv[1]) if len(sys.argv) > 1 else 50
def request(req, retries=5):
"""Send a request, backing off and retrying when the public server is busy."""
for attempt in range(retries):
response = client.request(req)
if response.is_successful():
return response.result
print(f" server said {response.result.get('error')}, retrying...")
time.sleep(2 ** attempt)
raise RuntimeError(f"request failed: {response.result}")
def xrp_delivered(meta):
"""Delivered XRP in whole XRP, or None when the payment delivered a token."""
amount = meta.get("delivered_amount")
return int(amount) / 1_000_000 if isinstance(amount, str) else None
latest = request(ServerInfo())["info"]["validated_ledger"]["seq"]
edges = []
for index in range(latest - N_LEDGERS + 1, latest + 1):
ledger = request(Ledger(ledger_index=index, transactions=True, expand=True))["ledger"]
for entry in ledger["transactions"]:
tx = entry.get("tx_json", entry)
meta = entry.get("meta", {})
if tx["TransactionType"] != "Payment" or meta.get("TransactionResult") != "tesSUCCESS":
continue
value = xrp_delivered(meta)
if value is None:
continue # keep this first graph to XRP only
edges.append({
"src": tx["Account"], "dst": tx["Destination"], "xrp": value,
"tag": tx.get("DestinationTag"), "ledger": index,
"time": ledger.get("close_time_iso"), "hash": entry.get("hash"),
})
time.sleep(0.2)
df = pd.DataFrame(edges)
df.to_csv("xrp_payments.csv", index=False)
print(f"{len(df)} successful XRP payments across {N_LEDGERS} ledgers")
# Aggregate parallel payments into one weighted, directed edge per pair of accounts.
G = nx.DiGraph()
for (src, dst), group in df.groupby(["src", "dst"]):
G.add_edge(src, dst, xrp=group["xrp"].sum(), count=len(group))
print(f"Graph: {G.number_of_nodes()} accounts, {G.number_of_edges()} edges")
stats = pd.DataFrame({
"in_degree": dict(G.in_degree()),
"out_degree": dict(G.out_degree()),
"xrp_in": dict(G.in_degree(weight="xrp")),
"xrp_out": dict(G.out_degree(weight="xrp")),
}).fillna(0)
# A pass-through account receives and forwards almost the same value in the window.
moved = stats[(stats.xrp_in > 0) & (stats.xrp_out > 0)].copy()
moved["pass_through"] = moved[["xrp_in", "xrp_out"]].min(axis=1) / moved[["xrp_in", "xrp_out"]].max(axis=1)
print("\nTop receivers (fan-in):")
print(stats.sort_values("in_degree", ascending=False).head(5).to_string())
print("\nTop senders (fan-out):")
print(stats.sort_values("out_degree", ascending=False).head(5).to_string())
print("\nAccounts forwarding at least 90% of what they received:")
print(moved[moved.pass_through >= 0.9].sort_values("xrp_in", ascending=False).head(5).to_string())
Look at the output. You'll almost certainly see one or two accounts sending hundreds of payments worth a fraction of a cent: that's dust spam, not laundering. You'll also see accounts that forward almost exactly what they received. Hold on to that observation; it's the starting point of the next module.
Going further
Once the basics work, the same toolkit scales to a real hackathon dataset:
- Tokens, not just XRP: extend
xrp_deliveredto keep issued-token amounts, keyed bycurrency.issuer. Stablecoin flows are where a lot of real-world value moves. - Account enrichment: for each account in your graph, call
account_info(balance, flags such asRequireDest) and look up the XRPScan well-known name. - Funding parents: the first transaction in an account's history (
account_txwithforward=True,limit=1) is usually the Payment that created it. - WebSocket streams:
subscribeto thetransactionsstream to watch the ledger live, useful for a demo. - BigQuery: a community-maintained public BigQuery table of XRPL history exists (see the resources), which can save hours of downloading. Check that it covers your window before you rely on it.
Check
You're done when you have xrp_payments.csv and a graph built from it, you can explain what a marker does, and you know why your team should cache data instead of calling the server inside a training loop. Submit the hash of one transaction from your dataset.
Resources
Public servers — XRPL Docs
doc· xrpl.org
account_tx — XRPL Docs
doc· xrpl.org
ledger — XRPL Docs
doc· xrpl.org
Markers and pagination — XRPL Docs
doc· xrpl.org
xrpl-py documentation
doc· xrpl-py.readthedocs.io
XRPScan API documentation
doc· docs.xrpscan.com
XRPScan well-known accounts endpoint
doc· docs.xrpscan.com
Bithomp API documentation
doc· docs.bithomp.com
XRPL history in BigQuery (community project)
article· github.com
Assignments
0 of 4 completeUnlocks
Finishing this module opens up: