Dane blockchain nie są czyste, niezawodne ani łatwe. Limity RPC, reorganizacje łańcucha, błędy BigInt i kompromisy indeksowania — trudne lekcje z dostarczania prawdziwych produktów DeFi.
Istnieje taka fantazja, że dane on-chain są z natury godne zaufania. Niezmienny rejestr. Transparentny stan. Wystarczy je odczytać i gotowe.
Też w to wierzyłem. Potem dostarczyłem dashboard DeFi na produkcję i spędziłem trzy tygodnie na rozgryzaniu, dlaczego nasze salda tokenów były błędne, historia zdarzeń miała luki, a nasza baza danych zawierała transakcje z bloków, które już nie istniały.
Dane on-chain są surowe, wrogie i pełne edge case'ów, które zepsują twoją aplikację w sposób, którego nie zauważysz, dopóki użytkownik nie zgłosi błędu. Ten post obejmuje wszystko, czego nauczyłem się na własnej skórze.
Pierwsza rzecz, o której nikt ci nie mówi: blockchain nie daje ci danych. Daje ci przejścia stanów. Nie ma żadnego SELECT * FROM transfers WHERE user = '0x...'. Są logi, pokwitowania, sloty storage'u i ślady wywołań — wszystko zakodowane w formatach, które wymagają kontekstu do dekodowania.
Log zdarzenia Transfer daje ci from, to i value. Nie mówi ci o symbolu tokena. Nie mówi ci o liczbie miejsc dziesiętnych. Nie mówi ci, czy to jest legalny transfer, czy token fee-on-transfer potrącający 3% z góry. Nie mówi ci, czy ten blok nadal będzie istnieć za 30 sekund.
Część o "niezmienności" jest prawdziwa — po sfinalizowaniu. Ale finalizacja nie jest natychmiastowa. A dane, które dostajesz z węzła RPC, niekoniecznie pochodzą ze sfinalizowanego bloku. Większość programistów odpytuje latest i traktuje to jak prawdę. To jest błąd, nie feature.
A potem jest kodowanie. Wszystko jest w hex. Adresy są checksumowane wielkimi/małymi literami (albo nie). Kwoty tokenów to liczby całkowite pomnożone przez 10^decimals. Transfer USDC o wartości 100$ wygląda na łańcuchu jak 100000000, ponieważ USDC ma 6 miejsc dziesiętnych, nie 18. Widziałem kod produkcyjny, który zakładał 18 miejsc dziesiętnych dla każdego tokena ERC-20. Wynikowe salda były błędne o czynnik 10^12.
Każda produkcyjna aplikacja Web3 komunikuje się z endpointem RPC. A każdy endpoint RPC ma limity rate, które są znacznie bardziej agresywne, niż się spodziewasz.
Oto liczby, które mają znaczenie:
eth_getLogs na szerokim zakresie bloków może pochłonąć setki CU. Spalasz miesięczną pulę w jeden dzień indeksowania.Płatne plany pomagają, ale nie eliminują problemu. Nawet za 200$/miesiąc na planie Growth Alchemy, intensywne zadanie indeksowania natrafi na limity przepustowości. A kiedy je trafisz, nie dostajesz graceful degradation. Dostajesz błędy 429, czasem z bezużytecznymi komunikatami, czasem bez nagłówka retry-after.
Rozwiązaniem jest połączenie fallback providerów, logiki ponowień i bardzo świadomego podejścia do tego, jakie wywołania wykonujesz. Oto jak wygląda solidna konfiguracja RPC z 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 },
),
});Opcja rank: true jest kluczowa. Mówi viem, żeby mierzył opóźnienia i współczynnik sukcesu dla każdego transportu i automatycznie preferował najszybszy, najniezawodniejszy. Jeśli Alchemy zacznie cię ograniczać, viem przeniesie ruch na Infura. Jeśli Infura padnie, spadnie do Ankr.
Ale jest subtelność: domyślna logika ponowień viem używa wykładniczego backoffu, co zazwyczaj jest tym, czego chcesz. Jednakże, od początku 2025 roku, istnieje znany problem, w którym retryCount nie wykonuje poprawnie ponowień przy błędach na poziomie RPC (jak 429) przy włączonym trybie batch. Jeśli batchujesz żądania, przetestuj zachowanie ponowień jawnie. Nie zakładaj, że działa.
Reorganizacja łańcucha zachodzi, gdy sieć tymczasowo nie zgadza się co do tego, który blok jest kanoniczny. Węzeł A widzi blok 1000 z transakcjami [A, B, C]. Węzeł B widzi inny blok 1000 z transakcjami [A, D]. W końcu sieć konwerguje i jedna wersja wygrywa.
W łańcuchach proof-of-work było to powszechne — reorganizacje 1-3 bloków zdarzały się wielokrotnie w ciągu dnia. Ethereum po merge jest lepsze. Udany atak reorganizacyjny wymaga teraz koordynacji blisko 50% walidatorów. Ale "lepsze" to nie "niemożliwe". W maju 2022 doszło do godnej uwagi reorganizacji 7 bloków na Beacon Chain, spowodowanej niespójnymi implementacjami proposer boost fork w klientach.
I nie ma znaczenia, jak rzadkie są reorganizacje w sieci głównej Ethereum. Jeśli budujesz na L2 lub sidechainach — Polygon, Arbitrum, Optimism — reorganizacje są częstsze. Polygon historycznie miał reorganizacje powyżej 10 bloków.
Oto praktyczny problem: zaindeksowałeś blok 18 000 000. Zapisałeś zdarzenia w bazie danych. Potem blok 18 000 000 został zreorganizowany. Teraz twoja baza danych zawiera zdarzenia z bloku, który nie istnieje w kanonicznym łańcuchu. Te zdarzenia mogą odwoływać się do transakcji, które nigdy się nie wydarzyły. Twoi użytkownicy widzą fantomowe transfery.
Naprawa zależy od twojej architektury:
Opcja 1: Opóźnienie potwierdzenia. Nie indeksuj danych, dopóki nie minie N bloków potwierdzeń. Dla sieci głównej Ethereum 64 bloki (dwie epoki) dają gwarancje finalizacji. Dla L2 sprawdź model finalizacji konkretnego łańcucha. To jest proste, ale dodaje opóźnienie — około 13 minut na Ethereum.
Opcja 2: Wykrywanie reorganizacji i rollback. Indeksuj agresywnie, ale śledź hasze bloków. Przy każdym nowym bloku weryfikuj, czy hash rodzica zgadza się z poprzednim zaindeksowanym blokiem. Jeśli się nie zgadza, wykryłeś reorganizację: usuń wszystko z osieroconych bloków i ponownie zaindeksuj kanoniczny łańcuch.
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 };
}
// Idź wstecz, żeby znaleźć, gdzie łańcuch się rozdzielił
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); // twoje wyszukiwanie w DB
if (onChain.hash === inDb?.hash) {
return { reorged: true, depth };
}
depth++;
checkNumber--;
}
return { reorged: true, depth };
}To nie jest hipotetyczne. Miałem system produkcyjny, w którym indeksowaliśmy zdarzenia na czubku łańcucha bez wykrywania reorganizacji. Przez trzy tygodnie działało dobrze. Potem reorganizacja 2 bloków na Polygon spowodowała duplikat zdarzenia mintowania NFT w naszej bazie danych. Frontend pokazywał użytkownikowi token, którego nie posiadał. Debugowanie tego zajęło dwa dni, ponieważ nikt nie szukał reorganizacji jako przyczyny źródłowej.
Masz trzy realne opcje na pozyskanie ustrukturyzowanych danych on-chain do swojej aplikacji.
Po prostu wywołuj getLogs, getBlock, getTransaction bezpośrednio. To działa dla odczytów na małą skalę — sprawdzanie salda użytkownika, pobieranie ostatnich zdarzeń dla pojedynczego kontraktu. Nie działa dla historycznego indeksowania ani złożonych zapytań między kontraktami.
Problem jest kombinatoryczny. Chcesz wszystkie swapy Uniswap V3 z ostatnich 30 dni? To ~200 tys. bloków. Przy limicie 2 tys. bloków na wywołanie getLogs u Alchemy to minimum 100 paginowanych żądań. Każde liczy się w twój limit rate. A jeśli jakiekolwiek wywołanie się nie powiedzie, potrzebujesz logiki ponowień, śledzenia kursora i sposobu na wznowienie od miejsca, w którym skończyłeś.
The Graph był oryginalnym rozwiązaniem. Zdefiniuj schemat, napisz mapowania w AssemblyScript, wdróż i odpytuj za pomocą GraphQL. Hosted Service został wycofany — wszystko jest teraz w zdecentralizowanej sieci Graph Network, co oznacza, że płacisz tokenami GRT za zapytania.
Zalety: standaryzowany, dobrze udokumentowany, duży ekosystem istniejących subgrafów, które możesz sforkować.
Wady: AssemblyScript jest uciążliwy. Debugowanie jest ograniczone. Wdrożenie trwa od minut do godzin. Jeśli twój subgraf ma błąd, wdrażasz ponownie i czekasz, aż zsynchronizuje się od zera. Zdecentralizowana sieć dodaje opóźnienia i czasem indexery pozostają w tyle za czubkiem łańcucha.
Używałem The Graph do dashboardów intensywnie korzystających z odczytów, gdzie akceptowalna jest świeżość danych 30-60 sekund. Tam sprawdza się dobrze. Nie użyłbym go do czegokolwiek wymagającego danych w czasie rzeczywistym lub złożonej logiki biznesowej w mapowaniach.
To tutaj ekosystem znacząco dojrzał. Ponder i Envio pozwalają pisać logikę indeksowania w TypeScript (nie w AssemblyScript), uruchamiać lokalnie podczas developmentu i wdrażać jako samodzielne serwisy.
Ponder daje ci maksymalną kontrolę. Definiujesz handlery zdarzeń w TypeScript, on zarządza pipeline'em indeksowania i dostajesz bazę danych SQL jako wynik. Kompromis: to ty jesteś właścicielem infrastruktury. Skalowanie, monitoring, obsługa reorganizacji — to na tobie.
Envio optymalizuje pod kątem szybkości synchronizacji. Ich benchmarki pokazują znacznie szybsze czasy początkowej synchronizacji w porównaniu z The Graph. Obsługują reorganizacje natywnie i wspierają HyperSync, wyspecjalizowany protokół do szybszego pobierania danych. Kompromis: kupujesz ich infrastrukturę i API.
Moja rekomendacja: jeśli budujesz produkcyjną aplikację DeFi i masz zasoby inżynierskie, użyj Ponder. Jeśli potrzebujesz najszybszej możliwej synchronizacji i nie chcesz zarządzać infrastrukturą, rozważ Envio. Jeśli potrzebujesz szybkiego prototypu lub chcesz subgrafów utrzymywanych przez społeczność, The Graph nadal jest w porządku.
Metoda RPC eth_getLogs jest zwodniczo prosta. Podaj zakres bloków i filtry, odzyskaj pasujące logi zdarzeń. Oto co faktycznie dzieje się na produkcji:
Limity zakresu bloków różnią się w zależności od dostawcy. Alchemy ogranicza do 2 tys. bloków (nieograniczone logi) lub nieograniczonych bloków (maks. 10 tys. logów). Infura ma inne limity. QuickNode ma inne limity. Publiczny RPC może ograniczać do 1 tys. bloków. Twój kod musi obsługiwać wszystkie te przypadki.
Istnieją limity rozmiaru odpowiedzi. Nawet w ramach zakresu bloków, jeśli popularny kontrakt emituje tysiące zdarzeń na blok, twoja odpowiedź może przekroczyć limit payloadu dostawcy (150MB u Alchemy). Wywołanie nie zwraca częściowych wyników. Po prostu zawodzi.
Puste zakresy nie są darmowe. Nawet jeśli nie ma pasujących logów, dostawca nadal skanuje zakres bloków. To liczy się w twoje jednostki obliczeniowe.
Oto narzędzie paginacyjne, które obsługuje te ograniczenia:
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) {
// Jeśli zakres jest za duży (za dużo wyników), podziel go na pół
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")
);
}Kluczowy insight to binarny podział przy błędzie. Jeśli zakres 2 tys. bloków zwraca za dużo logów, podziel go na dwa zakresy po 1 tys. Jeśli 1 tys. to nadal za dużo, podziel ponownie. To adaptuje się automatycznie do kontraktów o wysokiej aktywności bez wymagania od ciebie znajomości gęstości zdarzeń z góry.
Typ Number w JavaScript to 64-bitowy float. Może reprezentować liczby całkowite do 2^53 - 1 — około 9 biliardów. Brzmi jak dużo, dopóki nie zdasz sobie sprawy, że kwota 1 ETH w wei to 1000000000000000000 — liczba z 18 zerami. To 10^18, znacznie powyżej Number.MAX_SAFE_INTEGER.
Jeśli przypadkowo skonwertujesz BigInt na Number gdziekolwiek w swoim pipeline — JSON.parse, sterownik bazy danych, biblioteka logowania — dostajesz cichą utratę precyzji. Liczba wygląda mniej więcej poprawnie, ale ostatnie kilka cyfr jest nieprawidłowych. Nie złapiesz tego w testach, bo twoje testowe kwoty są małe.
Oto błąd, który dostarczyłem na produkcję:
// BŁĄD: Wygląda niewinnie, ale nie jest
function formatTokenAmount(amount: bigint, decimals: number): string {
return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
// Dla małych kwot to działa:
formatTokenAmount(1000000n, 6); // "1.0000" -- poprawnie
// Dla dużych kwot psuje się cicho:
formatTokenAmount(123456789012345678n, 18);
// Zwraca "0.1235" -- ŹLE, faktyczna precyzja jest utracona
// Number(123456789012345678n) === 123456789012345680
// Ostatnie dwie cyfry zostały zaokrąglone przez IEEE 754Rozwiązanie: nigdy nie konwertuj na Number przed dzieleniem. Używaj wbudowanych narzędzi viem, które operują na stringach i BigInt:
import { formatUnits, parseUnits } from "viem";
// Poprawnie: operuje na BigInt, zwraca string
function formatTokenAmount(amount: bigint, decimals: number, displayDecimals: number = 4): string {
const formatted = formatUnits(amount, decimals);
// formatUnits zwraca string z pełną precyzją jak "0.123456789012345678"
// Obetnij (nie zaokrąglaj) do pożądanej precyzji wyświetlania
const [whole, fraction = ""] = formatted.split(".");
const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
return `${whole}.${truncated}`;
}
// Również kluczowe: używaj parseUnits dla danych wejściowych użytkownika, nigdy parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
// parseUnits obsługuje konwersję string-do-BigInt poprawnie
return parseUnits(input, decimals);
}Zauważ, że obcinam zamiast zaokrąglać. To jest celowe. W kontekstach finansowych wyświetlanie "1.0001 ETH", gdy prawdziwa wartość to "1.00009999...", jest lepsze niż wyświetlanie "1.0001", gdy prawdziwa wartość to "1.00005001..." i została zaokrąglona w górę. Użytkownicy podejmują decyzje na podstawie wyświetlanych kwot. Obcinanie to konserwatywny wybór.
Kolejna pułapka: JSON.stringify nie wie, jak serializować BigInt. Rzuca wyjątek. Każda odpowiedź z twojego API, która zawiera kwoty tokenów, potrzebuje strategii serializacji. Używam konwersji na string na granicy API:
// Serializator odpowiedzi API
function serializeForApi(data: Record<string, unknown>): string {
return JSON.stringify(data, (_, value) => (typeof value === "bigint" ? value.toString() : value));
}Nie wszystkie dane on-chain mają takie same wymagania co do świeżości. Oto hierarchia, której używam:
Cache'uj na zawsze (niemutowalne):
Cache'uj na minuty do godzin:
Cache'uj na sekundy lub wcale:
Implementacja nie musi być skomplikowana. Dwupoziomowy cache z LRU w pamięci i Redis pokrywa większość przypadków:
import { LRUCache } from "lru-cache";
const memoryCache = new LRUCache<string, unknown>({
max: 10_000,
ttl: 1000 * 60, // 1 minuta domyślnie
});
type CacheTier = "immutable" | "short" | "volatile";
const TTL_MAP: Record<CacheTier, number> = {
immutable: 1000 * 60 * 60 * 24, // 24 godziny w pamięci, permanentnie w 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> {
// Najpierw sprawdź pamięć
const cached = memoryCache.get(key) as T | undefined;
if (cached !== undefined) return cached;
// Potem Redis (jeśli masz)
// const redisCached = await redis.get(key);
// if (redisCached) { ... }
const result = await fetcher();
memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
return result;
}
// Użycie:
const receipt = await cachedRpcCall(`receipt:${txHash}`, "immutable", () =>
client.getTransactionReceipt({ hash: txHash }),
);Nieintuicyjna lekcja: największy zysk wydajnościowy nie polega na cache'owaniu odpowiedzi RPC. Polega na unikaniu wywołań RPC w ogóle. Za każdym razem, gdy zamierzasz wywołać getBlock, zadaj sobie pytanie: czy naprawdę potrzebuję teraz danych z łańcucha, czy mogę je wyprowadzić z danych, które już mam? Czy mogę nasłuchiwać zdarzeń przez WebSocket zamiast pollować? Czy mogę zbatchować wiele odczytów w jedno wywołanie multicall?
System typów viem, zasilany przez ABIType, zapewnia pełną inferencję typów end-to-end od ABI twojego kontraktu do kodu TypeScript. Ale tylko jeśli skonfigurujesz to poprawnie.
Zły sposób:
// Brak inferencji typów — args to unknown[], zwracany typ to unknown
const result = await client.readContract({
address: "0x...",
abi: JSON.parse(abiString), // parsowane w runtime = brak informacji o typach
functionName: "balanceOf",
args: ["0x..."],
});Właściwy sposób:
// Zdefiniuj ABI jako const dla pełnej inferencji typów
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;
// Teraz TypeScript wie:
// - functionName autouzupełnia się do "balanceOf" | "transfer"
// - args dla balanceOf to [address: `0x${string}`]
// - zwracany typ dla balanceOf to bigint
const balance = await client.readContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: erc20Abi,
functionName: "balanceOf",
args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- w pełni typowaneAsercja as const jest tym, co sprawia, że to działa. Bez niej TypeScript poszerza typ ABI do { name: string, type: string, ... }[] i cała maszyneria inferencji się rozpada. To najczęstszy błąd, jaki widzę w bazach kodu Web3 TypeScript.
Dla większych projektów użyj @wagmi/cli, aby generować typowane bindowania kontraktów bezpośrednio z projektu Foundry lub Hardhat. Odczytuje skompilowane ABI i produkuje pliki TypeScript z już zastosowanymi asercjami as const. Bez ręcznego kopiowania ABI, bez dryftu typów.
Dane blockchain to problem systemów rozproszonych udający problem bazodanowy. W momencie, gdy traktujesz to jak "kolejne API", zaczynasz kumulować błędy, które są niewidoczne w development i sporadyczne na produkcji.
Narzędzia stały się dramatycznie lepsze. Viem to ogromna poprawa w stosunku do ethers.js pod względem bezpieczeństwa typów i doświadczenia dewelopera. Ponder i Envio uczyniły własne indeksowanie dostępnym. Ale fundamentalne wyzwania — reorganizacje, limity rate, kodowanie, finalizacja — są na poziomie protokołu. Żadna biblioteka ich nie abstrahuje.
Buduj z założeniem, że twój RPC będzie cię okłamywać, twoje bloki się zreorganizują, twoje liczby przepełnią się, a twój cache będzie serwować nieaktualne dane. Potem obsłuż każdy przypadek jawnie.
Tak wyglądają dane on-chain klasy produkcyjnej.