Extra

Costruisci la tua versione base di Token Benzina

Obiettivo. Partire da una cartella vuota e arrivare a un Token Benzina ridotto ma funzionante: un contratto che conserva il prezzo della benzina, un token che si compra e si vende in POL, un programma che porta il prezzo vero e una pagina web collegata a MetaMask. Tutto sul tuo computer, senza chiavi vere e senza spendere nulla.
Dimostrazione didattica su rete di prova: i token non hanno alcun valore reale. Questa è una versione di massima: non ha pausa, commissioni, permessi sul token né controlli di sicurezza. Il codice completo si trova anche nella cartella fuel/starter/ del progetto, da copiare.

Cosa ti serve

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

Analogia. L'oracolo è una lavagna in piazza su cui solo l'incaricato può scrivere il prezzo del giorno. Il token è il cambiavalute che guarda la lavagna: se la data scritta è troppo vecchia, si rifiuta di lavorare.

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

Non verificato:

Cosa hai imparato

Per approfondire