Dane on-chain na produkcji: o czym nikt ci nie mówi
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.
Iluzja niezawodnych danych#
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.
Limity RPC zrujnują ci weekend#
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:
- Alchemy Free: ~30M jednostek obliczeniowych/miesiąc, 40 żądań/minutę. Brzmi hojnie, dopóki nie zdasz sobie sprawy, że pojedyncze wywołanie
eth_getLogsna szerokim zakresie bloków może pochłonąć setki CU. Spalasz miesięczną pulę w jeden dzień indeksowania. - Infura Free: 100 tys. żądań/dzień, mniej więcej 1,15 żądania/sek. Spróbuj paginować przez 500 tys. bloków logów zdarzeń w tym tempie.
- QuickNode Free: Podobnie do Infura — 100 tys. żądań/dzień.
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.
Reorganizacje: błąd, którego nie zobaczysz nadchodzącego#
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.
Problem indeksowania: wybierz swoją trudność#
Masz trzy realne opcje na pozyskanie ustrukturyzowanych danych on-chain do swojej aplikacji.
Bezpośrednie wywołania RPC#
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 (Subgrafy)#
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.
Własne indeksery (Ponder, Envio)#
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.
getLogs jest bardziej niebezpieczne, niż wygląda#
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.
BigInt cię upokorzy#
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
);
}Strategia cache'owania: co, jak długo i kiedy inwalidować#
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):
- Pokwitowania transakcji (raz wykopane, nie zmieniają się)
- Sfinalizowane dane bloków (hash bloku, timestamp, lista transakcji)
- Bytecode kontraktu
- Historyczne logi zdarzeń ze sfinalizowanych bloków
Cache'uj na minuty do godzin:
- Metadane tokena (nazwa, symbol, decimals) — technicznie niemutowalne dla większości tokenów, ale upgrady proxy mogą zmienić implementację
- Rozwiązania ENS — TTL 5 minut sprawdza się dobrze
- Ceny tokenów — zależy od twoich wymagań dokładności, od 30 sekund do 5 minut
Cache'uj na sekundy lub wcale:
- Aktualny numer bloku
- Salda kont i nonce
- Status transakcji w toku
- Niesfinalizowane logi zdarzeń (znów problem reorganizacji)
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?
TypeScript i ABI kontraktów: właściwy sposób#
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.
Niewygodna prawda#
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.