On-Chain Data in Productie: Wat Niemand Je Vertelt
Blockchain-data is niet schoon, betrouwbaar of eenvoudig. RPC-ratelimieten, chain-reorgs, BigInt-bugs en indexeringskeuzes — harde lessen uit het shippen van echte DeFi-producten.
Er bestaat een fantasie dat on-chain data inherent betrouwbaar is. Onveranderbaar grootboek. Transparante staat. Gewoon uitlezen en je bent klaar.
Ik geloofde het ook. Toen shipte ik een DeFi-dashboard naar productie en besteedde drie weken aan het uitzoeken waarom onze tokenbalansen niet klopten, onze eventgeschiedenis hiaten had, en onze database transacties bevatte uit blokken die niet meer bestonden.
On-chain data is rauw, vijandig en vol edge cases die je applicatie op manieren breken die je niet opmerkt totdat een gebruiker een bugreport indient. Dit bericht behandelt alles wat ik op de harde manier heb geleerd.
De Illusie van Betrouwbare Data#
Hier is het eerste dat niemand je vertelt: de blockchain geeft je geen data. Het geeft je state-transities. Er is geen SELECT * FROM transfers WHERE user = '0x...'. Er zijn logs, receipts, storage slots en call traces — allemaal gecodeerd in formaten die context vereisen om te decoderen.
Een Transfer-eventlog geeft je from, to en value. Het vertelt je niet het tokensymbool. Het vertelt je niet de decimalen. Het vertelt je niet of dit een legitieme overdracht is of een fee-on-transfer token dat 3% afroomt. Het vertelt je niet of dit blok over 30 seconden nog zal bestaan.
Het "onveranderlijke" deel klopt — eenmaal gefinaliseerd. Maar finalisatie is niet instant. En de data die je terugkrijgt van een RPC-node is niet per se van een gefinaliseerd blok. De meeste developers queryen latest en behandelen het als waarheid. Dat is een bug, geen feature.
Dan is er de codering. Alles is hex. Adressen zijn mixed-case checksummed (of niet). Tokenbedragen zijn integers vermenigvuldigd met 10^decimals. Een USDC-overdracht van $100 ziet eruit als 100000000 on-chain, omdat USDC 6 decimalen heeft, niet 18. Ik heb productiecode gezien die 18 decimalen aannam voor elk ERC-20 token. De resulterende balansen weken af met een factor 10^12.
RPC-Ratelimieten Verpesten Je Weekend#
Elke productie-Web3-app praat met een RPC-endpoint. En elk RPC-endpoint heeft ratelimieten die veel agressiever zijn dan je verwacht.
Dit zijn de cijfers die ertoe doen:
- Alchemy Free: ~30M compute units/maand, 40 requests/minuut. Dat klinkt royaal totdat je beseft dat een enkele
eth_getLogs-aanroep over een breed blokbereik honderden CU's kan kosten. Je verbrandt je maandelijkse quotum in een dag indexeren. - Infura Free: 100K requests/dag, ongeveer 1,15 req/sec. Probeer maar eens door 500K blokken aan eventlogs te pagineren met dat tempo.
- QuickNode Free: Vergelijkbaar met Infura — 100K requests/dag.
De betaalde tiers helpen, maar elimineren het probleem niet. Zelfs met $200/maand op Alchemy's Growth-plan zal een zware indexeertaak doorvoerlimieten bereiken. En wanneer je ze bereikt, krijg je geen graceful degradation. Je krijgt 429-fouten, soms met nutteloze meldingen, soms zonder retry-after header.
De oplossing is een combinatie van fallback-providers, retry-logica en heel bewust zijn over welke aanroepen je doet. Hier is hoe een robuuste RPC-setup eruitziet met viem:
import { createPublicClient, fallback, http } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: fallback(
[
http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
retryCount: 3,
retryDelay: 1500,
timeout: 15_000,
}),
http("https://mainnet.infura.io/v3/YOUR_KEY", {
retryCount: 3,
retryDelay: 1500,
timeout: 15_000,
}),
http("https://rpc.ankr.com/eth", {
retryCount: 2,
retryDelay: 2000,
timeout: 20_000,
}),
],
{ rank: true }
),
});De rank: true-optie is cruciaal. Het vertelt viem om latency en succespercentage voor elke transport te meten en automatisch de snelste, meest betrouwbare te prefereren. Als Alchemy je begint te ratelimiten, verschuift viem verkeer naar Infura. Als Infura uitvalt, valt het terug op Ankr.
Maar er is een subtiliteit: viem's standaard retry-logica gebruikt exponential backoff, wat meestal is wat je wilt. Echter, per begin 2025 is er een bekend probleem waarbij retryCount RPC-level fouten (zoals 429's) niet correct herprobeert wanneer batchmodus is ingeschakeld. Als je requests batcht, test dan je retry-gedrag expliciet. Vertrouw er niet op dat het werkt.
Reorgs: De Bug Die Je Niet Ziet Aankomen#
Een chain-reorganisatie vindt plaats wanneer het netwerk tijdelijk het oneens is over welk blok canoniek is. Node A ziet blok 1000 met transacties [A, B, C]. Node B ziet een ander blok 1000 met transacties [A, D]. Uiteindelijk convergeert het netwerk en wint een versie.
Op proof-of-work-chains was dit gebruikelijk — reorgs van 1-3 blokken kwamen meerdere keren per dag voor. Post-merge Ethereum is beter. Een succesvolle reorg-aanval vereist nu de coordinatie van bijna 50% van de validators. Maar "beter" is niet "onmogelijk." Er was een opmerkelijke 7-blokken reorg op de Beacon Chain in mei 2022, veroorzaakt door inconsistente client-implementaties van de proposer boost fork.
En het maakt niet uit hoe zeldzaam reorgs zijn op Ethereum mainnet. Als je bouwt op L2's of sidechains — Polygon, Arbitrum, Optimism — zijn reorgs frequenter. Polygon had historisch gezien reorgs van 10+ blokken.
Hier is het praktische probleem: je hebt blok 18.000.000 geindexeerd. Je hebt events naar je database geschreven. Toen werd blok 18.000.000 gereorganiseerd. Nu heeft je database events van een blok dat niet bestaat op de canonieke chain. Die events verwijzen mogelijk naar transacties die nooit hebben plaatsgevonden. Je gebruikers zien fantoom-overdrachten.
De fix hangt af van je architectuur:
Optie 1: Bevestigingsvertraging. Indexeer data pas nadat N blokken aan bevestigingen zijn verstreken. Voor Ethereum mainnet geven 64 blokken (twee epochs) je finaliteitsgaranties. Voor L2's, controleer het specifieke finaliteitsmodel van de chain. Dit is simpel maar voegt latency toe — ongeveer 13 minuten op Ethereum.
Optie 2: Reorgdetectie en rollback. Indexeer agressief maar houd blokhashes bij. Verifieer bij elk nieuw blok dat de parent hash overeenkomt met het vorige blok dat je hebt geindexeerd. Zo niet, dan heb je een reorg gedetecteerd: verwijder alles van de verweesde blokken en herindexeer de canonieke chain.
interface IndexedBlock {
number: bigint;
hash: `0x${string}`;
parentHash: `0x${string}`;
}
async function detectReorg(
client: PublicClient,
lastIndexed: IndexedBlock
): Promise<{ reorged: boolean; depth: number }> {
const currentBlock = await client.getBlock({
blockNumber: lastIndexed.number,
});
if (currentBlock.hash === lastIndexed.hash) {
return { reorged: false, depth: 0 };
}
// Loop terug om te vinden waar de chain divergeerde
let depth = 1;
let checkNumber = lastIndexed.number - 1n;
while (checkNumber > 0n && depth < 128) {
const onChain = await client.getBlock({ blockNumber: checkNumber });
const inDb = await getIndexedBlock(checkNumber); // je DB-lookup
if (onChain.hash === inDb?.hash) {
return { reorged: true, depth };
}
depth++;
checkNumber--;
}
return { reorged: true, depth };
}Dit is niet hypothetisch. Ik had een productiesysteem waar we events indexeerden op de chain-tip zonder reorgdetectie. Drie weken werkte het prima. Toen veroorzaakte een 2-blokken reorg op Polygon een dubbele NFT-mint-event in onze database. De frontend toonde een gebruiker die een token bezat dat hij niet had. Die kostte twee dagen debuggen omdat niemand reorgs zocht als de hoofdoorzaak.
Het Indexeringsprobleem: Kies Je Pijn#
Je hebt drie echte opties om gestructureerde on-chain data in je applicatie te krijgen.
Directe RPC-Aanroepen#
Roep gewoon getLogs, getBlock, getTransaction direct aan. Dit werkt voor kleinschalige reads — het controleren van een gebruikersbalans, het ophalen van recente events voor een enkel contract. Het werkt niet voor historische indexering of complexe queries over meerdere contracten.
Het probleem is combinatorisch. Wil je alle Uniswap V3-swaps van de laatste 30 dagen? Dat zijn ~200K blokken. Met Alchemy's limiet van 2K blokken per getLogs-aanroep zijn dat minimaal 100 gepagineerde requests. Elk telt mee voor je ratelimiet. En als een aanroep mislukt, heb je retry-logica, cursor-tracking en een manier nodig om te hervatten waar je was gebleven.
The Graph (Subgraphs)#
The Graph was de originele oplossing. Definieer een schema, schrijf mappings in AssemblyScript, deploy en query met GraphQL. De Hosted Service is deprecated — alles zit nu op het gedecentraliseerde Graph Network, wat betekent dat je met GRT-tokens betaalt voor queries.
Het goede: gestandaardiseerd, goed gedocumenteerd, groot ecosysteem van bestaande subgraphs die je kunt forken.
Het slechte: AssemblyScript is pijnlijk. Debugging is beperkt. Deployment duurt minuten tot uren. Als je subgraph een bug heeft, deploy je opnieuw en wacht je tot het opnieuw synchroniseert vanaf nul. Het gedecentraliseerde netwerk voegt latency toe en soms lopen indexers achter op de chain-tip.
Ik heb The Graph gebruikt voor read-heavy dashboards waar datversheid van 30-60 seconden acceptabel is. Het werkt daar goed. Ik zou het niet gebruiken voor iets dat real-time data of complexe bedrijfslogica in de mappings vereist.
Custom Indexers (Ponder, Envio)#
Dit is waar het ecosysteem aanzienlijk is gerijpt. Ponder en Envio laten je indexeerlogica schrijven in TypeScript (niet AssemblyScript), lokaal draaien tijdens development en deployen als standalone services.
Ponder geeft je maximale controle. Je definieert event-handlers in TypeScript, het beheert de indexeringspijplijn en je krijgt een SQL-database als output. De afweging: je bezit de infrastructuur. Schalen, monitoring, reorg-afhandeling — het is aan jou.
Envio optimaliseert voor synchronisatiesnelheid. Hun benchmarks tonen aanzienlijk snellere initiele synchronisatietijden vergeleken met The Graph. Ze handelen reorgs native af en ondersteunen HyperSync, een gespecialiseerd protocol voor sneller data ophalen. De afweging: je koopt in op hun infrastructuur en API.
Mijn aanbeveling: als je een productie-DeFi-app bouwt en je hebt engineeringcapaciteit, gebruik Ponder. Als je de snelst mogelijke sync nodig hebt en geen infrastructuur wilt beheren, evalueer Envio. Als je een snel prototype nodig hebt of community-onderhouden subgraphs wilt, is The Graph nog steeds prima.
getLogs Is Gevaarlijker Dan Het Eruitziet#
De eth_getLogs RPC-methode is bedrieglijk simpel. Geef het een blokbereik en wat filters, krijg matchende eventlogs terug. Dit is wat er daadwerkelijk in productie gebeurt:
Blokbereiklimieten varieren per provider. Alchemy kapt af bij 2K blokken (onbeperkt logs) of onbeperkt blokken (max 10K logs). Infura heeft andere limieten. QuickNode heeft andere limieten. Een publieke RPC kan afkappen bij 1K blokken. Je code moet dit allemaal aankunnen.
Responsgrootte-limieten bestaan. Zelfs binnen het blokbereik, als een populair contract duizenden events per blok uitzendt, kan je response de payloadlimiet van de provider overschrijden (150MB bij Alchemy). De aanroep retourneert geen gedeeltelijke resultaten. Het faalt.
Lege bereiken zijn niet gratis. Zelfs als er nul matchende logs zijn, scant de provider nog steeds het blokbereik. Dit telt mee voor je compute units.
Hier is een paginatie-utility die deze beperkingen afhandelt:
import type { PublicClient, Log, AbiEvent } from "viem";
async function fetchLogsInChunks<T extends AbiEvent>(
client: PublicClient,
params: {
address: `0x${string}`;
event: T;
fromBlock: bigint;
toBlock: bigint;
maxBlockRange?: bigint;
}
): Promise<Log<bigint, number, false, T, true>[]> {
const { address, event, fromBlock, toBlock, maxBlockRange = 2000n } = params;
const allLogs: Log<bigint, number, false, T, true>[] = [];
let currentFrom = fromBlock;
while (currentFrom <= toBlock) {
const currentTo =
currentFrom + maxBlockRange - 1n > toBlock
? toBlock
: currentFrom + maxBlockRange - 1n;
try {
const logs = await client.getLogs({
address,
event,
fromBlock: currentFrom,
toBlock: currentTo,
});
allLogs.push(...logs);
currentFrom = currentTo + 1n;
} catch (error) {
// Als het bereik te groot is (te veel resultaten), splits het doormidden
if (isRangeTooLargeError(error) && currentTo > currentFrom) {
const mid = currentFrom + (currentTo - currentFrom) / 2n;
const firstHalf = await fetchLogsInChunks(client, {
address,
event,
fromBlock: currentFrom,
toBlock: mid,
maxBlockRange,
});
const secondHalf = await fetchLogsInChunks(client, {
address,
event,
fromBlock: mid + 1n,
toBlock: currentTo,
maxBlockRange,
});
allLogs.push(...firstHalf, ...secondHalf);
currentFrom = currentTo + 1n;
} else {
throw error;
}
}
}
return allLogs;
}
function isRangeTooLargeError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes("Log response size exceeded") ||
message.includes("query returned more than") ||
message.includes("exceed maximum block range")
);
}Het belangrijkste inzicht is de binaire splitsing bij falen. Als een blokbereik van 2K te veel logs retourneert, splits het in twee bereiken van 1K. Als 1K nog steeds te veel is, splits opnieuw. Dit past zich automatisch aan voor contracten met hoge activiteit zonder dat je de eventdichtheid van tevoren hoeft te kennen.
BigInt Zal Je Nederig Maken#
JavaScript's Number-type is een 64-bit float. Het kan gehele getallen representeren tot 2^53 - 1 — ongeveer 9 biljard. Dat klinkt als veel totdat je beseft dat een tokenbedrag van 1 ETH in wei 1000000000000000000 is — een getal met 18 nullen. Dat is 10^18, ruim voorbij Number.MAX_SAFE_INTEGER.
Als je ergens in je pijplijn per ongeluk een BigInt naar een Number converteert — JSON.parse, een database-driver, een logging-bibliotheek — krijg je stille precisieverlies. Het getal ziet er ongeveer correct uit, maar de laatste paar cijfers zijn fout. Je vangt dit niet op bij het testen omdat je testbedragen klein zijn.
Hier is de bug die ik naar productie heb geshipt:
// DE BUG: Ziet er onschuldig uit, is het niet
function formatTokenAmount(amount: bigint, decimals: number): string {
return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
// Voor kleine bedragen werkt dit prima:
formatTokenAmount(1000000n, 6); // "1.0000" -- correct
// Voor grote bedragen breekt het stilletjes:
formatTokenAmount(123456789012345678n, 18);
// Retourneert "0.1235" -- FOUT, werkelijke precisie is verloren
// Number(123456789012345678n) === 123456789012345680
// De laatste twee cijfers zijn afgerond door IEEE 754De fix: converteer nooit naar Number voordat je deelt. Gebruik viem's ingebouwde utilities, die werken met strings en BigInts:
import { formatUnits, parseUnits } from "viem";
// Correct: werkt op BigInt, retourneert string
function formatTokenAmount(
amount: bigint,
decimals: number,
displayDecimals: number = 4
): string {
const formatted = formatUnits(amount, decimals);
// formatUnits retourneert de volledige precisie-string zoals "0.123456789012345678"
// Afkappen (niet afronden) tot gewenste weergaveprecisie
const [whole, fraction = ""] = formatted.split(".");
const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
return `${whole}.${truncated}`;
}
// Ook cruciaal: gebruik parseUnits voor gebruikersinvoer, nooit parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
// parseUnits handelt de string-naar-BigInt-conversie correct af
return parseUnits(input, decimals);
}Let op: ik kap af in plaats van af te ronden. Dit is opzettelijk. In financiele contexten is het tonen van "1.0001 ETH" wanneer de echte waarde "1.00009999..." is beter dan het tonen van "1.0001" wanneer de echte waarde "1.00005001..." is en naar boven is afgerond. Gebruikers nemen beslissingen op basis van weergegeven bedragen. Afkappen is de conservatieve keuze.
Nog een valkuil: JSON.stringify weet niet hoe het BigInt moet serialiseren. Het gooit een fout. Elke response van je API die tokenbedragen bevat, heeft een serialisatiestrategie nodig. Ik gebruik stringconversie op de API-grens:
// API-response serializer
function serializeForApi(data: Record<string, unknown>): string {
return JSON.stringify(data, (_, value) =>
typeof value === "bigint" ? value.toString() : value
);
}Cachestrategie: Wat, Hoe Lang, en Wanneer Invalideren#
Niet alle on-chain data heeft dezelfde versheidsneisen. Hier is de hierarchie die ik gebruik:
Cache voor altijd (onveranderlijk):
- Transactie-receipts (eenmaal gemined, veranderen ze niet)
- Gefinaliseerde blokdata (blokhash, timestamp, transactielijst)
- Contractbytecode
- Historische eventlogs van gefinaliseerde blokken
Cache voor minuten tot uren:
- Tokenmetadata (naam, symbool, decimalen) — technisch onveranderlijk voor de meeste tokens, maar proxy-upgrades kunnen de implementatie wijzigen
- ENS-resoluties — 5 minuten TTL werkt goed
- Tokenprijzen — hangt af van je nauwkeurigheidsvereisten, 30 seconden tot 5 minuten
Cache voor seconden of helemaal niet:
- Huidig bloknummer
- Accountbalansen en nonce
- Status van lopende transacties
- Ongefinaliseerde eventlogs (het reorgprobleem weer)
De implementatie hoeft niet complex te zijn. Een tweetraps-cache met in-memory LRU en Redis dekt de meeste gevallen:
import { LRUCache } from "lru-cache";
const memoryCache = new LRUCache<string, unknown>({
max: 10_000,
ttl: 1000 * 60, // 1 minuut standaard
});
type CacheTier = "immutable" | "short" | "volatile";
const TTL_MAP: Record<CacheTier, number> = {
immutable: 1000 * 60 * 60 * 24, // 24 uur in memory, permanent in Redis
short: 1000 * 60 * 5, // 5 minuten
volatile: 1000 * 15, // 15 seconden
};
async function cachedRpcCall<T>(
key: string,
tier: CacheTier,
fetcher: () => Promise<T>
): Promise<T> {
// Controleer eerst memory
const cached = memoryCache.get(key) as T | undefined;
if (cached !== undefined) return cached;
// Dan Redis (als je die hebt)
// const redisCached = await redis.get(key);
// if (redisCached) { ... }
const result = await fetcher();
memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
return result;
}
// Gebruik:
const receipt = await cachedRpcCall(
`receipt:${txHash}`,
"immutable",
() => client.getTransactionReceipt({ hash: txHash })
);De contra-intuitieve les: de grootste prestatiewinst is niet het cachen van RPC-responses. Het is het helemaal vermijden van RPC-aanroepen. Elke keer dat je op het punt staat getBlock aan te roepen, vraag jezelf af: heb ik nu daadwerkelijk data van de chain nodig, of kan ik het afleiden uit data die ik al heb? Kan ik luisteren naar events via WebSocket in plaats van te pollen? Kan ik meerdere reads batchen in een enkele multicall?
TypeScript en Contract-ABI's: De Juiste Manier#
Viem's typesysteem, aangedreven door ABIType, biedt volledige end-to-end type-inferentie van je contract-ABI naar je TypeScript-code. Maar alleen als je het correct instelt.
De verkeerde manier:
// Geen type-inferentie — args is unknown[], return is unknown
const result = await client.readContract({
address: "0x...",
abi: JSON.parse(abiString), // geparsed tijdens runtime = geen type-info
functionName: "balanceOf",
args: ["0x..."],
});De juiste manier:
// Definieer ABI als const voor volledige type-inferentie
const erc20Abi = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "success", type: "bool" }],
},
] as const;
// Nu weet TypeScript:
// - functionName vult automatisch aan naar "balanceOf" | "transfer"
// - args voor balanceOf is [address: `0x${string}`]
// - returntype voor balanceOf is bigint
const balance = await client.readContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: erc20Abi,
functionName: "balanceOf",
args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- volledig getypeerdDe as const-assertie is wat het laat werken. Zonder verruimt TypeScript het ABI-type naar { name: string, type: string, ... }[] en stort alle inferentiemachinerie in. Dit is de meest voorkomende fout die ik zie in Web3 TypeScript-codebases.
Voor grotere projecten, gebruik @wagmi/cli om getypeerde contractbindings direct te genereren vanuit je Foundry- of Hardhat-project. Het leest je gecompileerde ABI's en produceert TypeScript-bestanden met as const-asserties die al zijn toegepast. Geen handmatig ABI kopieren, geen type-drift.
De Ongemakkelijke Waarheid#
Blockchain-data is een distributed systems-probleem dat zich voordoet als een databaseprobleem. Op het moment dat je het behandelt als "gewoon weer een API," begin je bugs op te stapelen die onzichtbaar zijn in development en intermitterend in productie.
De tooling is dramatisch verbeterd. Viem is een enorme verbetering ten opzichte van ethers.js voor type-safety en developer experience. Ponder en Envio hebben custom indexering toegankelijk gemaakt. Maar de fundamentele uitdagingen — reorgs, ratelimieten, codering, finaliteit — zijn protocol-level. Geen enkele library abstraheert ze weg.
Bouw met de aanname dat je RPC tegen je zal liegen, je blokken zullen reorganiseren, je getallen zullen overflowing, en je cache verouderde data zal serveren. Handel dan elk geval expliciet af.
Dat is hoe productiewaardige on-chain data eruitziet.