Extra
Costruisci la tua versione base di Token Benzina
fuel/starter/ del progetto, da copiare.Cosa ti serve
- Node.js 22 (il motore che esegue programmi JavaScript fuori dal browser). Con
node -vdeve risponderev22o più recente. - Un terminale e un editor di testo.
- Per l'ultimo passo, il browser con l'estensione MetaMask (capitolo 3).
Spiegazione delle sigle usate: npm è il gestore dei pacchetti di Node.js; Hardhat è l'ambiente che compila, prova e pubblica i contratti; OpenZeppelin è una raccolta di contratti già scritti e controllati; ERC-20 è lo standard dei token; RPC (Remote Procedure Call) è l'indirizzo con cui un programma parla a una rete; API è l'interfaccia con cui un servizio offre i suoi dati; POL è la moneta della rete Polygon.
1. Cartella vuota e strumenti
Apri il terminale e incolla questi comandi, uno dopo l'altro.
mkdir fuel-base
cd fuel-base
npm init -y
npm install --save-dev hardhat@2 @nomicfoundation/hardhat-toolbox@5 dotenv
npm install @openzeppelin/contracts@5
mkdir contracts test scripts
mkdir -p web/src
Il quarto comando installa Hardhat 2, i suoi strumenti di prova e dotenv (legge le impostazioni segrete da un file .env). Il quinto installa OpenZeppelin 5.
Ora crea il file di configurazione di Hardhat.
hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config({ quiet: true });
const { AMOY_RPC_URL, DEPLOYER_PRIVATE_KEY } = process.env;
module.exports = {
solidity: "0.8.28",
networks: {
// Polygon Amoy test network (chain id 80002). Only active if you set a key in .env
amoy: {
url: AMOY_RPC_URL || "https://polygon-amoy-bor-rpc.publicnode.com",
chainId: 80002,
accounts: DEPLOYER_PRIVATE_KEY ? [DEPLOYER_PRIVATE_KEY] : [],
},
},
};
La rete amoy è già descritta, ma resta spenta finché non metti una chiave nel file .env (passo 4). Le reti hardhat (temporanea, dentro il computer) e localhost (un nodo locale che avvierai tu) esistono già in Hardhat.
Crea anche un file .gitignore, che impedisce di condividere per sbaglio file riservati o pesanti:
.gitignore
node_modules
.env
cache
artifacts
deployment.json
web/node_modules
web/dist
web/src/deployment.json
2. I due contratti
L'oracolo
I prezzi hanno 6 decimali: 1,989 euro al litro si scrive 1989000, perché i contratti non conoscono i numeri con la virgola. Solo chi ha il ruolo UPDATER_ROLE può scrivere; isFresh dice se il prezzo è abbastanza recente.
contracts/SimpleFuelOracle.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
/// @title SimpleFuelOracle
/// @notice Stores the petrol price (EUR per litre) and the POL price (EUR).
/// Prices use 6 decimals: 1.989 EUR is stored as 1989000.
/// Only an address with UPDATER_ROLE can write them.
contract SimpleFuelOracle is AccessControl {
bytes32 public constant UPDATER_ROLE = keccak256("UPDATER_ROLE");
error ZeroPrice();
uint256 private _fuelEurPerLitre;
uint256 private _polEur;
uint64 private _updatedAt;
event PricesUpdated(uint256 fuelEurPerLitre, uint256 polEur, uint64 updatedAt);
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
function setPrices(uint256 fuelEurPerLitre, uint256 polEur) external onlyRole(UPDATER_ROLE) {
if (fuelEurPerLitre == 0 || polEur == 0) revert ZeroPrice();
_fuelEurPerLitre = fuelEurPerLitre;
_polEur = polEur;
_updatedAt = uint64(block.timestamp);
emit PricesUpdated(fuelEurPerLitre, polEur, _updatedAt);
}
/// @return price EUR per litre, 6 decimals
function fuelPrice() external view returns (uint256 price, uint64 updatedAt) {
return (_fuelEurPerLitre, _updatedAt);
}
/// @return price EUR per 1 POL, 6 decimals
function polPrice() external view returns (uint256 price, uint64 updatedAt) {
return (_polEur, _updatedAt);
}
/// @notice True if prices were set and are not older than maxAgeSeconds.
function isFresh(uint64 maxAgeSeconds) external view returns (bool) {
return _updatedAt != 0 && block.timestamp <= uint256(_updatedAt) + maxAgeSeconds;
}
}
Il token
È un ERC-20 di OpenZeppelin con due funzioni in più. buy riceve POL e crea token; sell distrugge token e restituisce POL, sempre al prezzo dell'oracolo. 1 token (18 decimali) vale un litro.
contracts/SimpleFuelToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {SimpleFuelOracle} from "./SimpleFuelOracle.sol";
/// @title SimpleFuelToken
/// @notice 1 FUEL (18 decimals) is worth one litre of petrol. Buy with POL, sell back for POL
/// at the oracle price. No fees. Teaching demo for a test network.
contract SimpleFuelToken is ERC20 {
SimpleFuelOracle public immutable oracle;
uint64 public constant MAX_AGE = 3 days;
error StalePrice();
error ZeroAmount();
error InsufficientReserve();
error TransferFailed();
constructor(address oracle_) ERC20("Simple FuelToken", "FUEL") {
oracle = SimpleFuelOracle(oracle_);
}
/// @notice Send POL, receive FUEL: fuel = pol * polEur / fuelEurPerLitre.
function buy() external payable {
if (msg.value == 0) revert ZeroAmount();
(uint256 f, uint256 p) = _prices();
uint256 fuelOut = (msg.value * p) / f;
if (fuelOut == 0) revert ZeroAmount();
_mint(msg.sender, fuelOut);
}
/// @notice Burn FUEL, receive POL: pol = fuel * fuelEurPerLitre / polEur.
function sell(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
(uint256 f, uint256 p) = _prices();
uint256 polOut = (amount * f) / p;
if (polOut == 0) revert ZeroAmount();
if (address(this).balance < polOut) revert InsufficientReserve();
_burn(msg.sender, amount);
(bool ok, ) = msg.sender.call{value: polOut}("");
if (!ok) revert TransferFailed();
}
/// @notice How much FUEL you would get for a given amount of POL (in wei).
function quoteBuy(uint256 polWei) external view returns (uint256) {
(uint256 f, uint256 p) = _prices();
return (polWei * p) / f;
}
function _prices() internal view returns (uint256 f, uint256 p) {
if (!oracle.isFresh(MAX_AGE)) revert StalePrice();
(f, ) = oracle.fuelPrice();
(p, ) = oracle.polPrice();
}
}
Rispetto al progetto completo mancano la pausa d'emergenza, il permesso di firma (ERC20Permit), la regolazione dell'età massima del prezzo, gli eventi di acquisto e vendita e la data della fonte.
Compila con:
npx hardhat compile
Deve rispondere Compiled 11 Solidity files successfully (il numero può variare un poco: comprende anche i file di OpenZeppelin).
3. I test
Un test è un piccolo programma che usa il contratto e controlla il risultato. Questi sei provano i casi principali, compreso il rifiuto di un prezzo vecchio.
test/fuel.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");
const FUEL = 1_989_000n; // 1.989 EUR per litre (6 decimals)
const POL = 400_000n; // 0.40 EUR per POL (6 decimals)
describe("SimpleFuelOracle and SimpleFuelToken", function () {
async function setup() {
const [admin, alice, stranger] = await ethers.getSigners();
const oracle = await ethers.deployContract("SimpleFuelOracle", [admin.address]);
await oracle.grantRole(await oracle.UPDATER_ROLE(), admin.address);
const token = await ethers.deployContract("SimpleFuelToken", [await oracle.getAddress()]);
return { admin, alice, stranger, oracle, token };
}
it("is not fresh before the first update", async function () {
const { oracle } = await setup();
expect(await oracle.isFresh(3 * 86400)).to.equal(false);
});
it("stores prices written by the updater", async function () {
const { oracle } = await setup();
await oracle.setPrices(FUEL, POL);
const [price] = await oracle.fuelPrice();
expect(price).to.equal(FUEL);
expect(await oracle.isFresh(3 * 86400)).to.equal(true);
});
it("rejects a stranger and a zero price", async function () {
const { oracle, stranger } = await setup();
await expect(oracle.connect(stranger).setPrices(FUEL, POL)).to.be.reverted;
await expect(oracle.setPrices(0, POL)).to.be.revertedWithCustomError(oracle, "ZeroPrice");
});
it("buys FUEL at the oracle price", async function () {
const { oracle, token, alice } = await setup();
await oracle.setPrices(FUEL, POL);
// 1 POL = 0.40 EUR, 1 L = 1.989 EUR => 0.2011... L
await token.connect(alice).buy({ value: ethers.parseEther("1") });
const expected = (ethers.parseEther("1") * POL) / FUEL;
expect(await token.balanceOf(alice.address)).to.equal(expected);
});
it("sells FUEL back for POL", async function () {
const { oracle, token, alice } = await setup();
await oracle.setPrices(FUEL, POL);
await token.connect(alice).buy({ value: ethers.parseEther("1") });
const fuel = await token.balanceOf(alice.address);
await expect(token.connect(alice).sell(fuel)).to.changeEtherBalance(
alice,
(fuel * FUEL) / POL
);
expect(await token.balanceOf(alice.address)).to.equal(0n);
});
it("refuses to trade when the price is old", async function () {
const { oracle, token, alice } = await setup();
await oracle.setPrices(FUEL, POL);
await time.increase(4 * 86400);
await expect(
token.connect(alice).buy({ value: ethers.parseEther("1") })
).to.be.revertedWithCustomError(token, "StalePrice");
});
});
npx hardhat test
Risultato ottenuto da noi il 2 novembre 2026 (i tempi accanto a ogni riga cambiano da un computer all'altro):
SimpleFuelOracle and SimpleFuelToken
✔ is not fresh before the first update
✔ stores prices written by the updater
✔ rejects a stranger and a zero price
✔ buys FUEL at the oracle price
✔ sells FUEL back for POL
✔ refuses to trade when the price is old
6 passing
4. Pubblicare sulla rete locale (e su Amoy)
«Pubblicare» (deploy) significa mettere i contratti su una rete e ottenere i loro indirizzi. Il programma sotto pubblica l'oracolo, dà il ruolo di scrittore al proprietario del portafoglio, pubblica il token e salva gli indirizzi in due file, che il passo 5 e il passo 6 leggono.
scripts/deploy.js
const fs = require("fs");
const path = require("path");
const { ethers, network } = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Network:", network.name, "| deployer:", deployer.address);
const oracle = await ethers.deployContract("SimpleFuelOracle", [deployer.address]);
await oracle.waitForDeployment();
await (await oracle.grantRole(await oracle.UPDATER_ROLE(), deployer.address)).wait();
const token = await ethers.deployContract("SimpleFuelToken", [await oracle.getAddress()]);
await token.waitForDeployment();
const info = {
chainId: Number((await ethers.provider.getNetwork()).chainId),
oracle: await oracle.getAddress(),
token: await token.getAddress(),
};
console.log("Oracle:", info.oracle);
console.log("Token: ", info.token);
// The scripts and the web page read the addresses from these files.
fs.writeFileSync("deployment.json", JSON.stringify(info, null, 2));
fs.writeFileSync(path.join("web", "src", "deployment.json"), JSON.stringify(info, null, 2));
}
main().catch((e) => {
console.error(e);
process.exitCode = 1;
});
Avvia la rete locale in un terminale e lasciala aperta. Hardhat stampa venti conti di prova con la loro chiave: sono pubblici e usati da tutti, quindi vanno bene solo per le prove locali.
npx hardhat node
In un secondo terminale, nella stessa cartella:
npx hardhat run scripts/deploy.js --network localhost
Risposta ottenuta (su una rete locale appena avviata gli indirizzi sono sempre questi):
Network: localhost | deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Oracle: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Token: 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
E su Polygon Amoy?
Per la rete di prova vera servono un portafoglio creato solo per le prove e un po' di POL gratuito (capitolo 3). Crea un file .env copiando .env.example e incolla la chiave privata del portafoglio di prova:
.env.example
# Copy to .env and fill in. Never share this file. Use a wallet created ONLY for tests.
AMOY_RPC_URL=https://polygon-amoy-bor-rpc.publicnode.com
DEPLOYER_PRIVATE_KEY=
# Optional: fixed POL price in EUR, used instead of CoinGecko
# POL_EUR=0.10
Poi:
npx hardhat run scripts/deploy.js --network amoy
I dati della rete sono: Chain ID 80002, moneta POL, RPC https://polygon-amoy-bor-rpc.publicnode.com (già scritto nella configurazione). Il file .env non va mai condiviso né pubblicato, e la chiave non deve mai essere quella di un portafoglio con denaro vero.
5. Il programma che porta il prezzo vero
Un oracolo da solo non sa nulla: qualcuno deve scrivere il dato. Questo programma legge i prezzi della benzina self-service dall'API dell'app Osservaprezzi del Ministero (il servizio del capitolo 2), calcola la mediana (il valore centrale, che ignora i refusi), legge il prezzo del POL da CoinGecko e scrive i due numeri sull'oracolo. Per cambiare città modifica le due coordinate in alto.
scripts/update-price.js
// Reads the petrol price from the Italian "Osservaprezzi" service and the POL price
// from CoinGecko, then writes both to the oracle.
const { ethers } = require("hardhat");
const deployment = require("../deployment.json");
const ZONE_URL = "https://carburanti.mise.gov.it/ospzApi/search/zone";
const COINGECKO_URL =
"https://api.coingecko.com/api/v3/simple/price?ids=polygon-ecosystem-token&vs_currencies=eur";
// Arezzo (Italy). Change these two numbers to use another city.
const LAT = 43.4636;
const LNG = 11.8748;
function median(values) {
const s = [...values].sort((a, b) => a - b);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
async function fetchFuelPrice() {
const res = await fetch(ZONE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
points: [{ lat: LAT, lng: LNG }],
radius: 30,
fuelType: "1-x",
priceOrder: "asc",
}),
signal: AbortSignal.timeout(30000),
});
if (!res.ok) throw new Error("Osservaprezzi HTTP " + res.status);
const data = await res.json();
const prices = [];
for (const station of data.results ?? []) {
for (const f of station.fuels ?? []) {
// fuelId 1 = unleaded petrol, isSelf = self-service price
if (f.fuelId === 1 && f.isSelf === true && Number(f.price) > 0) prices.push(Number(f.price));
}
}
if (prices.length === 0) throw new Error("No petrol prices found");
return { eur: median(prices), stations: prices.length };
}
async function fetchPolPrice() {
if (process.env.POL_EUR) return Number(process.env.POL_EUR); // manual override
const res = await fetch(COINGECKO_URL, { signal: AbortSignal.timeout(20000) });
if (!res.ok) throw new Error("CoinGecko HTTP " + res.status);
const eur = (await res.json())["polygon-ecosystem-token"]?.eur;
if (!(eur > 0)) throw new Error("CoinGecko: missing price");
return eur;
}
async function main() {
const fuel = await fetchFuelPrice();
const pol = await fetchPolPrice();
console.log(`Petrol: ${fuel.eur} EUR/L (median of ${fuel.stations} stations)`);
console.log(`POL: ${pol} EUR`);
const toUnits = (x) => BigInt(Math.round(x * 1e6)); // 6 decimals
const oracle = await ethers.getContractAt("SimpleFuelOracle", deployment.oracle);
const tx = await oracle.setPrices(toUnits(fuel.eur), toUnits(pol));
await tx.wait();
const [stored] = await oracle.fuelPrice();
console.log("On-chain petrol price:", Number(stored) / 1e6, "EUR/L");
console.log("Is fresh:", await oracle.isFresh(3 * 86400));
}
main().catch((e) => {
console.error(e);
process.exitCode = 1;
});
npx hardhat run scripts/update-price.js --network localhost
Risultato ottenuto da noi il 2 novembre 2026 (i prezzi cambiano ogni giorno):
Petrol: 1.99 EUR/L (median of 42 stations)
POL: 0.080189 EUR
On-chain petrol price: 1.99 EUR/L
Is fresh: true
Se CoinGecko non risponde, puoi fissare tu il prezzo del POL: POL_EUR=0.08 npx hardhat run scripts/update-price.js --network localhost.
Il servizio del Ministero non ha un contratto di stabilità: può cambiare formato o limitare l'accesso. In quel caso il programma si ferma con un errore e non scrive nulla.
6. La pagina web con MetaMask
Una sola pagina che legge il prezzo dall'oracolo e permette di collegare MetaMask e comprare token. Usa Vite (un programma che prepara le pagine web) e viem (una libreria per parlare con la blockchain dal browser). Il file web/src/deployment.json è stato scritto dal programma di pubblicazione.
web/package.json
{
"name": "fuel-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"viem": "^2.57.14",
"vite": "^8.3.5"
}
}
web/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My FuelToken</title>
<style>
body { font: 17px/1.6 system-ui, sans-serif; max-width: 34rem; margin: 2rem auto; padding: 0 1rem; background: #fff; color: #1c2330; }
button { font: inherit; padding: .5rem 1rem; margin: .25rem .25rem .25rem 0; }
.card { border: 1px solid #dde2ec; border-radius: 8px; padding: 1rem; margin: 1rem 0; }
#status { color: #5b6779; }
</style>
</head>
<body>
<h1>My FuelToken</h1>
<p>Test network only. The tokens have no value.</p>
<div class="card">
<p>Petrol price on the oracle: <strong id="price">...</strong></p>
<p id="fresh"></p>
</div>
<div class="card">
<button id="connect">Connect MetaMask</button>
<p id="account"></p>
<p>FUEL balance: <strong id="balance">-</strong></p>
<button id="buy" disabled>Buy FUEL with 0.1 POL</button>
</div>
<p id="status"></p>
<script type="module" src="/src/main.js"></script>
</body>
</html>
web/src/main.js
import { createPublicClient, createWalletClient, custom, http, parseEther, formatUnits } from "viem";
import { hardhat, polygonAmoy } from "viem/chains";
import deployment from "./deployment.json"; // written by scripts/deploy.js
const chain = deployment.chainId === 80002 ? polygonAmoy : hardhat;
const abi = [
{ type: "function", name: "fuelPrice", stateMutability: "view", inputs: [], outputs: [{ name: "price", type: "uint256" }, { name: "updatedAt", type: "uint64" }] },
{ type: "function", name: "isFresh", stateMutability: "view", inputs: [{ name: "maxAgeSeconds", type: "uint64" }], outputs: [{ type: "bool" }] },
{ type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ type: "uint256" }] },
{ type: "function", name: "buy", stateMutability: "payable", inputs: [], outputs: [] },
];
const $ = (id) => document.getElementById(id);
const publicClient = createPublicClient({ chain, transport: http() });
let walletClient;
let account;
async function readPrice() {
const [price, updatedAt] = await publicClient.readContract({
address: deployment.oracle, abi, functionName: "fuelPrice",
});
const fresh = await publicClient.readContract({
address: deployment.oracle, abi, functionName: "isFresh", args: [3n * 86400n],
});
$("price").textContent = formatUnits(price, 6) + " EUR per litre";
$("fresh").textContent = fresh
? "Fresh. Updated " + new Date(Number(updatedAt) * 1000).toLocaleString()
: "The price is old: buying is paused.";
}
async function readBalance() {
if (!account) return;
const b = await publicClient.readContract({
address: deployment.token, abi, functionName: "balanceOf", args: [account],
});
$("balance").textContent = formatUnits(b, 18) + " FUEL";
}
$("connect").onclick = async () => {
if (!window.ethereum) { $("status").textContent = "MetaMask not found."; return; }
walletClient = createWalletClient({ chain, transport: custom(window.ethereum) });
[account] = await walletClient.requestAddresses();
try { await walletClient.switchChain({ id: chain.id }); }
catch { await walletClient.addChain({ chain }); }
$("account").textContent = "Connected: " + account;
$("buy").disabled = false;
await readBalance();
};
$("buy").onclick = async () => {
$("status").textContent = "Waiting for MetaMask...";
try {
const hash = await walletClient.writeContract({
address: deployment.token, abi, functionName: "buy",
value: parseEther("0.1"), account,
});
await publicClient.waitForTransactionReceipt({ hash });
$("status").textContent = "Done: " + hash;
await readBalance();
} catch (e) {
$("status").textContent = "Cancelled or failed: " + (e.shortMessage || e.message);
}
};
readPrice().catch((e) => { $("price").textContent = "cannot read: " + e.message; });
Installa e avvia:
cd web
npm install viem vite
npm run dev
Apri l'indirizzo che Vite stampa (di solito http://localhost:5173/). Deve comparire il prezzo scritto al passo 5. Per comprare davvero con MetaMask, aggiungi la rete locale (nome Hardhat, RPC http://127.0.0.1:8545, Chain ID 31337, simbolo ETH) e importa una chiave di prova stampata da npx hardhat node; la pagina chiede da sola di cambiare rete. Se hai pubblicato su Amoy, la pagina usa Amoy.
Per usare la pagina con Amoy servono due cose: aver eseguito deploy.js --network amoy (che riscrive gli indirizzi) e aver lanciato update-price.js --network amoy.
Prova tu
Dopo aver comprato 0,1 POL di token dalla pagina, scrivi un tuo test che chiama sell con metà del saldo e controlla che il saldo di POL salga della cifra giusta. Poi cambia MAX_AGE nel token e guarda quale test si rompe.
Cosa abbiamo verificato davvero / cosa no
Verificato il 2 novembre 2026, su un VPS Linux con Node 22.23, ripetendo da zero in una cartella pulita i comandi e il codice di questa pagina (il codice mostrato è letto dai file eseguiti):
- installazione di Hardhat 2 e OpenZeppelin 5, compilazione: riuscite;
- 6 test su 6 superati (risultato riportato sopra);
- pubblicazione sulla rete locale Hardhat: riuscita, indirizzi come sopra;
- lettura del prezzo vero dall'API Osservaprezzi (mediana di 42 distributori intorno ad Arezzo) e scrittura sull'oracolo locale: riuscite, con lettura di ritorno e
isFreshvero; - pagina web: la compilazione con Vite riesce, e in un browser automatico la pagina legge il prezzo dall'oracolo, si collega a un portafoglio e acquista token sulla rete locale (saldo aggiornato).
Non verificato:
- la pubblicazione su Polygon Amoy con chiavi proprie, che non è stata rieseguita da chi scrive con questo codice: richiede un portafoglio di prova e POL del faucet;
- l'uso con MetaMask vero (installato nel browser): la prova automatica ha usato un portafoglio simulato collegato alla rete locale, e la richiesta di cambio rete di MetaMask non è stata provata;
- la vendita (
sell) dalla pagina web: è coperta solo dai test del contratto; - il funzionamento nel tempo dell'API del Ministero e di CoinGecko, che non dipendono da noi;
- la sicurezza: il codice è didattico e non ha avuto alcun controllo indipendente (audit).
Cosa hai imparato
- Un contratto che conserva un dato e un token che lo legge sono due pezzi separati.
- I test dimostrano il comportamento prima di pubblicare.
- Una rete locale permette di provare tutto senza chiavi vere e senza costi.
- Un programma esterno porta il dato nell'oracolo; la pagina web lo legge.