Indexing Story Protocol's IP Graph over Plain RPC
Story is the chain where intellectual property lives as on-chain state: every registered work is an IP Asset, licenses are minted against it, derivatives point back at their parents. That sounds like it needs a special API — but the IP graph is ordinary contract state on an EVM chain, which means you can index it end to end with three RPC primitives you already know: eth_getLogs, eth_call, and a WebSocket subscription. This post builds that pipeline, with every address and event signature below verified live against Story mainnet (chain ID 1514, block ~21.2M at time of writing).
The chain, in three facts that shape your indexer
Before the code, three properties of Story change how an Ethereum-shaped indexer should behave:
- Chain ID is 1514 (
0x5ea). Standard EVM execution — viem, ethers, and Foundry work unchanged against a Story RPC endpoint. - Blocks arrive every ~2.5 seconds with single-block BFT finality (Story runs its EVM over a CometBFT-style consensus engine). A block that exists is final.
- Because of fact 2, you can drop your reorg machinery. The detect-and-rollback logic an Ethereum indexer needs — parent-hash checks, confirmation lag, common-ancestor walkbacks — is dead weight here. Act on events as soon as you see them. (Still key rows on
(txHash, logIndex): it makes re-processing idempotent, which you'll want the first time you replay a range.)
Step 1: find registrations with eth_getLogs
The root of the IP graph is the IPAssetRegistry at 0x77319B4031e6eF1250907aa00018B8B1c67a244b. When a work is registered, it emits:
event IPRegistered(
address ipId,
uint256 indexed chainId,
address indexed tokenContract,
uint256 indexed tokenId,
string name,
string uri,
uint256 registrationDate
);
The event fingerprint — keccak256 of that canonical signature — is:
0x02ad3a2e0356b65fdfe4a73c825b78071ae469db35162978518b8c258abb3767
We verified this the honest way: pulled a recent log from the registry through our own endpoint (a registration at block 21,183,727 carries exactly this topic0 with four topics). Fetching a range looks like:
curl -X POST "https://rpc.swiftnodes.io/rpc/story?key=YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getLogs","params":[{
"address": "0x77319B4031e6eF1250907aa00018B8B1c67a244b",
"topics": ["0x02ad3a2e0356b65fdfe4a73c825b78071ae469db35162978518b8c258abb3767"],
"fromBlock": "0x1430000",
"toBlock": "latest"
}]}'
Two things to notice from the event layout, because they're the classic log-decoding traps:
- The three indexed params (
chainId,tokenContract,tokenId) live intopics[1..3]and are what you can filter by server-side. Want every IP Asset minted from one NFT collection? Put the padded collection address in topic position 2 and let the node do the work. ipId— the field you actually care most about — is not indexed. It's ABI-encoded indataalong withname,uri, andregistrationDate. You can't filter on it; you decode it client-side.
Step 2: decode with the ABI fragment
You don't need the full registry ABI — one event fragment does it:
import { createPublicClient, http, parseAbi, parseEventLogs } from "viem";
const client = createPublicClient({
transport: http("https://rpc.swiftnodes.io/rpc/story?key=YOUR_API_KEY"),
});
const abi = parseAbi([
"event IPRegistered(address ipId, uint256 indexed chainId, address indexed tokenContract, uint256 indexed tokenId, string name, string uri, uint256 registrationDate)",
]);
const logs = await client.getLogs({
address: "0x77319B4031e6eF1250907aa00018B8B1c67a244b",
events: abi,
fromBlock: 21_180_000n,
toBlock: "latest",
});
const parsed = parseEventLogs({ abi, logs });
// parsed[n].args.ipId → the IP Account address, your graph node's primary key
For a full backfill from mainnet genesis (February 2025) to today, don't ask for 21 million blocks in one call — most upstreams enforce range caps on eth_getLogs, and even permissive ones will time out. Walk the chain in fixed chunks (10k–50k blocks), checkpoint the last completed block, and resume from the checkpoint on restart. One practical note from our own probing: registration traffic is bursty — we found windows of several hours with zero registrations and single blocks carrying several. Empty chunks are normal; don't treat them as errors.
Step 3: read the graph with eth_call
Here's Story's distinctive move: the ipId emitted at registration is not just an ID number — it's the address of the asset's IP Account, a contract deployed per asset (in the ERC-6551 token-bound-account style). The registration event gives you the graph's nodes; the edges — license terms attached, royalty splits, derivative parent links — are contract state you read through the protocol's licensing and royalty modules with ordinary eth_call.
This is where indexing an IP graph differs from indexing a DEX: after the event sweep you'll typically do a second pass of per-asset reads to enrich each node. Hundreds of assets means hundreds of eth_calls — batch them. Multicall3 packs the reads into one round trip, and viem's client.multicall does it in one line. If you want to reconstruct state as it was at some historical block (say, license terms at the moment a derivative registered), pin the call to that block number — eth_call at a past block — and route it to an archive node with &archive=1.
Step 4: stay current over WebSocket
With ~2.5-second blocks, polling wastes most of its requests. Subscribe instead:
import { createPublicClient, webSocket } from "viem";
const ws = createPublicClient({
transport: webSocket("wss://rpc.swiftnodes.io/ws/story?key=YOUR_API_KEY"),
});
ws.watchEvent({
address: "0x77319B4031e6eF1250907aa00018B8B1c67a244b",
events: abi,
onLogs: (logs) => upsertAssets(logs), // final on arrival — no confirmation lag
});
Because finality is single-block, the event you receive is settled — no "wait N blocks before trusting it" stage. The one discipline to keep is reconnect handling: on a dropped socket, resubscribe and backfill the gap with a bounded eth_getLogs from your checkpoint, or a registration that fired during the gap silently never reaches your graph.
The whole pipeline
Backfill with chunked eth_getLogs → decode with one ABI fragment → enrich nodes via batched eth_call (archive-pinned when historical) → hold a WebSocket for the live edge, with gap-backfill on reconnect. Checkpoint by block, key by (txHash, logIndex), skip the reorg logic entirely. That's a complete IP-graph indexer built from three standard RPC methods — no custom API, no special SDK required for the read path.
Every call in this post ran against the SwiftNodes Story endpoint — HTTP and WebSocket, archive included on paid plans, flat-rate, no KYC. The free tier is enough to build the whole prototype.
Related posts
- Decoding Event Logs: Topics, Signatures, and Indexed Parameters
You can fetch logs with eth_getLogs and eth_subscribe — but the raw log is address + topics + data, not a readable event. This tutorial explains how logs are encoded (topic0 = the event signature hash, indexed params in topics, the rest in data), how to decode them in viem/ethers/web3.py, and the traps: indexed dynamic types, address padding, and anonymous events.
- Reading Balances Right: eth_getBalance, balanceOf, and the Traps in Between
Reading a balance sounds trivial, but it's where indexers quietly go wrong: native vs token, wrong decimals, summing Transfer events, rebasing and fee-on-transfer tokens, and latest-vs-historical reads. Here's how to read balances correctly over RPC, with viem/ethers/web3.py examples.
- eth_getBlockReceipts: Every Receipt in a Block, One Call
Fetching every receipt in a block one transaction at a time is the N+1 problem in disguise — hundreds of round-trips per block. eth_getBlockReceipts returns all of them in a single call. Here's how it works, when to use it over JSON-RPC batching, and how to fall back when a node doesn't support it.