On-chain data v produkci: Co vam nikdo nerekne
Blockchain data nejsou cista, spolehlivá ani jednoduchá. RPC rate limity, reorganizace řetězce, BigInt bugy a kompromisy indexovani — těžké lekce z dodavani reálných DeFi produktu.
Existuje takova fantazie, ze on-chain data jsou ze sve podstaty duveryhoodna. Nemenitelna ucetni kniha. Transparentni stav. Stačí si je precist a je to.
Tomu jsem veril taky. Pak jsem dodal DeFi dashboard do produkce a stravil tri tydny zjistovanim, proč jsou nase zustatky tokenu špatně, proč ma nase historie udalosti mezery a proč nase databáze obsahuje transakce z bloku, které uz neexistuji.
On-chain data jsou surova, neprivativa a plna okrajovych pripadu, které vasi aplikaci rozbiji způsoby, kterých si nevsimnete, dokud uzivatel nepodali bug report. Tento clanek pokryva vse, co jsem se naučil na vlastni kuzi.
Iluze spolehllivych dat#
Prvni věc, kterou vam nikdo nerekne: blockchain vam nedava data. Dava vam stavove prechody. Neexistuje žádný SELECT * FROM transfers WHERE user = '0x...'. Existuji logy, uctenky, ulozne sloty a stopy volani — všechno zakodovane ve formatech, které k dekodovani vyzaduji kontext.
Log udalosti Transfer vam da from, to a value. Nerekne vam symbol tokenu. Nerekne vam pocet desetinnych mist. Nerekne vam, jestli je to legitimni převod, nebo fee-on-transfer token, který stahuje 3 % z vrchu. Nerekne vam, jestli tento blok bude za 30 sekund ještě existovat.
Část o "nemenitelnosti" je pravdiva — jakmile je blok finalizovan. Ale finalizace neni okamzita. A data, která dostanete zpet z RPC uzlu, nemusi byt nutne z finalizovaneho bloku. Většina vyvojaru se dotazuje na latest a zachazi s tim jako s pravdou. To je bug, ne feature.
Pak je tu kodovani. Všechno je hex. Adresy jsou checksum s ruznou velikosti pismen (nebo ne). Castky tokenu jsou cela čísla vynasobena 10^decimals. Převod 100 $ v USDC vypada na retezci jako 100000000, protoze USDC ma 6 desetinnych mist, ne 18. Videl jsem produkcni kod, který predpokladal 18 desetinnych mist pro každý ERC-20 token. Vysledne zustatky byly chybne o faktor 10^12.
RPC rate limity vam znici vikend#
Každá produkcni Web3 aplikace komunikuje s RPC endpointem. A každý RPC endpoint ma rate limity, které jsou mnohem agresivnejsi, nez cekate.
Čísla, na kterých zalezi:
- Alchemy Free: ~30M compute units/mesic, 40 requestu/minutu. Zni to steedre, dokud si neuvedomite, ze jediné volani
eth_getLogspřes siroky rozsah bloku může spolknout stovky CU. Svuj mesicni limit vyceerpate za den indexovani. - Infura Free: 100K requestu/den, zhruba 1,15 req/s. Zkuste pri tomto tempu stránkovat přes 500K bloku event logu.
- QuickNode Free: Podobne jako Infura — 100K requestu/den.
Placene tarify pomahaji, ale problem neeliminuji. I za $200/mesic na Alchemy Growth planu tezka indexovaci uloha narazi na limity propustnosti. A kdyz na ne narazite, nedostanete elegantni degradaci. Dostanete chyby 429, někdy s neuzitecnymi zpravami, někdy bez retry-after hlavicky.
Resenim je kombinace zalosnich provideru, retry logiky a velmi promysleneho pristupu k tomu, která volani provadite. Takto vypada robustni RPC setup s 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 }
),
});Volba rank: true je klicova. Říká viem, aby meril latenci a uspesnost kazdeho transportu a automaticky preferoval ten nejrychlejsi a nejspolehlivejsi. Pokud vas Alchemy zacne rate-limitovat, viem presmeruje provoz na Infura. Pokud Infura spadne, prepne na Ankr.
Je tu vsak jemnost: vychozi retry logika viem pouziva exponencialni backoff, coz je obvykle to, co chcete. Nicmene od začátku roku 2025 existuje znamy problem, kdy retryCount správně neopakuje RPC-level chyby (jako 429) pri zapnutem batch rezimu. Pokud davkujete requesty, otestujte sve retry chovani explicitne. Nespolehejte se, ze to funguje.
Reorganizace: Bug, který neprijde#
K reorganizaci řetězce dochazi, kdyz se sit docasne neshodne na tom, který blok je kanonicky. Uzel A vidi blok 1000 s transakcemi [A, B, C]. Uzel B vidi jiný blok 1000 s transakcemi [A, D]. Nakonec se sit sjedna a jedna verze zvitezi.
Na proof-of-work retezcich to bylo bezne — reorganizace 1-3 bloku se stavaly vicekrat denne. Post-merge Ethereum je na tom lépe. Uspesny reorganizacni utok nyni vyzaduje koordinaci blizko 50 % validatoru. Ale "lépe" neznamena "nemozne." V kvetnu 2022 doslo k vyznamne reorganizaci 7 bloku na Beacon Chain, zpusobene nekonzistentnimi klientskymi implementacemi proposer boost forku.
A nezalezi na tom, jak vzacne jsou reorganizace na Ethereum mainnetu. Pokud stavite na L2 nebo sidechainech — Polygon, Arbitrum, Optimism — reorganizace jsou castejsi. Polygon historicky mel reorganizace 10+ bloku.
Praktický problem: zaindexovali jste blok 18 000 000. Zapsali jste udalosti do databáze. Pak se blok 18 000 000 reorganizoval. Ted vase databáze obsahuje udalosti z bloku, který na kanonickem retezci neexistuje. Tyto udalosti mohou odkazovat na transakce, které se nikdy nestaly. Vasi uzivatele vidi fantomove prevody.
Oprava zavisi na vasi architekture:
Možnost 1: Zpozdeni potvrzeni. Neindexujte data, dokud neuplyne N bloku potvrzeni. Pro Ethereum mainnet vam 64 bloku (dve epochy) dava zaruky finality. Pro L2 zkontrolujte specifické finality model daneho řetězce. Je to jednoduché, ale pridava to latenci — přibližně 13 minut na Ethereum.
Možnost 2: Detekce reorganizace a rollback. Indexujte agresivne, ale sledujte hashe bloku. U kazdeho noveho bloku overujte, ze parent hash odpovida predchozimu zaindexovanemu bloku. Pokud ne, detekovali jste reorganizaci: smazte vse z osamelych bloku a znovu zaindexujte kanonicky řetězec.
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 };
}
// Jdeme zpet a hledame, kde se retezec rozdelil
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); // vase DB lookup
if (onChain.hash === inDb?.hash) {
return { reorged: true, depth };
}
depth++;
checkNumber--;
}
return { reorged: true, depth };
}Tohle neni hypoteticke. Mel jsem produkcni system, kde jsme indexovali udalosti na spicce řetězce bez detekce reorganizace. Tri tydny to fungovalo skvele. Pak reorganizace 2 bloku na Polygon zpusobila duplicitni NFT mint udalost v nasi databázi. Frontend ukazoval uzivateli, ze vlastni token, který nevlastnil. Debugovani trvalo dva dny, protoze nikdo nehledal reorganizace jako prvotni pricinu.
Problem indexovani: Vyberte si svou bolest#
Mate tri reálné možnosti, jak dostat strukturovana on-chain data do vasi aplikace.
Prime RPC volani#
Proste zavolejte getLogs, getBlock, getTransaction přímo. Funguje to pro čtení v malem měřítku — kontrola zustatku uzivatele, nacteni poslednich udalosti pro jeden kontrakt. Nefunguje to pro historicke indexovani nebo slozite dotazy přes kontrakty.
Problem je kombinatoricky. Chcete všechny Uniswap V3 swapy za poslednich 30 dni? To je ~200K bloku. Prii Alchemy limitu 2K bloku na jeden getLogs dotaz je to minimalne 100 strankovanch requestu. Každý se pocita do vaseho rate limitu. A pokud jakykoli dotaz selze, potřebujete retry logiku, sledovani kurzoru a způsob, jak pokracovat od mista, kde jste skoncili.
The Graph (Subgraphs)#
The Graph bylo puvodni řešení. Definujete schema, napisete mappingy v AssemblyScript, nasadite a dotazujete se pomoci GraphQL. Hosted Service byl zrusen — všechno je nyni na decentralizovane Graph Network, coz znamena, ze platite GRT tokeny za dotazy.
To dobre: standardizovane, dobre zdokumentovane, velky ekosystem existujicich subgrafu, které můžete forkovat.
To špatně: AssemblyScript je bolestive. Debugovani je omezene. Nasazení trva minuty az hodiny. Pokud ma vas subgraf bug, znovu nasadite a cekate, az se od začátku resynchronizuje. Decentralizovana sit pridava latenci a někdy indexery zaostávaji za spickou řetězce.
The Graph jsem pouzival pro read-heavy dashboardy, kde je akceptovatelna cerstvost dat 30-60 sekund. Tam funguje dobre. Nepouzil bych ho pro cokoliv, co vyzaduje realtime data nebo slozitou business logiku v mappinzich.
Vlastni indexery (Ponder, Envio)#
Tady ekosystem vyrazne dozral. Ponder a Envio vam umozni psát indexovaci logiku v TypeScript (ne AssemblyScript), behat lokalne behem vyvoje a nasadit jako samostatne sluzby.
Ponder vam dava maximalni kontrolu. Definujete event handlery v TypeScript, spravuje indexovaci pipeline a vysledkem je SQL databáze. Kompromis: infrastrukturu vlastnite vy. Skalovani, monitoring, obsluha reorganizaci — to je na vas.
Envio optimalizuje pro rychlost synchronizace. Jejich benchmarky ukazuji vyrazne rychlejsi pocatecni sync nez The Graph. Reorganizace řeší nativne a podporuji HyperSync, specializovany protokol pro rychlejsi stazeni dat. Kompromis: kupujete si jejich infrastrukturu a API.
Moje doporuceni: pokud stavite produkcni DeFi aplikaci a mate inzenyrskou kapacitu, pouzijte Ponder. Pokud potřebujete co nejrychlejsi sync a nechcete spravovat infrastrukturu, zvazte Envio. Pokud potřebujete rychly prototyp nebo chcete komunitou udrzovane subgrafy, The Graph je stale v poradku.
getLogs je nebezpecnejsi, nez to vypada#
RPC metoda eth_getLogs je klamave jednoduchá. Dejte ji rozsah bloku a nějaké filtry, dostanete zpet odpovidajici event logy. Co se skutečně stane v produkci:
Limity rozsahu bloku se lisi podle poskytovatele. Alchemy omezuje na 2K bloku (neomezene logy) nebo neomezene bloky (max 10K logu). Infura ma jiné limity. QuickNode ma jiné limity. Verejne RPC může omezovat na 1K bloku. Vas kod musi všechny tyto případy zvladnout.
Existuji limity velikosti odpovedi. I v ramci rozsahu bloku, pokud popularni kontrakt emituje tisice udalosti na blok, vase odpoved může prekrocit limit datove zateze poskytovatele (150MB u Alchemy). Volani nevrati částečně výsledky. Selze.
Prazdne rozsahy nejsou zadarmo. I kdyz neexistuji žádné odpovidajici logy, poskytovatel stale prohledava rozsah bloku. Pocita se to do vasich compute units.
Zde je strankovaci utilita, která řeší tato omezeni:
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) {
// Pokud je rozsah prilis velky (prilis mnoho vysledku), rozdelime ho napul
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")
);
}Klicovy poznatek je binarni rozdeleni pri selhani. Pokud rozsah 2K bloku vrati prilis mnoho logu, rozdelime ho na dva rozsahy po 1K. Pokud je 1K stale moc, rozdelime znovu. Toto se automaticky prizpusobi kontraktum s vysokou aktivitou, aniz byste museli predem znat hustotu udalosti.
BigInt vas pokori#
JavaScriptovy typ Number je 64bitovy float. Dokaze reprezentovat cela čísla az do 2^53 - 1 — přibližně 9 biliardu. Zni to hodne, dokud si neuvedomite, ze castka 1 ETH ve wei je 1000000000000000000 — číslo s 18 nulami. To je 10^18, daleko za Number.MAX_SAFE_INTEGER.
Pokud kdekoliv ve vasem pipeline nahodne prevedete BigInt na Number — JSON.parse, databazovy driver, logovaci knihovna — dojde k tihe ztrate presnosti. Číslo vypada zhruba správně, ale poslednich par cislic je špatně. Prii testovani to nezachytite, protoze vase testovaci castky jsou male.
Tady je bug, který jsem dodal do produkce:
// BUG: Vypada nevinne, neni
function formatTokenAmount(amount: bigint, decimals: number): string {
return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
// Pro male castky to funguje dobre:
formatTokenAmount(1000000n, 6); // "1.0000" -- spravne
// Pro velke castky to tise selze:
formatTokenAmount(123456789012345678n, 18);
// Vraci "0.1235" -- SPATNE, skutecna presnost je ztracena
// Number(123456789012345678n) === 123456789012345680
// Posledni dve cislice byly zaokrouhleny IEEE 754Oprava: nikdy neprevadejte na Number pred delenim. Pouzijte vestaveene utility viem, které pracuji s retezci a BigInt:
import { formatUnits, parseUnits } from "viem";
// Spravne: pracuje s BigInt, vraci retezec
function formatTokenAmount(
amount: bigint,
decimals: number,
displayDecimals: number = 4
): string {
const formatted = formatUnits(amount, decimals);
// formatUnits vraci retezec s plnou presnosti jako "0.123456789012345678"
// Orezeme (nezaokrouhlujeme) na pozadovanou zobrazovaci presnost
const [whole, fraction = ""] = formatted.split(".");
const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
return `${whole}.${truncated}`;
}
// Take klicove: pouzijte parseUnits pro uzivatelsky vstup, nikdy parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
// parseUnits spravne zvladne konverzi retezce na BigInt
return parseUnits(input, decimals);
}Vsimete si, ze orezavam místo zaokrouhlovani. Je to zamerne. Ve financnim kontextu je zobrazeni "1.0001 ETH", kdyz skutecna hodnota je "1.00009999...", lepsi nez zobrazeni "1.0001", kdyz skutecna hodnota je "1.00005001..." a byla zaokrouhlena nahoru. Uzivatele se rozhoduji na zaklade zobrazenych castek. Oriznuti je konzervativni volba.
Další past: JSON.stringify nevi, jak serializovat BigInt. Vyhodi chybu. Každá odpoved z vaseho API, která obsahuje castky tokenu, potrebuje strategii serializace. Používám konverzi na řetězec na hranici API:
// Serializer API odpovedi
function serializeForApi(data: Record<string, unknown>): string {
return JSON.stringify(data, (_, value) =>
typeof value === "bigint" ? value.toString() : value
);
}Strategie cachovani: Co, jak dlouho a kdy invalidovat#
Ne vsechna on-chain data maji stejně pozadavky na cerstvost. Zde je hierarchie, kterou používám:
Cachovat navzdy (nemenne):
- Uctenky transakcí (jakmile jsou vytezeny, nemeeni se)
- Data finalizovanych bloku (hash bloku, casove razitko, seznam transakcí)
- Bytekod kontraktu
- Historicke event logy z finalizovanych bloku
Cachovat minuty az hodiny:
- Metadata tokenu (nazev, symbol, desetinna mista) — technický nemenne pro vetsinu tokenu, ale proxy upgrady mohou zmenit implementaci
- ENS resolvovani — TTL 5 minut funguje dobre
- Ceny tokenu — zavisi na vasich pozadavcich na presnost, 30 sekund az 5 minut
Cachovat sekundy nebo vubec ne:
- Aktualni číslo bloku
- Zustatky a nonce uctu
- Stav cekajicich transakcí
- Nefinalizovane event logy (opet problem reorganizace)
Implementace nemusi byt slozita. Dvouvrstva cache s in-memory LRU a Redis pokryva vetsinu pripadu:
import { LRUCache } from "lru-cache";
const memoryCache = new LRUCache<string, unknown>({
max: 10_000,
ttl: 1000 * 60, // 1 minuta vychozi
});
type CacheTier = "immutable" | "short" | "volatile";
const TTL_MAP: Record<CacheTier, number> = {
immutable: 1000 * 60 * 60 * 24, // 24 hodin v pameti, trvale v Redis
short: 1000 * 60 * 5, // 5 minut
volatile: 1000 * 15, // 15 sekund
};
async function cachedRpcCall<T>(
key: string,
tier: CacheTier,
fetcher: () => Promise<T>
): Promise<T> {
// Nejdriv zkontrolujeme pamet
const cached = memoryCache.get(key) as T | undefined;
if (cached !== undefined) return cached;
// Pak Redis (pokud ho mate)
// const redisCached = await redis.get(key);
// if (redisCached) { ... }
const result = await fetcher();
memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
return result;
}
// Pouziti:
const receipt = await cachedRpcCall(
`receipt:${txHash}`,
"immutable",
() => client.getTransactionReceipt({ hash: txHash })
);Neintuitivni lekce: nejvetsi výkonnostní vyhrou neni cachovani RPC odpovedi. Je to vyhybani se RPC volanim uplne. Pokaždé, kdyz chystáte volat getBlock, zeptejte se sami sebe: potřebuji opravdu data z řetězce prave ted, nebo je mohu odvodit z dat, která uz mam? Mohu naslouchat udalostem přes WebSocket místo pollovani? Mohu davkovat vice čtení do jednoho multicall?
TypeScript a ABI kontraktu: Správný způsob#
Typovy system viem, pohaneny ABIType, poskytuje plnou end-to-end typovou inferenci od ABI vaseho kontraktu az po vas TypeScript kod. Ale pouze pokud to správně nastavite.
Špatný způsob:
// Zadna typova inference — args je unknown[], return je unknown
const result = await client.readContract({
address: "0x...",
abi: JSON.parse(abiString), // parsovano za behu = zadne typove informace
functionName: "balanceOf",
args: ["0x..."],
});Správný způsob:
// Definujte ABI jako const pro plnou typovou inferenci
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;
// Ted TypeScript vi:
// - functionName autocomplete nabizi "balanceOf" | "transfer"
// - args pro balanceOf je [address: `0x${string}`]
// - navratovy typ pro balanceOf je bigint
const balance = await client.readContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: erc20Abi,
functionName: "balanceOf",
args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- plne typovanoAserce as const je to, co to rozjizdi. Bez ni TypeScript rozsiri typ ABI na { name: string, type: string, ... }[] a veskera inferecni masineerie se zhroutí. Toto je jedina nejcastejsi chyba, kterou v Web3 TypeScript codebazich vidim.
Pro větší projekty pouzijte @wagmi/cli ke generovani typovanych vazeb kontraktu přímo z vaseho Foundry nebo Hardhat projektu. Precte vase zkompilovane ABI a produkuje TypeScript soubory s asercemi as const jiz aplikovanymi. Žádné rucni kopirovani ABI, žádný typovy drift.
Neprijemna pravda#
Blockchain data jsou problem distribuovanych systemu maskujici se jako databazovy problem. V momente, kdy s nimi zachazite jako s "jen dalsim API," zacinate hromadit bugy, které jsou neviditelne pri vyvoji a intermitentni v produkci.
Nástroje se dramaticky zlepsily. Viem je obrovsky pokrok oproti ethers.js pro typovou bezpecnost a vyvojarsky zazitek. Ponder a Envio zpristupnily vlastni indexovani. Ale fundamentalni vyzvy — reorganizace, rate limity, kodovani, finalita — jsou na urovni protokolu. Žádná knihovna je neabstrahuje pryc.
Stavejte s predpokladem, ze vam vase RPC bude lhat, vase bloky se reorganizuji, vase čísla pretecou a vase cache bude servitovat stara data. Pak každý případ explicitne osetrte.
Takhle vypadaji produkcni on-chain data.