Ethereum per sviluppatori web: smart contract senza l'hype
I concetti Ethereum che ogni sviluppatore web deve conoscere: account, transazioni, smart contract, codifica ABI, ethers.js, WAGMI e lettura di dati on-chain senza gestire un proprio nodo.
La maggior parte dei contenuti "Ethereum per sviluppatori" rientra in due categorie: analogie semplificate che non ti aiutano a costruire nulla, o specifiche di protocollo approfondite che presuppongono tu sappia già cos'è un Merkle Patricia Trie. Nessuna delle due è utile se sei uno sviluppatore web che vuole leggere il saldo di un token, far firmare una transazione a un utente, o mostrare metadati NFT in un'app React.
Questo post è la via di mezzo pratica. Spiegherò esattamente cosa succede quando il tuo frontend comunica con Ethereum, quali sono le parti in movimento e come il tooling moderno (ethers.js, viem, WAGMI) si mappa a concetti che già comprendi dalla costruzione di applicazioni web.
Niente metafore sui distributori automatici. Niente "immagina un mondo dove..." Solo il modello tecnico e il codice.
Il modello mentale#
Ethereum è una macchina a stati replicata. Ogni nodo della rete mantiene una copia identica dello stato — un enorme key-value store che mappa indirizzi a dati degli account. Quando "invii una transazione," stai proponendo una transizione di stato. Se abbastanza validatori concordano che è valida, lo stato si aggiorna. Tutto qui.
Lo stato stesso è semplice. È una mappatura da indirizzi a 20 byte a oggetti account. Ogni account ha quattro campi:
- nonce: Quante transazioni ha inviato questo account (per gli EOA) o quanti contratti ha creato (per gli account contratto). Previene gli attacchi replay.
- balance: Quantità di ETH, denominata in wei (1 ETH = 10^18 wei). Sempre un big integer.
- codeHash: Hash del bytecode EVM. Per i wallet normali (EOA), è l'hash di byte vuoti. Per i contratti, è l'hash del codice deployato.
- storageRoot: Root hash del trie di storage dell'account. Solo i contratti hanno storage significativo.
Ci sono due tipi di account, e la distinzione è importante per tutto ciò che segue:
Externally Owned Account (EOA) sono controllati da una chiave privata. Sono ciò che MetaMask gestisce. Possono iniziare transazioni. Non hanno codice. Quando qualcuno dice "wallet," intende un EOA.
Account contratto sono controllati dal loro codice. Non possono iniziare transazioni — possono solo eseguire in risposta a una chiamata. Hanno codice e storage. Quando qualcuno dice "smart contract," intende questo. Il codice è immutabile una volta deployato (con alcune eccezioni tramite pattern proxy, che è tutta un'altra discussione).
L'intuizione critica: ogni cambio di stato su Ethereum inizia con un EOA che firma una transazione. I contratti possono chiamare altri contratti, ma la catena di esecuzione inizia sempre con un umano (o un bot) con una chiave privata.
Gas: il calcolo ha un prezzo#
Ogni operazione nell'EVM costa gas. Sommare due numeri costa 3 gas. Salvare una parola da 32 byte costa 20.000 gas (prima volta) o 5.000 gas (aggiornamento). Leggere lo storage costa 2.100 gas (freddo) o 100 gas (caldo, già acceduto in questa transazione).
Non paghi il gas in "unità gas." Paghi in ETH. Il costo totale è:
totalCost = gasUsed * gasPrice
Dopo l'EIP-1559 (aggiornamento London), il prezzo del gas è diventato un sistema a due parti:
totalCost = gasUsed * (baseFee + priorityFee)
- baseFee: Impostata dal protocollo in base alla congestione della rete. Viene bruciata (distrutta).
- priorityFee (mancia): Va al validatore. Mancia più alta = inclusione più rapida.
- maxFeePerGas: Il massimo che sei disposto a pagare per unità di gas.
- maxPriorityFeePerGas: La mancia massima per unità di gas.
Se baseFee + priorityFee > maxFeePerGas, la tua transazione attende fino a quando la baseFee scende. Ecco perché le transazioni "si bloccano" durante l'alta congestione.
L'implicazione pratica per gli sviluppatori web: leggere dati è gratis. Scrivere dati costa denaro. Questa è la singola differenza architetturale più importante tra Web2 e Web3. Ogni SELECT è gratis. Ogni INSERT, UPDATE, DELETE costa denaro reale. Progetta le tue dApp di conseguenza.
Transazioni#
Una transazione è una struttura dati firmata. Ecco i campi che contano:
interface Transaction {
// Chi riceve questa transazione — un indirizzo EOA o un indirizzo contratto
to: string; // indirizzo hex a 20 byte, o null per il deploy di contratti
// Quanti ETH inviare (in wei)
value: bigint; // Può essere 0n per chiamate pure a contratti
// Dati di chiamata funzione codificati, o vuoto per semplici trasferimenti ETH
data: string; // Byte codificati in hex, "0x" per trasferimenti semplici
// Contatore sequenziale, previene attacchi replay
nonce: number; // Deve essere esattamente uguale al nonce attuale del mittente
// Limite gas — gas massimo che questa tx può consumare
gasLimit: bigint;
// Parametri commissione EIP-1559
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
// Identificatore della chain (1 = mainnet, 11155111 = Sepolia, 137 = Polygon)
chainId: number;
}Il ciclo di vita di una transazione#
-
Costruzione: La tua app costruisce l'oggetto transazione. Se stai chiamando una funzione di contratto, il campo
datacontiene la chiamata di funzione codificata ABI (ne parleremo sotto). -
Firma: La chiave privata firma la transazione codificata in RLP, producendo i componenti di firma
v,r,s. Questo prova che il mittente ha autorizzato questa specifica transazione. L'indirizzo del mittente è derivato dalla firma — non è esplicitamente nella transazione. -
Broadcasting: La transazione firmata viene inviata a un nodo RPC via
eth_sendRawTransaction. Il nodo la valida (nonce corretto, saldo sufficiente, firma valida) e la aggiunge al suo mempool. -
Mempool: La transazione si trova in un pool di transazioni in attesa. I validatori selezionano le transazioni da includere nel prossimo blocco, generalmente preferendo mance più alte. Qui avviene il front-running — altri attori possono vedere la tua transazione in attesa e inviare la propria con una mancia più alta per essere eseguiti prima di te.
-
Inclusione: Un validatore include la tua transazione in un blocco. L'EVM la esegue. Se ha successo, i cambi di stato vengono applicati. Se viene revertita, i cambi di stato vengono annullati — ma paghi comunque per il gas consumato fino al punto di revert.
-
Finalità: Su Ethereum proof-of-stake, un blocco diventa "finalizzato" dopo due epoche (~12,8 minuti). Prima della finalità, le riorganizzazioni della chain sono teoricamente possibili (anche se rare). La maggior parte delle app tratta 1-2 conferme di blocco come "sufficienti" per operazioni non critiche.
Ecco come appare l'invio di un semplice trasferimento ETH con ethers.js v6:
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
const tx = await wallet.sendTransaction({
to: "0xRecipientAddress...",
value: ethers.parseEther("0.1"), // Converte "0.1" in wei (100000000000000000n)
});
console.log("Hash tx:", tx.hash);
// Attendi l'inclusione in un blocco
const receipt = await tx.wait();
console.log("Numero blocco:", receipt.blockNumber);
console.log("Gas usato:", receipt.gasUsed.toString());
console.log("Stato:", receipt.status); // 1 = successo, 0 = revertE lo stesso con viem:
import { createWalletClient, http, parseEther } from "viem";
import { mainnet } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
const client = createWalletClient({
account,
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
const hash = await client.sendTransaction({
to: "0xRecipientAddress...",
value: parseEther("0.1"),
});
console.log("Hash tx:", hash);Nota la differenza: ethers restituisce un oggetto TransactionResponse con un metodo .wait(). Viem restituisce solo l'hash — usi una chiamata separata publicClient.waitForTransactionReceipt({ hash }) per attendere la conferma. Questa separazione delle responsabilità è intenzionale nel design di viem.
Smart contract#
Uno smart contract è bytecode deployato più storage persistente a un indirizzo specifico. Quando "chiami" un contratto, stai inviando una transazione (o facendo una chiamata di sola lettura) con il campo data impostato a un'invocazione di funzione codificata.
Bytecode e ABI#
Il bytecode è il codice EVM compilato. Non interagisci direttamente con esso. È ciò che l'EVM esegue.
L'ABI (Application Binary Interface) è una descrizione JSON dell'interfaccia del contratto. Dice alla tua libreria client come codificare le chiamate di funzione e decodificare i valori di ritorno. Pensalo come una specifica OpenAPI per un contratto.
Ecco un frammento di un ABI token ERC-20:
const ERC20_ABI = [
// Funzioni di sola lettura (view/pure — nessun costo di gas quando chiamate esternamente)
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address owner) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
// Funzioni che cambiano stato (richiedono una transazione, costano gas)
"function transfer(address to, uint256 amount) returns (bool)",
"function approve(address spender, uint256 amount) returns (bool)",
"function transferFrom(address from, address to, uint256 amount) returns (bool)",
// Eventi (emessi durante l'esecuzione, memorizzati nei log della transazione)
"event Transfer(address indexed from, address indexed to, uint256 value)",
"event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;Ethers.js accetta questo formato "ABI leggibile dagli umani". Viem può usarlo anch'esso, ma spesso lavorerai con l'ABI JSON completo generato dal compilatore Solidity. Entrambi sono equivalenti — il formato leggibile è solo più comodo per le interfacce comuni.
Come vengono codificate le chiamate di funzione#
Questa è la parte che la maggior parte dei tutorial salta, ed è la parte che ti farà risparmiare ore di debugging.
Quando chiami transfer("0xBob...", 1000000), il campo data della transazione viene impostato a:
0xa9059cbb // Selettore funzione
0000000000000000000000000xBob...000000000000000000000000 // address, paddato a 32 byte
00000000000000000000000000000000000000000000000000000000000f4240 // uint256 importo (1000000 in hex)
Il selettore funzione sono i primi 4 byte dell'hash Keccak-256 della firma della funzione:
keccak256("transfer(address,uint256)") = 0xa9059cbb...
selettore = primi 4 byte = 0xa9059cbb
I byte rimanenti sono gli argomenti codificati ABI, ciascuno paddato a 32 byte. Questo schema di codifica è deterministico — la stessa chiamata di funzione produce sempre gli stessi calldata.
Perché è importante? Perché quando vedi dati di transazione grezzi su Etherscan e iniziano con 0xa9059cbb, sai che è una chiamata transfer. Quando la tua transazione reverte e il messaggio di errore è solo un blob hex, puoi decodificarlo usando l'ABI. E quando stai costruendo batch di transazioni o interagendo con contratti multicall, codificherai i calldata manualmente.
Ecco come codificare e decodificare manualmente con ethers.js:
import { ethers } from "ethers";
const iface = new ethers.Interface(ERC20_ABI);
// Codifica una chiamata di funzione
const calldata = iface.encodeFunctionData("transfer", [
"0xBobAddress...",
1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
// Decodifica calldata di nuovo in nome funzione e argomenti
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name); // "transfer"
console.log(decoded.args[0]); // "0xBobAddress..."
console.log(decoded.args[1]); // 1000000n (BigInt)
// Decodifica i dati di ritorno di una funzione
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // trueSlot di storage#
Lo storage del contratto è un key-value store dove sia le chiavi che i valori sono da 32 byte. Solidity assegna gli slot di storage sequenzialmente partendo da 0. La prima variabile di stato dichiarata va nello slot 0, la successiva nello slot 1, e così via. Le mapping e gli array dinamici usano uno schema basato su hash.
Puoi leggere lo storage di qualsiasi contratto direttamente, anche se la variabile è marcata come private in Solidity. "Private" significa solo che altri contratti non possono leggerla — chiunque può leggerla via eth_getStorageAt:
// Lettura dello storage slot 0 di un contratto
const slot0 = await provider.getStorage(
"0xContractAddress...",
0
);
console.log(slot0); // Valore hex grezzo da 32 byteCosì i block explorer mostrano lo stato "interno" del contratto. Non c'è controllo degli accessi sulle letture dello storage. La privacy su una blockchain pubblica è fondamentalmente limitata.
Eventi e log#
Gli eventi sono il modo del contratto di emettere dati strutturati che vengono memorizzati nei log della transazione ma non nello storage del contratto. Sono più economici delle scritture in storage (375 gas per il primo topic + 8 gas per byte di dati, contro 20.000 gas per una scrittura in storage) e sono progettati per essere interrogati efficientemente.
Un evento può avere fino a 3 parametri indexed (memorizzati come "topic") e un numero qualsiasi di parametri non indicizzati (memorizzati come "data"). I parametri indicizzati possono essere filtrati — puoi chiedere "dammi tutti gli eventi Transfer dove to è questo indirizzo." I parametri non indicizzati non possono essere filtrati; devi recuperare tutti gli eventi corrispondenti e filtrare lato client.
// Ascolto di eventi Transfer in tempo reale con ethers.js
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
contract.on("Transfer", (from, to, value, event) => {
console.log(`${from} -> ${to}: ${ethers.formatUnits(value, 18)} token`);
console.log("Blocco:", event.log.blockNumber);
console.log("Hash tx:", event.log.transactionHash);
});
// Interrogazione di eventi storici
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // from=qualsiasi, to=specifico
const events = await contract.queryFilter(filter, 19000000, 19100000); // intervallo di blocchi
for (const event of events) {
console.log("Da:", event.args.from);
console.log("Valore:", event.args.value.toString());
}Lo stesso con viem:
import { createPublicClient, http, parseAbiItem } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
// Log storici
const logs = await client.getLogs({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
args: {
to: "0xMyAddress...",
},
fromBlock: 19000000n,
toBlock: 19100000n,
});
for (const log of logs) {
console.log("Da:", log.args.from);
console.log("A:", log.args.to);
console.log("Valore:", log.args.value);
}
// Monitoraggio in tempo reale
const unwatch = client.watchEvent({
address: "0xTokenAddress...",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
onLogs: (logs) => {
for (const log of logs) {
console.log(`Trasferimento: ${log.args.from} -> ${log.args.to}`);
}
},
});
// Chiama unwatch() per smettere di ascoltareLettura di dati on-chain#
Qui è dove Ethereum diventa pratico per gli sviluppatori web. Non hai bisogno di eseguire un nodo. Non hai bisogno di minare. Non hai nemmeno bisogno di un wallet. Leggere dati da Ethereum è gratuito, senza permessi, e funziona tramite una semplice API JSON-RPC.
JSON-RPC: l'API HTTP di Ethereum#
Ogni nodo Ethereum espone un'API JSON-RPC. È letteralmente HTTP POST con body JSON. Non c'è nulla di specifico della blockchain nel layer di trasporto.
// Questo è ciò che la tua libreria fa sotto il cofano
const response = await fetch("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_call",
params: [
{
to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
data: "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
// balanceOf(vitalik.eth)
},
"latest",
],
}),
});
const result = await response.json();
console.log(result);
// { jsonrpc: "2.0", id: 1, result: "0x000000000000000000000000000000000000000000000000000000174876e800" }Quella è una eth_call grezza. Simula l'esecuzione di una transazione senza inviarla realmente. Nessun costo di gas. Nessun cambio di stato. Legge solo il valore di ritorno. Così funzionano le funzioni view e pure dall'esterno — usano eth_call invece di eth_sendRawTransaction.
I due metodi RPC critici#
eth_call: Simula l'esecuzione. Gratuito. Nessun cambio di stato. Usato per tutte le operazioni di lettura — controllare saldi, leggere prezzi, chiamare funzioni view. Può essere chiamato su qualsiasi blocco storico specificando un numero di blocco invece di "latest".
eth_sendRawTransaction: Invia una transazione firmata per l'inclusione in un blocco. Costa gas. Cambia lo stato (se ha successo). Usato per tutte le operazioni di scrittura — trasferimenti, approvazioni, swap, mint.
Tutto il resto nell'API JSON-RPC è una variante di questi due o un metodo utility (eth_blockNumber, eth_getTransactionReceipt, eth_getLogs, ecc.).
Provider: il tuo gateway verso la chain#
Non esegui il tuo nodo. Quasi nessuno lo fa per lo sviluppo di applicazioni. Invece, usi un servizio provider:
- Alchemy: Il più popolare. Ottima dashboard, supporto webhook, API avanzate per NFT e metadati token. Piano gratuito: ~300M unità di calcolo/mese.
- Infura: L'originale. Di proprietà di ConsenSys. Affidabile. Piano gratuito: 100K richieste/giorno.
- QuickNode: Buono per il multi-chain. Modello di prezzo leggermente diverso.
- Endpoint RPC pubblici:
https://rpc.ankr.com/eth,https://cloudflare-eth.com. Gratuiti ma con rate limiting e occasionalmente inaffidabili. Vanno bene per lo sviluppo, pericolosi per la produzione. - Tenderly: Eccellente per simulazione e debugging. Il loro RPC include un simulatore di transazioni integrato.
Per la produzione, configura sempre almeno due provider come fallback. Il downtime RPC è reale e succederà nel peggior momento possibile.
import { ethers } from "ethers";
// ethers.js v6 fallback provider
const provider = new ethers.FallbackProvider([
{
provider: new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/KEY1"),
priority: 1,
stallTimeout: 2000,
weight: 2,
},
{
provider: new ethers.JsonRpcProvider("https://mainnet.infura.io/v3/KEY2"),
priority: 2,
stallTimeout: 2000,
weight: 1,
},
]);Leggere lo stato del contratto gratuitamente#
Questa è la mossa potente che la maggior parte degli sviluppatori Web2 non realizza: puoi leggere qualsiasi dato pubblico da qualsiasi contratto su Ethereum senza pagare nulla, senza un wallet, e senza alcuna autenticazione oltre a una chiave API per il tuo provider RPC.
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
// Interfaccia ERC-20 — solo le funzioni di lettura
const erc20 = new ethers.Contract(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
[
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address) view returns (uint256)",
],
provider // Nota: provider, non signer. Sola lettura.
);
const [name, symbol, decimals, totalSupply] = await Promise.all([
erc20.name(),
erc20.symbol(),
erc20.decimals(),
erc20.totalSupply(),
]);
console.log(`${name} (${symbol})`); // "USD Coin (USDC)"
console.log(`Decimali: ${decimals}`); // 6 (NON 18!)
console.log(`Offerta totale: ${ethers.formatUnits(totalSupply, decimals)}`);
// Controlla il saldo di un indirizzo specifico
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Saldo: ${ethers.formatUnits(balance, decimals)} USDC`);Nessun wallet. Nessun gas. Nessuna transazione. Solo una eth_call JSON-RPC sotto il cofano. Questo è identico nel concetto a fare una richiesta GET a un'API REST. La blockchain è il database, il contratto è l'API, e eth_call è la tua query SELECT.
ethers.js v6#
ethers.js è il jQuery del Web3 — è stata la prima libreria che la maggior parte degli sviluppatori ha imparato, ed è ancora la più utilizzata. La versione 6 è un miglioramento significativo rispetto alla v5, con supporto nativo BigInt (finalmente), moduli ESM e un'API più pulita.
Le tre astrazioni fondamentali#
Provider: Una connessione di sola lettura alla blockchain. Può chiamare funzioni view, recuperare blocchi, leggere log. Non può firmare o inviare transazioni.
import { ethers } from "ethers";
// Connettiti a un nodo
const provider = new ethers.JsonRpcProvider("https://...");
// Query base
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");Signer: Un'astrazione su una chiave privata. Può firmare transazioni e messaggi. Un Signer è sempre connesso a un Provider.
// Da una chiave privata (lato server, script)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
// Da un wallet del browser (lato client)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
// Ottieni l'indirizzo
const address = await signer.getAddress();Contract: Un proxy JavaScript per un contratto deployato. I metodi sull'oggetto Contract corrispondono alle funzioni nell'ABI. Le funzioni view restituiscono valori. Le funzioni che cambiano stato restituiscono un TransactionResponse.
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
// Lettura (gratuita, restituisce il valore direttamente)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance è un bigint: 1000000000n (1000 USDC con 6 decimali)
// Per scrivere, connettiti con un signer
const usdcWithSigner = usdc.connect(signer);
// Scrittura (costa gas, restituisce TransactionResponse)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // Attendi l'inclusione nel blocco
if (receipt.status === 0) {
throw new Error("Transazione revertita");
}TypeChain per la sicurezza dei tipi#
Le interazioni ABI grezze sono tipizzate come stringhe. Puoi sbagliare il nome di una funzione, passare tipi di argomenti sbagliati, o interpretare male i valori di ritorno. TypeChain genera tipi TypeScript dai tuoi file ABI:
// Senza TypeChain — nessun controllo dei tipi
const balance = await contract.balanceOf("0x...");
// balance è 'any'. Nessun autocomplete. Facile da usare male.
// Con TypeChain — piena sicurezza dei tipi
import { USDC__factory } from "./typechain";
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance è BigNumber. L'autocomplete funziona. Errori di tipo catturati a compile time.Per i nuovi progetti, considera l'uso dell'inferenza dei tipi integrata di viem dagli ABI. Raggiunge lo stesso risultato senza un passaggio separato di generazione del codice.
Ascolto degli eventi#
Lo streaming di eventi in tempo reale è fondamentale per dApp responsive. ethers.js usa provider WebSocket per questo:
// WebSocket per eventi in tempo reale
const wsProvider = new ethers.WebSocketProvider("wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
// Ascolta tutti gli eventi Transfer
contract.on("Transfer", (from, to, value, event) => {
console.log(`Trasferimento: ${from} -> ${to}`);
console.log(`Importo: ${ethers.formatUnits(value, 6)} USDC`);
});
// Ascolta trasferimenti VERSO un indirizzo specifico
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
console.log(`Trasferimento in arrivo: ${ethers.formatUnits(value, 6)} USDC da ${from}`);
});
// Pulizia quando hai finito
contract.removeAllListeners();WAGMI + Viem: lo stack moderno#
WAGMI (We're All Gonna Make It) è una libreria di React hook per Ethereum. Viem è il client TypeScript sottostante che utilizza. Insieme, hanno in gran parte sostituito ethers.js + web3-react come lo stack standard per lo sviluppo frontend di dApp.
Perché il cambiamento? Tre ragioni: piena inferenza TypeScript dagli ABI (nessuna codegen necessaria), bundle size più piccolo, e React hook che gestiscono il disordinato state management asincrono delle interazioni con i wallet.
Configurazione#
// wagmi.config.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
export const config = createConfig({
chains: [mainnet, sepolia],
connectors: [
injected(),
walletConnect({ projectId: "YOUR_WALLETCONNECT_PROJECT_ID" }),
],
transports: {
[mainnet.id]: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
[sepolia.id]: http("https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY"),
},
});// app/providers.tsx
"use client";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi.config";
const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</WagmiProvider>
);
}Lettura dati dal contratto#
useReadContract è l'hook che userai di più. Avvolge eth_call con caching React Query, refetching e stati di loading/errore:
"use client";
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
const ERC20_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "owner", type: "address" }],
outputs: [{ name: "balance", type: "uint256" }],
},
] as const;
function TokenBalance({ address }: { address: `0x${string}` }) {
const { data: balance, isLoading, error } = useReadContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
abi: ERC20_ABI,
functionName: "balanceOf",
args: [address],
});
if (isLoading) return <span>Caricamento...</span>;
if (error) return <span>Errore: {error.message}</span>;
// balance è tipizzato come bigint perché l'ABI dice uint256
return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}Nota l'as const sull'ABI. È fondamentale. Senza, TypeScript perde i tipi letterali e balance diventa unknown invece di bigint. L'intero sistema di inferenza dei tipi dipende dalle asserzioni const.
Scrittura su contratti#
useWriteContract gestisce l'intero ciclo di vita: prompt del wallet, firma, broadcasting e tracking della conferma.
"use client";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseUnits } from "viem";
function SendTokens() {
const { writeContract, data: hash, isPending, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash,
});
function handleSend() {
writeContract({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
functionName: "transfer",
args: [
"0xRecipientAddress...",
parseUnits("100", 6), // 100 USDC
],
});
}
return (
<div>
<button onClick={handleSend} disabled={isPending}>
{isPending ? "Conferma nel wallet..." : "Invia 100 USDC"}
</button>
{hash && <p>Transazione: {hash}</p>}
{isConfirming && <p>In attesa di conferma...</p>}
{isSuccess && <p>Trasferimento confermato!</p>}
{error && <p>Errore: {error.message}</p>}
</div>
);
}Monitoraggio degli eventi#
useWatchContractEvent configura una sottoscrizione WebSocket per il monitoraggio degli eventi in tempo reale:
"use client";
import { useWatchContractEvent } from "wagmi";
import { useState } from "react";
import { formatUnits } from "viem";
interface TransferEvent {
from: string;
to: string;
value: string;
}
function LiveTransfers() {
const [transfers, setTransfers] = useState<TransferEvent[]>([]);
useWatchContractEvent({
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
abi: ERC20_ABI,
eventName: "Transfer",
onLogs(logs) {
const newTransfers = logs.map((log) => ({
from: log.args.from as string,
to: log.args.to as string,
value: formatUnits(log.args.value as bigint, 6),
}));
setTransfers((prev) => [...newTransfers, ...prev].slice(0, 50));
},
});
return (
<ul>
{transfers.map((t, i) => (
<li key={i}>
{t.from.slice(0, 8)}... → {t.to.slice(0, 8)}...: {t.value} USDC
</li>
))}
</ul>
);
}Pattern di connessione wallet#
Connettere il wallet di un utente è il "login" del Web3. Eccetto che non è un login. Non c'è sessione, nessun cookie, nessuno stato lato server. La connessione del wallet dà alla tua app il permesso di leggere l'indirizzo dell'utente e richiedere firme di transazioni. Tutto qui.
L'interfaccia provider EIP-1193#
Ogni wallet espone un'interfaccia standard definita dall'EIP-1193. È un oggetto con un metodo request:
interface EIP1193Provider {
request(args: { method: string; params?: unknown[] }): Promise<unknown>;
on(event: string, handler: (...args: unknown[]) => void): void;
removeListener(event: string, handler: (...args: unknown[]) => void): void;
}MetaMask lo inietta come window.ethereum. Altri wallet iniettano la propria proprietà o usano anch'essi window.ethereum (il che causa conflitti — il problema delle "guerre dei wallet", parzialmente risolto dall'EIP-6963).
// Interazione a basso livello con il wallet (non dovresti farlo direttamente, ma è utile da capire)
// Richiedi accesso all'account
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
console.log("Indirizzo connesso:", accounts[0]);
// Ottieni la chain corrente
const chainId = await window.ethereum.request({
method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (mainnet)
// Ascolta i cambi di account (l'utente cambia account in MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
if (accounts.length === 0) {
console.log("Wallet disconnesso");
} else {
console.log("Passato a:", accounts[0]);
}
});
// Ascolta i cambi di chain (l'utente cambia rete)
window.ethereum.on("chainChanged", (chainId: string) => {
// L'approccio raccomandato è ricaricare la pagina
window.location.reload();
});EIP-6963: scoperta multi-wallet#
Il vecchio approccio con window.ethereum si rompe quando gli utenti hanno più wallet installati. Quale ottiene window.ethereum? L'ultimo a iniettarsi? Il primo? È una race condition.
L'EIP-6963 risolve questo con un protocollo di scoperta basato sugli eventi del browser:
// Scoperta di tutti i wallet disponibili
interface EIP6963ProviderDetail {
info: {
uuid: string;
name: string;
icon: string;
rdns: string; // Nome di dominio inverso, es. "io.metamask"
};
provider: EIP1193Provider;
}
const wallets: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
wallets.push(event.detail);
});
// Richiedi a tutti i wallet di annunciarsi
window.dispatchEvent(new Event("eip6963:requestProvider"));
// Ora 'wallets' contiene tutti i wallet installati con nomi e icone
// Puoi mostrare una UI di selezione walletWAGMI gestisce tutto questo per te. Quando usi il connettore injected(), usa automaticamente l'EIP-6963 se disponibile e fa fallback a window.ethereum.
WalletConnect#
WalletConnect è un protocollo che connette wallet mobile a dApp desktop tramite un server relay. L'utente scansiona un codice QR con il suo wallet mobile, stabilendo una connessione crittografata. Le richieste di transazione vengono inoltrate dalla tua dApp al loro telefono.
Con WAGMI, è solo un altro connettore:
import { walletConnect } from "wagmi/connectors";
const connector = walletConnect({
projectId: "YOUR_PROJECT_ID", // Ottienilo da cloud.walletconnect.com
showQrModal: true,
});Gestione del cambio di chain#
Gli utenti sono spesso sulla rete sbagliata. La tua dApp è sulla Mainnet, loro sono connessi a Sepolia. O sono su Polygon e tu hai bisogno della Mainnet. WAGMI fornisce useSwitchChain:
"use client";
import { useAccount, useSwitchChain } from "wagmi";
import { mainnet } from "wagmi/chains";
function NetworkGuard({ children }: { children: React.ReactNode }) {
const { chain } = useAccount();
const { switchChain, isPending } = useSwitchChain();
if (!chain) return <p>Per favore connetti il tuo wallet</p>;
if (chain.id !== mainnet.id) {
return (
<div>
<p>Per favore passa a Ethereum Mainnet</p>
<button
onClick={() => switchChain({ chainId: mainnet.id })}
disabled={isPending}
>
{isPending ? "Cambio in corso..." : "Cambia rete"}
</button>
</div>
);
}
return <>{children}</>;
}IPFS e metadati#
Gli NFT non memorizzano immagini on-chain. La blockchain memorizza un URI che punta a un file JSON di metadati, che a sua volta contiene un URL all'immagine. Il pattern standard, definito dalla funzione tokenURI dell'ERC-721:
Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"
Quel file JSON segue uno schema standard:
{
"name": "Cool NFT #42",
"description": "Un NFT molto cool",
"image": "ipfs://QmImageHash...",
"attributes": [
{ "trait_type": "Sfondo", "value": "Blu" },
{ "trait_type": "Rarità", "value": "Leggendario" }
]
}CID IPFS vs URL#
Gli indirizzi IPFS usano Content Identifier (CID) — hash del contenuto stesso. ipfs://QmXyz... significa "il contenuto il cui hash è QmXyz...". Questo è storage indirizzato al contenuto: l'URI è derivato dal contenuto, quindi il contenuto non può mai cambiare senza cambiare l'URI. Questa è la garanzia di immutabilità su cui si basano gli NFT (quando effettivamente usano IPFS — molti usano URL centralizzati invece, il che è un segnale d'allarme).
Per mostrare contenuti IPFS in un browser, hai bisogno di un gateway che traduca gli URI IPFS in HTTP:
function ipfsToHttp(uri: string): string {
if (uri.startsWith("ipfs://")) {
const cid = uri.replace("ipfs://", "");
return `https://ipfs.io/ipfs/${cid}`;
// Oppure usa un gateway dedicato:
// return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
}
return uri;
}
// Recupero dei metadati NFT
async function getNftMetadata(
contractAddress: string,
tokenId: bigint,
provider: ethers.Provider
) {
const contract = new ethers.Contract(
contractAddress,
["function tokenURI(uint256 tokenId) view returns (string)"],
provider
);
const tokenUri = await contract.tokenURI(tokenId);
const httpUri = ipfsToHttp(tokenUri);
const response = await fetch(httpUri);
const metadata = await response.json();
return {
name: metadata.name,
description: metadata.description,
image: ipfsToHttp(metadata.image),
attributes: metadata.attributes,
};
}Servizi di pinning#
IPFS è una rete peer-to-peer. Il contenuto rimane disponibile solo finché qualcuno lo ospita ("pinna"). Se carichi un'immagine NFT su IPFS e poi spegni il tuo nodo, il contenuto scompare.
I servizi di pinning mantengono il tuo contenuto disponibile:
- Pinata: Il più popolare. API semplice. Piano gratuito generoso (1GB). Gateway dedicati per caricamento più veloce.
- NFT.Storage: Gratuito, supportato da Protocol Labs (i creatori di IPFS). Progettato specificamente per metadati NFT. Usa Filecoin per la persistenza a lungo termine.
- Web3.Storage: Simile a NFT.Storage, più generico.
// Upload su Pinata
async function pinToIpfs(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
method: "POST",
headers: {
Authorization: `Bearer ${PINATA_JWT}`,
},
body: formData,
});
const result = await response.json();
return `ipfs://${result.IpfsHash}`; // Restituisce CID
}Il problema dell'indicizzazione#
Ecco il segreto sporco dello sviluppo blockchain: non puoi interrogare efficientemente dati storici da un nodo RPC.
Vuoi tutti gli eventi Transfer per un token nell'ultimo anno? Dovrai scansionare milioni di blocchi con eth_getLogs, paginando in chunk da 2.000-10.000 blocchi (il massimo varia per provider). Sono migliaia di chiamate RPC. Ci vorranno minuti o ore e bruceranno la tua quota API.
Vuoi tutti i token posseduti da un indirizzo specifico? Non c'è una singola chiamata RPC per questo. Dovresti scansionare ogni evento Transfer per ogni contratto ERC-20, tracciando i saldi. Non è fattibile.
Vuoi tutti gli NFT in un wallet? Stesso problema. Devi scansionare ogni evento Transfer ERC-721 in ogni contratto NFT.
La blockchain è una struttura dati ottimizzata per la scrittura. È eccellente nell'elaborare nuove transazioni. È terribile nel rispondere a query storiche. Questo è il disallineamento fondamentale tra ciò di cui le UI delle dApp hanno bisogno e ciò che la chain fornisce nativamente.
Il protocollo The Graph#
The Graph è un protocollo di indicizzazione decentralizzato. Scrivi un "subgraph" — uno schema e un insieme di handler per eventi — e The Graph indicizza la chain e serve i dati tramite un'API GraphQL.
# Schema subgraph (schema.graphql)
type Transfer @entity {
id: Bytes!
from: Bytes!
to: Bytes!
value: BigInt!
blockNumber: BigInt!
timestamp: BigInt!
}
type Account @entity {
id: Bytes!
balance: BigInt!
transfersFrom: [Transfer!]! @derivedFrom(field: "from")
transfersTo: [Transfer!]! @derivedFrom(field: "to")
}// Interrogazione di un subgraph dal tuo frontend
const SUBGRAPH_URL =
"https://api.studio.thegraph.com/query/YOUR_ID/YOUR_SUBGRAPH/v0.0.1";
async function getRecentTransfers(address: string) {
const query = `
query GetTransfers($address: Bytes!) {
transfers(
where: { from: $address }
orderBy: blockNumber
orderDirection: desc
first: 100
) {
id
from
to
value
blockNumber
timestamp
}
}
`;
const response = await fetch(SUBGRAPH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables: { address } }),
});
const { data } = await response.json();
return data.transfers;
}Il compromesso: The Graph aggiunge latenza (tipicamente 1-2 blocchi dietro la testa della chain) e un'altra dipendenza. La rete decentralizzata ha anche costi di indicizzazione (paghi in token GRT). Per progetti più piccoli, il servizio hosted (Subgraph Studio) è gratuito.
API avanzate di Alchemy e Moralis#
Se non vuoi mantenere un subgraph, sia Alchemy che Moralis offrono API pre-indicizzate che rispondono direttamente a query comuni:
// Alchemy: Ottieni tutti i saldi dei token ERC-20 per un indirizzo
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "alchemy_getTokenBalances",
params: ["0xAddress...", "erc20"],
}),
}
);
// Restituisce TUTTI i saldi dei token ERC-20 in una sola chiamata
// vs. scansionare il balanceOf() di ogni possibile contratto ERC-20// Alchemy: Ottieni tutti gli NFT posseduti da un indirizzo
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY/getNFTs?owner=0xAddress...`
);
const { ownedNfts } = await response.json();
for (const nft of ownedNfts) {
console.log(`${nft.title} - ${nft.contract.address}#${nft.tokenId}`);
}Queste API sono proprietarie e centralizzate. Stai scambiando la decentralizzazione per l'esperienza sviluppatore. Per la maggior parte delle dApp, è un compromesso che vale la pena. Ai tuoi utenti non importa se la vista del loro portfolio viene da un subgraph o dal database di Alchemy. Gli importa che si carica in 200ms invece di 30 secondi.
Errori comuni#
Dopo aver rilasciato diverse dApp in produzione e aver debuggato il codice di altri team, questi sono gli errori che vedo ripetutamente. Ogni singolo mi ha colpito personalmente.
BigInt ovunque#
Ethereum tratta numeri molto grandi. I saldi ETH sono in wei (10^18). Le offerte dei token possono essere 10^27 o superiori. Il Number di JavaScript può rappresentare in modo sicuro interi solo fino a 2^53 - 1 (circa 9 * 10^15). Non è sufficiente per gli importi in wei.
// SBAGLIATO — perdita di precisione silenziosa
const balance = 1000000000000000000; // 1 ETH in wei
const double = balance * 2;
console.log(double); // 2000000000000000000 — sembra giusto, ma...
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance); // 100000000000000000000 — SBAGLIATO! Arrotondato per eccesso.
console.log(largeBalance === 100000000000000000000); // true — corruzione dati
// GIUSTO — usa BigInt
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — corretto
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — correttoRegole per BigInt nel codice dApp:
- Non convertire mai importi wei in
Number. UsaBigIntovunque, converti in stringhe leggibili solo per la visualizzazione. - Non usare mai
Math.floor,Math.round, ecc. sui BigInt. Non funzionano. Usa la divisione intera:amount / 10n ** 6n. - JSON non supporta BigInt. Se serializzi stato che include BigInt, hai bisogno di un serializzatore personalizzato:
JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v). - Usa le funzioni di formattazione delle librerie.
ethers.formatEther(),ethers.formatUnits(),formatEther()eformatUnits()di viem. Gestiscono la conversione correttamente.
import { formatUnits, parseUnits } from "viem";
// Visualizzazione: BigInt → stringa leggibile
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
// Input: stringa leggibile → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
// USDC ha 6 decimali, non 18
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"Operazioni wallet asincrone#
Ogni interazione con il wallet è asincrona e può fallire in modi che la tua app deve gestire con grazia:
// L'utente può rifiutare qualsiasi prompt del wallet
try {
const tx = await writeContract({
address: contractAddress,
abi: ERC20_ABI,
functionName: "approve",
args: [spenderAddress, amount],
});
} catch (error) {
if (error.code === 4001) {
// L'utente ha rifiutato la transazione nel wallet
// Questo è normale — non è un errore da segnalare
showToast("Transazione annullata");
} else if (error.code === -32603) {
// Errore interno JSON-RPC — spesso significa che la transazione revertirebbe
showToast("La transazione fallirebbe. Controlla il tuo saldo.");
} else {
// Errore inaspettato
console.error("Errore transazione:", error);
showToast("Qualcosa è andato storto. Per favore riprova.");
}
}Insidie asincrone principali:
- I prompt del wallet bloccano dal lato dell'utente. L'
awaitnel tuo codice può durare 30 secondi mentre l'utente legge i dettagli della transazione in MetaMask. Non mostrare uno spinner di caricamento che li faccia pensare che qualcosa si sia rotto. - L'utente può cambiare account durante l'interazione. Richiedi l'approvazione dall'Account A, l'utente passa all'Account B, poi approva. Ora l'Account B ha approvato ma stai per inviare una transazione dall'Account A. Ricontrolla sempre l'account connesso prima delle operazioni critiche.
- I pattern di scrittura a due passaggi sono comuni. Molte operazioni DeFi richiedono
approve+execute. L'utente deve firmare due transazioni. Se approva ma non esegue, devi controllare lo stato dell'allowance e saltare il passaggio di approvazione la volta successiva.
Errori di mismatch della rete#
Questo fa perdere più tempo di debug di qualsiasi altro problema. Il tuo contratto è sulla Mainnet. Il tuo wallet è su Sepolia. Il tuo provider RPC punta a Polygon. Tre reti diverse, tre stati diversi, tre blockchain completamente non correlate. E il messaggio di errore è di solito inutile — "execution reverted" o "contract not found."
// Controllo difensivo della chain
import { useAccount, useChainId } from "wagmi";
function useRequireChain(requiredChainId: number) {
const chainId = useChainId();
const { isConnected } = useAccount();
if (!isConnected) {
return { ready: false, error: "Per favore connetti il tuo wallet" };
}
if (chainId !== requiredChainId) {
return {
ready: false,
error: `Per favore passa a ${getChainName(requiredChainId)}. Sei su ${getChainName(chainId)}.`,
};
}
return { ready: true, error: null };
}Front-running nella DeFi#
Quando invii uno swap su un DEX, la tua transazione in attesa è visibile nel mempool. Un bot può vedere il tuo trade, fare front-run spingendo il prezzo in su, lasciare che il tuo trade venga eseguito a un prezzo peggiore, e poi vendere immediatamente dopo per un profitto. Questo si chiama "attacco sandwich."
Come sviluppatore frontend, non puoi prevenirlo completamente, ma puoi mitigarlo:
// Impostazione della tolleranza allo slippage su uno swap tipo Uniswap
const amountOutMin = expectedOutput * 995n / 1000n; // 0.5% di tolleranza allo slippage
// Usando una deadline per prevenire transazioni in attesa di lunga durata
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 minuti
await router.swapExactTokensForTokens(
amountIn,
amountOutMin, // Output minimo accettabile — reverte se otteniamo meno
[tokenA, tokenB],
userAddress,
deadline, // Reverte se non eseguito entro 20 minuti
);Per transazioni di alto valore, considera l'uso di Flashbots Protect RPC, che invia transazioni direttamente ai block builder invece che al mempool pubblico. Questo previene completamente gli attacchi sandwich perché i bot non vedono mai la tua transazione in attesa:
// Uso di Flashbots Protect come endpoint RPC
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");Confusione sui decimali#
Non tutti i token hanno 18 decimali. USDC e USDT ne hanno 6. WBTC ne ha 8. Alcuni token hanno 0, 2, o decimali arbitrari. Leggi sempre i decimals() dal contratto prima di formattare gli importi:
async function formatTokenAmount(
tokenAddress: string,
rawAmount: bigint,
provider: ethers.Provider
): Promise<string> {
const contract = new ethers.Contract(
tokenAddress,
["function decimals() view returns (uint8)", "function symbol() view returns (string)"],
provider
);
const [decimals, symbol] = await Promise.all([
contract.decimals(),
contract.symbol(),
]);
return `${ethers.formatUnits(rawAmount, decimals)} ${symbol}`;
}
// formatTokenAmount(USDC, 1000000n, provider) → "1.0 USDC"
// formatTokenAmount(WETH, 1000000000000000000n, provider) → "1.0 WETH"
// formatTokenAmount(WBTC, 100000000n, provider) → "1.0 WBTC"Errori nella stima del gas#
Quando estimateGas fallisce, di solito significa che la transazione revertirebbe. Ma il messaggio di errore è spesso solo "cannot estimate gas" senza indicazione del perché. Usa eth_call per simulare la transazione e ottenere il vero motivo del revert:
import { createPublicClient, http, decodeFunctionResult } from "viem";
async function simulateAndGetError(client: ReturnType<typeof createPublicClient>, tx: object) {
try {
await client.call({
account: tx.from,
to: tx.to,
data: tx.data,
value: tx.value,
});
return null; // Nessun errore — la transazione avrebbe successo
} catch (error) {
// Decodifica il motivo del revert
if (error.data) {
// Stringhe di revert comuni
if (error.data.startsWith("0x08c379a0")) {
// Error(string) — revert standard con messaggio
const reason = decodeAbiParameters(
[{ type: "string" }],
`0x${error.data.slice(10)}`
);
return `Revert: ${reason[0]}`;
}
}
return error.message;
}
}Mettere tutto insieme#
Ecco un componente React completo e minimale che connette un wallet, legge un saldo token e invia un trasferimento. Questo è lo scheletro di ogni dApp:
"use client";
import { useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { injected } from "wagmi/connectors";
import { formatUnits, parseUnits } from "viem";
import { useState } from "react";
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const USDC_ABI = [
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;
export function TokenDashboard() {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
// Leggi il saldo — si esegue solo quando l'indirizzo è definito
const { data: balance, refetch: refetchBalance } = useReadContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address },
});
// Scrivi: trasferisci token
const {
writeContract,
data: txHash,
isPending: isSigning,
error: writeError,
} = useWriteContract();
// Attendi la conferma
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
// Aggiorna il saldo dopo la conferma
if (isSuccess) {
refetchBalance();
}
if (!isConnected) {
return (
<button onClick={() => connect({ connector: injected() })}>
Connetti wallet
</button>
);
}
return (
<div>
<p>Connesso: {address}</p>
<p>
Saldo USDC:{" "}
{balance !== undefined ? formatUnits(balance, 6) : "Caricamento..."}
</p>
<div>
<input
placeholder="Indirizzo destinatario (0x...)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
<input
placeholder="Importo (es. 100)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<button
onClick={() => {
writeContract({
address: USDC_ADDRESS,
abi: USDC_ABI,
functionName: "transfer",
args: [recipient as `0x${string}`, parseUnits(amount, 6)],
});
}}
disabled={isSigning || isConfirming}
>
{isSigning
? "Conferma nel wallet..."
: isConfirming
? "Confermando..."
: "Invia USDC"}
</button>
</div>
{writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
{isSuccess && <p style={{ color: "green" }}>Trasferimento confermato!</p>}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
Visualizza su Etherscan
</a>
)}
<button onClick={() => disconnect()}>Disconnetti</button>
</div>
);
}Dove andare da qui#
Questo post ha coperto i concetti essenziali e il tooling per gli sviluppatori web che si avvicinano a Ethereum. C'è molta più profondità in ogni area:
- Solidity: Se vuoi scrivere contratti, non solo interagire con essi. La documentazione ufficiale e i corsi di Patrick Collins sono i migliori punti di partenza.
- Standard ERC: ERC-20 (token fungibili), ERC-721 (NFT), ERC-1155 (multi-token), ERC-4626 (vault tokenizzati). Ciascuno definisce un'interfaccia standard che tutti i contratti di quella categoria implementano.
- Layer 2: Arbitrum, Optimism, Base, zkSync. Stessa esperienza sviluppatore, costi di gas inferiori, assunzioni di fiducia leggermente diverse. Il tuo codice ethers.js e viem funziona in modo identico — basta cambiare il chain ID e l'URL RPC.
- Account Abstraction (ERC-4337): La prossima evoluzione della UX dei wallet. Wallet smart contract che supportano la sponsorizzazione del gas, il recupero sociale e le transazioni in batch. È la direzione verso cui si sta muovendo il pattern "connetti wallet".
- MEV e ordinamento delle transazioni: Se stai costruendo DeFi, capire il Maximal Extractable Value non è opzionale. La documentazione di Flashbots è la risorsa canonica.
L'ecosistema blockchain si muove velocemente, ma i fondamentali in questo post — account, transazioni, codifica ABI, chiamate RPC, indicizzazione degli eventi — non sono cambiati dal 2015 e non cambieranno presto. Impara bene questi e tutto il resto è solo superficie API.