Extra

Build your own basic version of Petrol Token

Goal. Start from an empty folder and reach a reduced but working Petrol Token: a contract that stores the petrol price, a token you buy and sell with POL, a program that brings in the real price, and a web page connected to MetaMask. Everything runs on your own computer, with no real keys and no cost.
Teaching demo on a test network: the tokens have no real value. This is a basic version: it has no pause, no fees, no permissions on the token and no security checks. The complete code is also in the project folder fuel/starter/, ready to copy.

What you need

Acronyms used: npm is the Node.js package manager; Hardhat is the environment that compiles, tests and publishes contracts; OpenZeppelin is a collection of ready-made, audited contracts; ERC-20 is the token standard; RPC (Remote Procedure Call) is the address a program uses to talk to a network; API is the interface a service uses to offer its data; POL is the coin of the Polygon network.

1. Empty folder and tools

Open the terminal and paste these commands, one after the other.

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

The fourth command installs Hardhat 2, its testing tools and dotenv (it reads secret settings from a .env file). The fifth installs OpenZeppelin 5.

Now create the Hardhat configuration file.

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] : [],
    },
  },
};

The amoy network is already described, but it stays off until you put a key in the .env file (step 4). The hardhat network (temporary, inside the computer) and localhost (a local node you will start) already exist in Hardhat.

Also create a .gitignore file, which keeps private or heavy files from being shared by mistake:

.gitignore

node_modules
.env
cache
artifacts
deployment.json
web/node_modules
web/dist
web/src/deployment.json

2. The two contracts

Analogy. The oracle is a blackboard in the square where only the appointed person may write the price of the day. The token is the money changer who looks at the blackboard: if the written date is too old, he refuses to trade.

The oracle

Prices have 6 decimals: 1.989 euro per litre is written 1989000, because contracts do not know decimal numbers. Only an address with the UPDATER_ROLE role can write; isFresh tells whether the price is recent enough.

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;
    }
}

The token

It is an OpenZeppelin ERC-20 with two extra functions. buy receives POL and creates tokens; sell destroys tokens and returns POL, always at the oracle price. 1 token (18 decimals) is worth one litre.

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();
    }
}

Compared with the full project, it lacks the emergency pause, the signing permit (ERC20Permit), the adjustable maximum price age, the buy and sell events and the source date.

Compile with:

npx hardhat compile

It must answer Compiled 11 Solidity files successfully (the number may vary slightly: it includes OpenZeppelin files).

3. The tests

A test is a small program that uses the contract and checks the result. These six cover the main cases, including the refusal of an old price.

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

Result we obtained on 2 November 2026 (the times next to each line differ from computer to computer):

  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. Publishing on the local network (and on Amoy)

"Publishing" (deploy) means putting the contracts on a network and getting their addresses. The program below publishes the oracle, gives the wallet owner the writer role, publishes the token and saves the addresses in two files, which steps 5 and 6 read.

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;
});

Start the local network in one terminal and leave it open. Hardhat prints twenty test accounts with their key: they are public and used by everyone, so they are fine only for local tests.

npx hardhat node

In a second terminal, in the same folder:

npx hardhat run scripts/deploy.js --network localhost

Answer we obtained (on a freshly started local network the addresses are always these):

Network: localhost | deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Oracle: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Token:  0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0

And on Polygon Amoy?

For the real test network you need a wallet created only for tests and some free POL (chapter 3). Create a .env file by copying .env.example and paste the private key of the test wallet:

.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

Then:

npx hardhat run scripts/deploy.js --network amoy

Network data: Chain ID 80002, coin POL, RPC https://polygon-amoy-bor-rpc.publicnode.com (already in the configuration). The .env file must never be shared or published, and the key must never belong to a wallet holding real money.

5. The program that brings in the real price

An oracle alone knows nothing: someone has to write the data. This program reads self-service petrol prices from the API of the Ministry's Osservaprezzi app (the service from chapter 2), computes the median (the middle value, which ignores typing errors), reads the POL price from CoinGecko and writes both numbers to the oracle. To change city, edit the two coordinates at the top.

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

Result we obtained on 2 November 2026 (prices change every day):

Petrol: 1.99 EUR/L (median of 42 stations)
POL:    0.080189 EUR
On-chain petrol price: 1.99 EUR/L
Is fresh: true

If CoinGecko does not answer, you can fix the POL price yourself: POL_EUR=0.08 npx hardhat run scripts/update-price.js --network localhost.

The Ministry's service has no stability guarantee: it may change format or limit access. In that case the program stops with an error and writes nothing.

6. The web page with MetaMask

A single page that reads the price from the oracle and lets you connect MetaMask and buy tokens. It uses Vite (a program that prepares web pages) and viem (a library to talk to the blockchain from the browser). The file web/src/deployment.json was written by the deploy program.

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; });

Install and start:

cd web
npm install viem vite
npm run dev

Open the address Vite prints (usually http://localhost:5173/). The price written in step 5 must appear. To really buy with MetaMask, add the local network (name Hardhat, RPC http://127.0.0.1:8545, Chain ID 31337, symbol ETH) and import a test key printed by npx hardhat node; the page asks by itself to switch network. If you published on Amoy, the page uses Amoy.

To use the page with Amoy you need two things: to have run deploy.js --network amoy (which rewrites the addresses) and update-price.js --network amoy.

Try it

After buying 0.1 POL of tokens from the page, write a test of your own that calls sell with half the balance and checks that the POL balance rises by the right amount. Then change MAX_AGE in the token and see which test breaks.

What we really verified / what we did not

Verified on 2 November 2026, on a Linux VPS with Node 22.23, repeating from scratch in a clean folder the commands and the code of this page (the code shown is read from the files that were run):

Not verified:

What you learned

Further reading