Extra
Build your own basic version of Petrol Token
fuel/starter/, ready to copy.What you need
- Node.js 22 (the engine that runs JavaScript programs outside the browser).
node -vmust answerv22or newer. - A terminal and a text editor.
- For the last step, a browser with the MetaMask extension (chapter 3).
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
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):
- installing Hardhat 2 and OpenZeppelin 5, compiling: succeeded;
- 6 tests out of 6 passed (result shown above);
- deploying on the local Hardhat network: succeeded, addresses as above;
- reading the real price from the Osservaprezzi API (median of 42 stations around Arezzo) and writing it to the local oracle: succeeded, with read-back and
isFreshtrue; - web page: the Vite build succeeds, and in an automated browser the page reads the price from the oracle, connects to a wallet and buys tokens on the local network (balance updated).
Not verified:
- deploying on Polygon Amoy with your own keys, which the author did not rerun with this code: it needs a test wallet and faucet POL;
- use with a real MetaMask installed in the browser: the automated test used a simulated wallet connected to the local network, and MetaMask's network-switch prompt was not tried;
- selling (
sell) from the web page: it is covered only by the contract tests; - how the Ministry's API and CoinGecko behave over time, which we do not control;
- security: the code is for teaching and has had no independent audit.
What you learned
- A contract that stores a datum and a token that reads it are two separate pieces.
- Tests prove the behaviour before publishing.
- A local network lets you try everything with no real keys and no cost.
- An outside program brings the datum into the oracle; the web page reads it.