Przejdź do treści
·11 min czytania

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.

Udostępnij:X / TwitterLinkedIn

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_getLogs na 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:

typescript
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.

typescript
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:

typescript
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ę:

typescript
// 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 754

Rozwiązanie: nigdy nie konwertuj na Number przed dzieleniem. Używaj wbudowanych narzędzi viem, które operują na stringach i BigInt:

typescript
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:

typescript
// 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:

typescript
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:

typescript
// 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:

typescript
// 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 typowane

Asercja 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.

Powiązane wpisy