Vai al contenuto
·12 min di lettura

Dati on-chain in produzione: quello che nessuno ti dice

I dati blockchain non sono puliti, affidabili o semplici. Rate limit degli RPC, riorganizzazioni della chain, bug con BigInt e compromessi sull'indicizzazione -- lezioni duramente apprese dal rilascio di prodotti DeFi reali.

Condividi:X / TwitterLinkedIn

C'è questa fantasia che i dati on-chain siano intrinsecamente affidabili. Registro immutabile. Stato trasparente. Basta leggerlo e hai finito.

Ci credevo anch'io. Poi ho messo in produzione una dashboard DeFi e ho passato tre settimane a capire perché i nostri saldi dei token erano sbagliati, la cronologia degli eventi aveva lacune e il nostro database conteneva transazioni da blocchi che non esistevano più.

I dati on-chain sono grezzi, ostili e pieni di casi limite che romperanno la tua applicazione in modi che non noterai finché un utente non aprir una segnalazione bug. Questo articolo copre tutto ciò che ho imparato a mie spese.

L'illusione dei dati affidabili#

Ecco la prima cosa che nessuno ti dice: la blockchain non ti d dati. Ti d transizioni di stato. Non esiste un SELECT * FROM transfers WHERE user = '0x...'. Ci sono log, ricevute, slot di storage e call trace, tutti codificati in formati che richiedono contesto per essere decodificati.

Un log di evento Transfer ti d from, to e value. Non ti dice il simbolo del token. Non ti dice i decimali. Non ti dice se questo è un trasferimento legittimo o un token fee-on-transfer che trattiene il 3% sopra. Non ti dice se questo blocco esister ancora tra 30 secondi.

La parte "immutabile" è vera, una volta finalizzato. Ma la finalizzazione non è istantanea. E i dati che ricevi da un nodo RPC non provengono necessariamente da un blocco finalizzato. La maggior parte degli sviluppatori interroga latest e lo tratta come verità. Questo è un bug, non una feature.

Poi c'è la codifica. Tutto è in esadecimale. Gli indirizzi sono in maiuscolo/minuscolo con checksum (o no). Gli importi dei token sono interi moltiplicati per 10^decimals. Un trasferimento USDC di $100 appare come 100000000 on-chain perché USDC ha 6 decimali, non 18. Ho visto codice in produzione che assumeva 18 decimali per ogni token ERC-20. I saldi risultanti erano sbagliati di un fattore 10^12.

I rate limit degli RPC ti rovineranno il weekend#

Ogni applicazione Web3 in produzione comunica con un endpoint RPC. E ogni endpoint RPC ha rate limit molto più aggressivi di quanto ti aspetti.

Ecco i numeri che contano:

  • Alchemy Free: ~30M unit di calcolo/mese, 40 richieste/minuto. Sembra generoso finché non realizzi che una singola chiamata eth_getLogs su un ampio intervallo di blocchi può consumare centinaia di CU. Brucerai la tua quota mensile in un giorno di indicizzazione.
  • Infura Free: 100K richieste/giorno, circa 1,15 req/sec. Prova a paginare attraverso 500K blocchi di log di eventi a quella velocità.
  • QuickNode Free: Simile a Infura -- 100K richieste/giorno.

I piani a pagamento aiutano, ma non eliminano il problema. Anche a $200/mese sul piano Growth di Alchemy, un lavoro di indicizzazione pesante raggiunger i limiti di throughput. E quando li raggiungi, non ottieni un degrado graceful. Ottieni errori 429, a volte con messaggi poco utili, a volte senza header retry-after.

La soluzione è una combinazione di provider di fallback, logica di retry e molta attenzione su quali chiamate fai. Ecco come appare una configurazione RPC robusta con 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 }
  ),
});

L'opzione rank: true è fondamentale. Dice a viem di misurare latenza e tasso di successo per ogni transport e preferire automaticamente il più veloce e affidabile. Se Alchemy inizia a limitarti, viem sposta il traffico su Infura. Se Infura va già, passa ad Ankr.

Ma c'è una sottigliezza: la logica di retry predefinita di viem usa il backoff esponenziale, che è generalmente ciò che vuoi. Tuttavia, all'inizio del 2025, c'è un problema noto per cui retryCount non ritenta correttamente gli errori a livello RPC (come i 429) quando la modalità batch è abilitata. Se stai facendo batch di richieste, testa esplicitamente il comportamento di retry. Non dare per scontato che funzioni.

Reorg: il bug che non vedrai arrivare#

Una riorganizzazione della chain avviene quando la rete temporaneamente non è d'accordo su quale blocco sia quello canonico. Il nodo A vede il blocco 1000 con le transazioni [A, B, C]. Il nodo B vede un blocco 1000 diverso con le transazioni [A, D]. Alla fine la rete converge e una versione prevale.

Sulle chain proof-of-work, questo era comune: riorganizzazioni di 1-3 blocchi avvenivano più volte al giorno. Ethereum post-merge è migliore. Un attacco di riorganizzazione riuscito ora richiede il coordinamento di quasi il 50% dei validatori. Ma "migliore" non significa "impossibile". C'è stata una notevole riorganizzazione di 7 blocchi sulla Beacon Chain a maggio 2022, causata da implementazioni inconsistenti del proposer boost fork tra i client.

E non importa quanto siano rare le riorganizzazioni su Ethereum mainnet. Se stai costruendo su L2 o sidechain -- Polygon, Arbitrum, Optimism -- le riorganizzazioni sono più frequenti. Polygon storicamente ha avuto riorganizzazioni di 10+ blocchi.

Ecco il problema pratico: hai indicizzato il blocco 18.000.000. Hai scritto gli eventi nel database. Poi il blocco 18.000.000 è stato riorganizzato. Ora il tuo database ha eventi da un blocco che non esiste sulla chain canonica. Quegli eventi potrebbero riferirsi a transazioni che non sono mai avvenute. I tuoi utenti vedono trasferimenti fantasma.

La soluzione dipende dalla tua architettura:

Opzione 1: Ritardo di conferma. Non indicizzare i dati finché non sono passate N conferme di blocco. Per Ethereum mainnet, 64 blocchi (due epoch) ti danno garanzie di finalit. Per le L2, verifica il modello di finalit della chain specifica. Questo è semplice ma aggiunge latenza -- circa 13 minuti su Ethereum.

Opzione 2: Rilevamento delle riorganizzazioni e rollback. Indicizza aggressivamente ma tieni traccia degli hash dei blocchi. Ad ogni nuovo blocco, verifica che l'hash del blocco padre corrisponda al blocco precedente che hai indicizzato. Se non corrisponde, hai rilevato una riorganizzazione: cancella tutto dai blocchi orfani e reindicizza la chain canonica.

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 };
  }
 
  // Risali all'indietro per trovare dove la chain si  biforcata
  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); // la tua query al DB
 
    if (onChain.hash === inDb?.hash) {
      return { reorged: true, depth };
    }
 
    depth++;
    checkNumber--;
  }
 
  return { reorged: true, depth };
}

Questo non è ipotetico. Ho avuto un sistema in produzione dove indicizzavamo eventi alla punta della chain senza rilevamento delle riorganizzazioni. Per tre settimane ha funzionato bene. Poi una riorganizzazione di 2 blocchi su Polygon ha causato un evento di mint NFT duplicato nel nostro database. Il frontend mostrava un utente che possedeva un token che in realtà non aveva. Ho impiegato due giorni a fare debug perché nessuno cercava le riorganizzazioni come causa principale.

Il problema dell'indicizzazione: scegli il tuo dolore#

Hai tre opzioni reali per ottenere dati on-chain strutturati nella tua applicazione.

Chiamate RPC dirette#

Chiama direttamente getLogs, getBlock, getTransaction. Questo funziona per letture su piccola scala -- controllare il saldo di un utente, recuperare eventi recenti per un singolo contratto. Non funziona per indicizzazione storica o query complesse tra contratti.

Il problema è combinatorio. Vuoi tutti gli swap di Uniswap V3 degli ultimi 30 giorni? Sono circa 200K blocchi. Con il limite di 2K blocchi per chiamata getLogs di Alchemy, sono minimo 100 richieste paginate. Ognuna conta contro il tuo rate limit. E se una qualsiasi chiamata fallisce, hai bisogno di logica di retry, tracciamento del cursore e un modo per riprendere da dove ti eri fermato.

The Graph (Subgraph)#

The Graph è stata la soluzione originale. Definisci uno schema, scrivi i mapping in AssemblyScript, fai il deploy e interroga con GraphQL. L'Hosted Service è stato deprecato -- ora tutto è sul Graph Network decentralizzato, il che significa che paghi con token GRT per le query.

Il bello: standardizzato, ben documentato, grande ecosistema di subgraph esistenti che puoi forkare.

Il brutto: AssemblyScript è doloroso. Il debugging è limitato. Il deployment richiede da minuti a ore. Se il tuo subgraph ha un bug, fai il redeploy e aspetti che si risincronizzi da zero. La rete decentralizzata aggiunge latenza e a volte gli indexer restano indietro rispetto alla punta della chain.

Ho usato The Graph per dashboard a lettura intensiva dove una freschezza dei dati di 30-60 secondi è accettabile. Funziona bene in quel contesto. Non lo userei per nulla che richieda dati in tempo reale o logica di business complessa nei mapping.

Indexer personalizzati (Ponder, Envio)#

qui che l'ecosistema è maturato significativamente. Ponder e Envio ti permettono di scrivere logica di indicizzazione in TypeScript (non AssemblyScript), eseguire in locale durante lo sviluppo e fare il deploy come servizi autonomi.

Ponder ti d il massimo controllo. Definisci gli handler degli eventi in TypeScript, gestisce la pipeline di indicizzazione e ottieni un database SQL come output. Il compromesso: l'infrastruttura è tua. Scaling, monitoraggio, gestione delle riorganizzazioni -- sta tutto a te.

Envio ottimizza per la velocità di sincronizzazione. I loro benchmark mostrano tempi di sincronizzazione iniziale significativamente più rapidi rispetto a The Graph. Gestiscono le riorganizzazioni nativamente e supportano HyperSync, un protocollo specializzato per il recupero dati più veloce. Il compromesso: ti leghi alla loro infrastruttura e API.

La mia raccomandazione: se stai costruendo un'app DeFi in produzione e hai capacità ingegneristica, usa Ponder. Se hai bisogno della sincronizzazione più veloce possibile e non vuoi gestire l'infrastruttura, valuta Envio. Se hai bisogno di un prototipo rapido o vuoi subgraph mantenuti dalla comunità, The Graph va ancora bene.

getLogs è più pericoloso di quanto sembri#

Il metodo RPC eth_getLogs è ingannevolmente semplice. Dagli un intervallo di blocchi e dei filtri, ottieni indietro i log degli eventi corrispondenti. Ecco cosa succede realmente in produzione:

I limiti dell'intervallo di blocchi variano per provider. Alchemy ha un cap a 2K blocchi (log illimitati) o blocchi illimitati (massimo 10K log). Infura ha limiti diversi. QuickNode ha limiti diversi. Un RPC pubblico potrebbe avere un cap a 1K blocchi. Il tuo codice deve gestire tutti questi casi.

Esistono limiti sulla dimensione della risposta. Anche all'interno dell'intervallo di blocchi, se un contratto popolare emette migliaia di eventi per blocco, la tua risposta può superare il limite di payload del provider (150MB su Alchemy). La chiamata non restituisce risultati parziali. Fallisce.

Gli intervalli vuoti non sono gratuiti. Anche se non ci sono log corrispondenti, il provider scansiona comunque l'intervallo di blocchi. Questo conta contro le tue unit di calcolo.

Ecco un'utility di paginazione che gestisce questi vincoli:

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) {
      // Se l'intervallo  troppo grande (troppi risultati), dividilo a met
      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")
  );
}

L'intuizione chiave è la divisione binaria in caso di fallimento. Se un intervallo di 2K blocchi restituisce troppi log, dividilo in due intervalli da 1K. Se 1K è ancora troppo, dividi di nuovo. Questo si adatta automaticamente ai contratti ad alta attività senza richiedere di conoscere in anticipo la densit degli eventi.

BigInt ti metter in ginocchio#

Il tipo Number di JavaScript è un float a 64 bit. Pu rappresentare interi fino a 2^53 - 1 -- circa 9 quadrilioni. Sembra tanto finché non realizzi che un importo di 1 ETH in wei è 1000000000000000000 -- un numero con 18 zeri. Questo è 10^18, ben oltre Number.MAX_SAFE_INTEGER.

Se accidentalmente converti un BigInt in un Number in qualsiasi punto della tua pipeline -- JSON.parse, un driver di database, una libreria di logging -- ottieni una perdita di precisione silenziosa. Il numero sembra approssimativamente corretto ma le ultime cifre sono sbagliate. Non lo scoprirai nei test perché i tuoi importi di test sono piccoli.

Ecco il bug che ho mandato in produzione:

typescript
// IL BUG: Sembra innocuo, non lo
function formatTokenAmount(amount: bigint, decimals: number): string {
  return (Number(amount) / Math.pow(10, decimals)).toFixed(4);
}
 
// Per importi piccoli funziona bene:
formatTokenAmount(1000000n, 6); // "1.0000" -- corretto
 
// Per importi grandi si rompe silenziosamente:
formatTokenAmount(123456789012345678n, 18);
// Restituisce "0.1235" -- SBAGLIATO, la precisione  persa
// Number(123456789012345678n) === 123456789012345680
// Le ultime due cifre sono state arrotondate da IEEE 754

La soluzione: non convertire mai a Number prima di dividere. Usa le utility integrate di viem, che operano su stringhe e BigInt:

typescript
import { formatUnits, parseUnits } from "viem";
 
// Corretto: opera su BigInt, restituisce stringa
function formatTokenAmount(
  amount: bigint,
  decimals: number,
  displayDecimals: number = 4
): string {
  const formatted = formatUnits(amount, decimals);
 
  // formatUnits restituisce la stringa a piena precisione come "0.123456789012345678"
  // Tronca (non arrotondare) alla precisione di visualizzazione desiderata
  const [whole, fraction = ""] = formatted.split(".");
  const truncated = fraction.slice(0, displayDecimals).padEnd(displayDecimals, "0");
 
  return `${whole}.${truncated}`;
}
 
// Altrettanto critico: usa parseUnits per l'input utente, mai parseFloat
function parseTokenInput(input: string, decimals: number): bigint {
  // parseUnits gestisce correttamente la conversione da stringa a BigInt
  return parseUnits(input, decimals);
}

Nota che tronco invece di arrotondare. Questo è deliberato. In contesti finanziari, mostrare "1.0001 ETH" quando il valore reale è "1.00009999..." è meglio che mostrare "1.0001" quando il valore reale è "1.00005001..." ed è stato arrotondato per eccesso. Gli utenti prendono decisioni basate sugli importi visualizzati. Il troncamento è la scelta conservativa.

Un'altra trappola: JSON.stringify non sa come serializzare BigInt. Genera un errore. Ogni singola risposta dalla tua API che include importi di token ha bisogno di una strategia di serializzazione. Io uso la conversione a stringa al confine dell'API:

typescript
// Serializzatore per le risposte API
function serializeForApi(data: Record<string, unknown>): string {
  return JSON.stringify(data, (_, value) =>
    typeof value === "bigint" ? value.toString() : value
  );
}

Strategia di caching: cosa, per quanto tempo e quando invalidare#

Non tutti i dati on-chain hanno le stesse esigenze di freschezza. Ecco la gerarchia che uso:

Cache permanente (immutabile):

  • Ricevute delle transazioni (una volta minate, non cambiano)
  • Dati dei blocchi finalizzati (hash del blocco, timestamp, lista transazioni)
  • Bytecode dei contratti
  • Log di eventi storici da blocchi finalizzati

Cache da minuti a ore:

  • Metadati dei token (nome, simbolo, decimali) -- tecnicamente immutabili per la maggior parte dei token, ma gli aggiornamenti dei proxy possono cambiare l'implementazione
  • Risoluzioni ENS -- un TTL di 5 minuti funziona bene
  • Prezzi dei token -- dipende dai requisiti di accuratezza, da 30 secondi a 5 minuti

Cache per secondi o niente:

  • Numero del blocco corrente
  • Saldi e nonce degli account
  • Stato delle transazioni in attesa
  • Log di eventi non finalizzati (di nuovo il problema delle riorganizzazioni)

L'implementazione non deve essere complessa. Una cache a due livelli con LRU in memoria e Redis copre la maggior parte dei casi:

typescript
import { LRUCache } from "lru-cache";
 
const memoryCache = new LRUCache<string, unknown>({
  max: 10_000,
  ttl: 1000 * 60, // 1 minuto di default
});
 
type CacheTier = "immutable" | "short" | "volatile";
 
const TTL_MAP: Record<CacheTier, number> = {
  immutable: 1000 * 60 * 60 * 24, // 24 ore in memoria, permanente in Redis
  short: 1000 * 60 * 5,            // 5 minuti
  volatile: 1000 * 15,             // 15 secondi
};
 
async function cachedRpcCall<T>(
  key: string,
  tier: CacheTier,
  fetcher: () => Promise<T>
): Promise<T> {
  // Controlla prima la memoria
  const cached = memoryCache.get(key) as T | undefined;
  if (cached !== undefined) return cached;
 
  // Poi Redis (se lo hai)
  // const redisCached = await redis.get(key);
  // if (redisCached) { ... }
 
  const result = await fetcher();
  memoryCache.set(key, result, { ttl: TTL_MAP[tier] });
 
  return result;
}
 
// Utilizzo:
const receipt = await cachedRpcCall(
  `receipt:${txHash}`,
  "immutable",
  () => client.getTransactionReceipt({ hash: txHash })
);

La lezione controintuitiva: il miglioramento di performance più grande non è il caching delle risposte RPC. è evitare del tutto le chiamate RPC. Ogni volta che stai per chiamare getBlock, chiediti: ho davvero bisogno di dati dalla chain in questo momento, o posso derivarli da dati che ho già? Posso ascoltare gli eventi via WebSocket invece di fare polling? Posso raggruppare più letture in una singola multicall?

TypeScript e ABI dei contratti: il modo giusto#

Il sistema di tipi di viem, basato su ABIType, fornisce inferenza di tipo completa end-to-end dall'ABI del tuo contratto al codice TypeScript. Ma solo se lo configuri correttamente.

Il modo sbagliato:

typescript
// Nessuna inferenza di tipo -- args  unknown[], il ritorno  unknown
const result = await client.readContract({
  address: "0x...",
  abi: JSON.parse(abiString), // parsato a runtime = nessuna informazione di tipo
  functionName: "balanceOf",
  args: ["0x..."],
});

Il modo giusto:

typescript
// Definisci l'ABI come const per l'inferenza di tipo completa
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;
 
// Ora TypeScript sa:
// - functionName autocompleta a "balanceOf" | "transfer"
// - args per balanceOf  [address: `0x${string}`]
// - il tipo di ritorno per balanceOf  bigint
const balance = await client.readContract({
  address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
  abi: erc20Abi,
  functionName: "balanceOf",
  args: ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"],
});
// typeof balance = bigint -- completamente tipizzato

L'asserzione as const è ciò che fa funzionare tutto. Senza di essa, TypeScript allarga il tipo dell'ABI a { name: string, type: string, ... }[] e tutto il meccanismo di inferenza crolla. Questo è l'errore più comune che vedo nelle codebase TypeScript Web3.

Per progetti più grandi, usa @wagmi/cli per generare binding tipizzati dei contratti direttamente dal tuo progetto Foundry o Hardhat. Legge i tuoi ABI compilati e produce file TypeScript con le asserzioni as const già applicate. Nessuna copia manuale degli ABI, nessuna deriva dei tipi.

La scomoda verità#

I dati blockchain sono un problema di sistema distribuito mascherato da problema di database. Nel momento in cui li tratti come "solo un'altra API", inizi ad accumulare bug invisibili in sviluppo e intermittenti in produzione.

Gli strumenti sono migliorati enormemente. viem è un miglioramento enorme rispetto a ethers.js per type safety e developer experience. Ponder e Envio hanno reso l'indicizzazione personalizzata accessibile. Ma le sfide fondamentali -- riorganizzazioni, rate limit, codifica, finalit -- sono a livello di protocollo. Nessuna libreria le astrae via.

Costruisci con l'assunzione che il tuo RPC ti mentir, i tuoi blocchi si riorganizzeranno, i tuoi numeri andranno in overflow e la tua cache servir dati stantii. Poi gestisci esplicitamente ogni caso.

Ecco che aspetto hanno i dati on-chain production-grade.

Articoli correlati