Olive oil traceability, step by step

Build your own basic version of Pietre di Vita

Goal: build, step by step and starting from an empty folder, a basic working version of Pietre di Vita: a contract, its tests, a small server that records the steps and creates the QR code, and a public page that says green or red. The Pietre di Vita mill is fictional and all data is made up.

This guide was really executed from start to finish in a clean folder, copying and pasting exactly the commands and code you see here. The outputs are the real ones from that run. At the end you find what was verified and what was not.

0. What you need

A computer with a terminal and Node.js 22 (the program that runs JavaScript, from nodejs.org). Check the version:

node -v
v22.23.1

You will use three terminal windows: one for the local test network, one for the server, one for commands. New words: Hardhat is the environment that compiles and tests contracts; OpenZeppelin is a collection of ready-made, audited contracts; ERC-721 is the standard for unique tokens; SHA-256 is the function that computes a file's fingerprint; RPC (Remote Procedure Call) is the address a program uses to talk to the chain; QR (Quick Response) code is the square pattern you scan with a phone.

1. The folder and the libraries

Create the folder and initialise the Node project:

mkdir pietre-base
cd pietre-base
npm init -y
Wrote to /home/you/pietre-base/package.json:

{
  "name": "pietre-base",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

Install Hardhat 2 with its toolbox, then OpenZeppelin 5 and the server libraries (express for the web server, qrcode for the QR code, dotenv to read the .env file):

npm install --save-dev hardhat@2 @nomicfoundation/hardhat-toolbox@5
npm install @openzeppelin/contracts@5 express@4 qrcode dotenv

The command may print security warnings about Hardhat's dependencies: they do not block anything. Now a .gitignore file (so secret or heavy files never end up in a git archive) and the subfolders:

printf 'node_modules\n.env\ncache\nartifacts\n' > .gitignore
mkdir contracts test scripts server

Create the Hardhat configuration file. The Amoy network is added only if you fill in the .env file:

hardhat.config.js

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config({ quiet: true });

// Local node address. If port 8545 is busy on your computer, set RPC_URL in .env
const networks = {
  localhost: { url: process.env.RPC_URL || "http://127.0.0.1:8545" },
};
// The Amoy network is added only if you filled in .env
if (process.env.AMOY_RPC_URL && process.env.DEPLOYER_PRIVATE_KEY) {
  networks.amoy = {
    url: process.env.AMOY_RPC_URL,
    chainId: 80002,
    accounts: [process.env.DEPLOYER_PRIVATE_KEY],
  };
}

module.exports = {
  solidity: {
    version: "0.8.24",
    settings: { optimizer: { enabled: true, runs: 200 }, evmVersion: "cancun" },
  },
  networks,
};

2. The reduced contract

A smart contract is a program that lives on the chain: its rules are inside and apply to everybody. This contract does six things: creates a lot (an ERC-721 token), assigns roles (with OpenZeppelin's AccessControl: who harvests, who analyses, who bottles), records steps with the document's fingerprint, bottles with a range of bottle numbers, recalls the lot and answers the public question "is this bottle valid?". Function names are in Italian as in the full version; comments and messages are in English.

contracts/MiniOlio.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

/// @title MiniOlio - a minimal olive oil traceability contract (teaching example, invented mill)
/// Each lot is an ERC-721 token. Every phase is recorded with the hash of a document.
contract MiniOlio is ERC721, AccessControl {
    // The four phases, in the order they must be recorded
    enum Fase { Raccolta, Molitura, Analisi, Imbottigliamento }

    struct Passo {
        Fase fase;
        address attore;
        uint64 quando;
        bytes32 docHash; // fingerprint (SHA-256) of the document kept off-chain
        string descrizione;
    }

    struct Lotto {
        string codice;
        uint32 primaBottiglia; // first bottle number (0 until bottled)
        uint32 ultimaBottiglia; // last bottle number (0 until bottled)
        bool richiamato;
        string motivoRichiamo;
    }

    bytes32 public constant PRODUTTORE_ROLE = keccak256("PRODUTTORE_ROLE");
    bytes32 public constant LABORATORIO_ROLE = keccak256("LABORATORIO_ROLE");
    bytes32 public constant IMBOTTIGLIATORE_ROLE = keccak256("IMBOTTIGLIATORE_ROLE");

    uint256 private _ultimoId;
    mapping(uint256 => Lotto) private _lotti;
    mapping(uint256 => Passo[]) private _passi;
    mapping(bytes32 => uint256) private _idDaCodice; // keccak256(code) => token id

    event LottoCreato(uint256 indexed id, string codice);
    event PassoAggiunto(uint256 indexed id, Fase fase, address indexed attore, bytes32 docHash);
    event Imbottigliato(uint256 indexed id, uint32 primaBottiglia, uint32 ultimaBottiglia);
    event Richiamato(uint256 indexed id, string motivo);

    constructor() ERC721("Pietre di Vita - lotto", "PDV") {
        // For this demo the deployer gets every role. In real life each role goes to a different person.
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(PRODUTTORE_ROLE, msg.sender);
        _grantRole(LABORATORIO_ROLE, msg.sender);
        _grantRole(IMBOTTIGLIATORE_ROLE, msg.sender);
    }

    /// Creates a lot: mints one token to the producer who calls.
    function creaLotto(string calldata codice) external onlyRole(PRODUTTORE_ROLE) returns (uint256 id) {
        require(bytes(codice).length > 0, "empty code");
        require(_idDaCodice[keccak256(bytes(codice))] == 0, "code already used");
        id = ++_ultimoId;
        _lotti[id].codice = codice;
        _idDaCodice[keccak256(bytes(codice))] = id;
        _safeMint(msg.sender, id);
        emit LottoCreato(id, codice);
    }

    /// Records Raccolta, Molitura or Analisi. Phases must follow the order, no skipping.
    function aggiungiPasso(uint256 id, Fase fase, string calldata descrizione, bytes32 docHash) external {
        require(fase != Fase.Imbottigliamento, "use imbottiglia");
        _controllaRuolo(fase);
        _controllaOrdine(id, fase);
        _registra(id, fase, descrizione, docHash);
    }

    /// Records the bottling and the range of bottle numbers (first..last, both included).
    function imbottiglia(uint256 id, uint32 prima, uint32 ultima, bytes32 docHash)
        external
        onlyRole(IMBOTTIGLIATORE_ROLE)
    {
        require(prima >= 1 && ultima >= prima, "bad range");
        _controllaOrdine(id, Fase.Imbottigliamento);
        _lotti[id].primaBottiglia = prima;
        _lotti[id].ultimaBottiglia = ultima;
        _registra(id, Fase.Imbottigliamento, "Bottling", docHash);
        emit Imbottigliato(id, prima, ultima);
    }

    /// Marks the lot as recalled. Anyone who then checks a bottle of the lot sees it.
    function richiama(uint256 id, string calldata motivo) external onlyRole(PRODUTTORE_ROLE) {
        _esiste(id);
        require(!_lotti[id].richiamato, "already recalled");
        _lotti[id].richiamato = true;
        _lotti[id].motivoRichiamo = motivo;
        emit Richiamato(id, motivo);
    }

    // ---------- reads (free, no wallet needed) ----------

    function lotto(uint256 id) external view returns (Lotto memory) {
        _esiste(id);
        return _lotti[id];
    }

    function passi(uint256 id) external view returns (Passo[] memory) {
        _esiste(id);
        return _passi[id];
    }

    function idDaCodice(string calldata codice) external view returns (uint256) {
        return _idDaCodice[keccak256(bytes(codice))];
    }

    /// Public check of one bottle. valida is true only if the lot exists, is fully bottled,
    /// the bottle number is inside the range and the lot is not recalled.
    function verificaBottiglia(string calldata codice, uint32 n)
        external
        view
        returns (bool valida, bool richiamato, uint256 id)
    {
        id = _idDaCodice[keccak256(bytes(codice))];
        if (id == 0) return (false, false, 0);
        Lotto storage l = _lotti[id];
        bool inIntervallo = l.primaBottiglia != 0 && n >= l.primaBottiglia && n <= l.ultimaBottiglia;
        richiamato = l.richiamato;
        valida = inIntervallo && !richiamato;
    }

    // ---------- internals ----------

    function _esiste(uint256 id) private view {
        require(id != 0 && id <= _ultimoId, "lot not found");
    }

    function _controllaRuolo(Fase fase) private view {
        if (fase == Fase.Analisi) _checkRole(LABORATORIO_ROLE);
        else _checkRole(PRODUTTORE_ROLE);
    }

    function _controllaOrdine(uint256 id, Fase fase) private view {
        _esiste(id);
        require(uint8(fase) == _passi[id].length, "phase out of order");
    }

    function _registra(uint256 id, Fase fase, string memory descrizione, bytes32 docHash) private {
        _passi[id].push(Passo(fase, msg.sender, uint64(block.timestamp), docHash, descrizione));
        emit PassoAggiunto(id, fase, msg.sender, docHash);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, AccessControl)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

Three things to notice. Phases must follow the order (phase out of order otherwise). A step is accepted only from someone with the right role. Only the document's fingerprint (docHash) goes on the chain: the document stays outside.

Compile:

npx hardhat compile
Compiled 18 Solidity files successfully (evm target: cancun).

3. The tests

A test is a small program that tries the contract and says whether it behaves as expected. These run on a fake chain living only in memory:

test/MiniOlio.test.js

const { expect } = require("chai");
const { ethers } = require("hardhat");

const Fase = { Raccolta: 0, Molitura: 1, Analisi: 2, Imbottigliamento: 3 };
const hash = (text) => ethers.keccak256(ethers.toUtf8Bytes(text)); // any 32-byte value works in tests

describe("MiniOlio", function () {
  let olio, owner, lab, outsider;

  beforeEach(async function () {
    [owner, lab, outsider] = await ethers.getSigners();
    olio = await ethers.deployContract("MiniOlio");
    // Give the lab account only the laboratory role
    await olio.grantRole(await olio.LABORATORIO_ROLE(), lab.address);
    await olio.creaLotto("TEST-001");
  });

  async function fillAllPhases() {
    await olio.aggiungiPasso(1, Fase.Raccolta, "Harvest", hash("a"));
    await olio.aggiungiPasso(1, Fase.Molitura, "Milling", hash("b"));
    await olio.connect(lab).aggiungiPasso(1, Fase.Analisi, "Analysis", hash("c"));
    await olio.imbottiglia(1, 1, 500, hash("d"));
  }

  it("creates a lot as an ERC-721 token owned by the producer", async function () {
    expect(await olio.ownerOf(1)).to.equal(owner.address);
    expect(await olio.idDaCodice("TEST-001")).to.equal(1n);
  });

  it("rejects a duplicate lot code", async function () {
    await expect(olio.creaLotto("TEST-001")).to.be.revertedWith("code already used");
  });

  it("records the phases in order", async function () {
    await fillAllPhases();
    const passi = await olio.passi(1);
    expect(passi.length).to.equal(4);
    expect(passi[2].attore).to.equal(lab.address);
  });

  it("refuses a phase out of order", async function () {
    await expect(olio.connect(lab).aggiungiPasso(1, Fase.Analisi, "Too early", hash("x"))).to.be.revertedWith(
      "phase out of order"
    );
  });

  it("refuses someone without the right role", async function () {
    await expect(olio.connect(outsider).aggiungiPasso(1, Fase.Raccolta, "Intruder", hash("x"))).to.be.revertedWithCustomError(
      olio,
      "AccessControlUnauthorizedAccount"
    );
  });

  it("keeps the lab out of the producer's phases", async function () {
    await expect(olio.connect(lab).aggiungiPasso(1, Fase.Raccolta, "Wrong role", hash("x"))).to.be.revertedWithCustomError(
      olio,
      "AccessControlUnauthorizedAccount"
    );
  });

  it("validates only bottles inside the bottled range", async function () {
    await fillAllPhases();
    expect((await olio.verificaBottiglia("TEST-001", 1)).valida).to.equal(true);
    expect((await olio.verificaBottiglia("TEST-001", 500)).valida).to.equal(true);
    expect((await olio.verificaBottiglia("TEST-001", 501)).valida).to.equal(false);
    expect((await olio.verificaBottiglia("TEST-001", 0)).valida).to.equal(false);
  });

  it("does not validate bottles of a lot that is not bottled yet", async function () {
    expect((await olio.verificaBottiglia("TEST-001", 1)).valida).to.equal(false);
  });

  it("does not know an unknown lot", async function () {
    const r = await olio.verificaBottiglia("NOPE", 1);
    expect(r.valida).to.equal(false);
    expect(r.id).to.equal(0n);
  });

  it("marks every bottle of a recalled lot as not valid", async function () {
    await fillAllPhases();
    await olio.richiama(1, "Test recall");
    const r = await olio.verificaBottiglia("TEST-001", 10);
    expect(r.valida).to.equal(false);
    expect(r.richiamato).to.equal(true);
  });

  it("lets only the producer recall", async function () {
    await expect(olio.connect(lab).richiama(1, "No")).to.be.revertedWithCustomError(olio, "AccessControlUnauthorizedAccount");
  });
});
npx hardhat test
  MiniOlio
    ✔ creates a lot as an ERC-721 token owned by the producer (93ms)
    ✔ rejects a duplicate lot code (69ms)
    ✔ records the phases in order (104ms)
    ✔ refuses a phase out of order (46ms)
    ✔ refuses someone without the right role
    ✔ keeps the lab out of the producer's phases
    ✔ validates only bottles inside the bottled range (137ms)
    ✔ does not validate bottles of a lot that is not bottled yet
    ✔ does not know an unknown lot
    ✔ marks every bottle of a recalled lot as not valid
    ✔ lets only the producer recall


  11 passing (6s)

4. Local deploy

Deploying means publishing the contract on a chain. First the script, which saves the contract address to a file (the server reads it from there):

scripts/deploy.js

const hre = require("hardhat");
const fs = require("fs");

async function main() {
  const olio = await hre.ethers.deployContract("MiniOlio");
  await olio.waitForDeployment();
  const address = await olio.getAddress();
  console.log("MiniOlio deployed to:", address, "on network:", hre.network.name);

  // Save the address: the backend reads it from here
  fs.mkdirSync("deployments", { recursive: true });
  fs.writeFileSync(`deployments/${hre.network.name}.json`, JSON.stringify({ address }, null, 2) + "\n");
}

main().catch((e) => {
  console.error(e);
  process.exitCode = 1;
});

In window 1 start the local test network. It keeps running and prints 20 test accounts with keys known to everybody, good only for experiments (never use them on a real network). If port 8545 is already busy on your computer, use --port 8599 and put RPC_URL=http://127.0.0.1:8599 in the .env file of step 5:

npx hardhat node

In window 2 (still in the pietre-base folder) publish the contract:

npx hardhat run scripts/deploy.js --network localhost
MiniOlio deployed to: 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0 on network: localhost

The address you see will differ from mine, and that is fine.

4b. On the Polygon Amoy test network

Polygon Amoy is a public test network: its coins (test POL) are worthless and come free from "faucets" (test-coin dispensers, for example Chainlink's). Its Chain ID (network identification number) is 80002. You can check that the public RPC address answers and really is Amoy (0x13882 in hexadecimal is 80002):

curl -s -X POST https://polygon-amoy-bor-rpc.publicnode.com -H "content-type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}'
echo
{"jsonrpc":"2.0","result":"0x13882","id":1}

To publish on Amoy you need a new account created only for tests (for example with MetaMask, never your real one), some test POL and these lines in the .env file. Never write a real key, never paste one in a chat and never put .env in git (our .gitignore already excludes it):

AMOY_RPC_URL=https://polygon-amoy-bor-rpc.publicnode.com
DEPLOYER_PRIVATE_KEY=private key of a NEW test-only wallet
PRIVATE_KEY=the same key, read by the server
RPC_URL=https://polygon-amoy-bor-rpc.publicnode.com
NETWORK=amoy

Then npx hardhat run scripts/deploy.js --network amoy. This step was not executed on purpose (it would need an account with funds): it is the same procedure as the local deploy on another network; see the final section.

5. The mini server

The server (Node with Express) does three things: computes a file's SHA-256 fingerprint and writes it on the chain together with the step; creates a bottle's QR code; shows the public page by reading the contract. First the connection to the chain:

server/chain.js

const { ethers } = require("ethers");
const fs = require("fs");
const path = require("path");

const root = path.join(__dirname, "..");
const network = process.env.NETWORK || "localhost";
const rpcUrl = process.env.RPC_URL || "http://127.0.0.1:8545";

const abi = JSON.parse(
  fs.readFileSync(path.join(root, "artifacts/contracts/MiniOlio.sol/MiniOlio.json"), "utf8")
).abi;
const { address } = JSON.parse(fs.readFileSync(path.join(root, `deployments/${network}.json`), "utf8"));

const provider = new ethers.JsonRpcProvider(rpcUrl);

// Read-only contract: enough for the public page, no key needed
const reader = new ethers.Contract(address, abi, provider);

// Contract that can write: needs a signer.
// On a local node we use the node's first test account. On Amoy we use PRIVATE_KEY from .env.
async function writer() {
  const signer = process.env.PRIVATE_KEY
    ? new ethers.Wallet(process.env.PRIVATE_KEY, provider)
    : await provider.getSigner(0);
  return new ethers.Contract(address, abi, signer);
}

module.exports = { reader, writer, address, ethers };

Then the server. The /api part is private and needs a secret code (API_TOKEN) in the header; /qr and /b are public and read-only:

server/index.js

require("dotenv").config({ quiet: true });
const express = require("express");
const crypto = require("crypto");
const QRCode = require("qrcode");
const { reader, writer, address, ethers } = require("./chain");

const PORT = process.env.PORT || 3900;
const BASE_URL = process.env.BASE_URL || `http://127.0.0.1:${PORT}`;
const API_TOKEN = process.env.API_TOKEN;
if (!API_TOKEN) throw new Error("Set API_TOKEN in .env");

const FASI = { Raccolta: 0, Molitura: 1, Analisi: 2 };
const app = express();

// Express 4 does not catch errors thrown in async handlers: this wrapper passes them to the error handler
const h = (fn) => (req, res, next) => fn(req, res, next).catch(next);

// SHA-256 of the received bytes, as a 0x... value that fits a bytes32
const sha256Hex = (buf) => "0x" + crypto.createHash("sha256").update(buf).digest("hex");
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));

// ---------- private API (needs the token) ----------
const api = express.Router();
api.use((req, res, next) => {
  if (req.get("x-api-token") !== API_TOKEN) return res.status(401).json({ error: "bad token" });
  next();
});
api.use(express.json());
api.use(express.raw({ type: "application/octet-stream", limit: "20mb" }));

api.post("/hash", (req, res) => {
  res.json({ sha256: sha256Hex(req.body) });
});

api.post("/lotto", h(async (req, res) => {
  const c = await writer();
  const tx = await c.creaLotto(req.body.codice);
  await tx.wait();
  res.json({ ok: true, tx: tx.hash, id: Number(await c.idDaCodice(req.body.codice)) });
}));

// Body = the document (PDF, photo, ...). The server hashes it and writes only the hash on-chain.
api.post("/lotto/:codice/passo", h(async (req, res) => {
  const fase = FASI[req.query.fase];
  if (fase === undefined) return res.status(400).json({ error: "fase must be Raccolta, Molitura or Analisi" });
  const c = await writer();
  const id = await c.idDaCodice(req.params.codice);
  const docHash = sha256Hex(req.body);
  const tx = await c.aggiungiPasso(id, fase, String(req.query.descrizione || ""), docHash);
  await tx.wait();
  res.json({ ok: true, tx: tx.hash, docHash });
}));

api.post("/lotto/:codice/imbottiglia", h(async (req, res) => {
  const c = await writer();
  const id = await c.idDaCodice(req.params.codice);
  const docHash = sha256Hex(req.body);
  const tx = await c.imbottiglia(id, Number(req.query.prima), Number(req.query.ultima), docHash);
  await tx.wait();
  res.json({ ok: true, tx: tx.hash, docHash });
}));

api.post("/lotto/:codice/richiama", h(async (req, res) => {
  const c = await writer();
  const id = await c.idDaCodice(req.params.codice);
  const tx = await c.richiama(id, req.body.motivo);
  await tx.wait();
  res.json({ ok: true, tx: tx.hash });
}));

// Errors from the chain (wrong role, wrong order...) come back as a readable 400
api.use((err, req, res, next) => {
  res.status(400).json({ error: err.reason || err.shortMessage || err.message });
});
app.use("/api", api);

// ---------- public part (no token, read only) ----------
app.get("/qr/:codice/:n.png", h(async (req, res) => {
  const url = `${BASE_URL}/b/${encodeURIComponent(req.params.codice)}/${Number(req.params.n)}`;
  res.type("png").send(await QRCode.toBuffer(url, { width: 300, margin: 2 }));
}));

app.get("/b/:codice/:n", h(async (req, res) => {
  const codice = req.params.codice;
  const n = Number(req.params.n);
  if (!Number.isInteger(n) || n < 0) return res.status(400).send("Bad bottle number");

  const [valida, richiamato, id] = await reader.verificaBottiglia(codice, n);
  let verdetto = "green";
  let titolo = "Valid bottle";
  if (id === 0n) { verdetto = "red"; titolo = "Unknown lot"; }
  else if (richiamato) { verdetto = "red"; titolo = "Recalled lot: do not use"; }
  else if (!valida) { verdetto = "red"; titolo = "This bottle number does not exist in the lot"; }

  let passiHtml = "";
  if (id !== 0n) {
    const passi = await reader.passi(id);
    passiHtml = "<ol>" + passi.map((p) =>
      `<li>${["Harvest", "Milling", "Analysis", "Bottling"][Number(p.fase)]}: ${esc(p.descrizione)}` +
      ` <small>(${new Date(Number(p.quando) * 1000).toISOString().slice(0, 10)}, document hash ${esc(p.docHash.slice(0, 12))}...)</small></li>`
    ).join("") + "</ol>";
  }

  res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Bottle ${esc(codice)} / ${n}</title>
<style>body{font:18px/1.5 system-ui,sans-serif;max-width:36rem;margin:0 auto;padding:16px;background:#fff;color:#222}
.v{padding:16px;border-radius:8px;color:#fff;font-weight:700}.green{background:#2f7d32}.red{background:#b3261e}small{color:#555}</style></head>
<body><p>Pietre di Vita (invented mill, teaching demo) - bottle ${n} of lot ${esc(codice)}</p>
<div class="v ${verdetto}">${verdetto === "green" ? "GREEN" : "RED"}: ${titolo}</div>${passiHtml}
<p><small>Read directly from the contract ${esc(address)}.</small></p></body></html>`);
}));

// Last safety net for the public pages
app.use((err, req, res, next) => {
  res.status(500).send("Could not read the blockchain. Try again later.");
});

app.listen(PORT, "127.0.0.1", () => console.log(`Server on ${BASE_URL}, contract ${address}`));

The .env.example file is the template for the secret .env file (which must never be shared):

.env.example

# Copy this file to .env and fill it in. Never commit .env.
API_TOKEN=put-a-long-random-string-here
# Local node (default values, you can leave them out):
# RPC_URL=http://127.0.0.1:8545
# NETWORK=localhost
# For Polygon Amoy (a test network) only:
# AMOY_RPC_URL=https://polygon-amoy-bor-rpc.publicnode.com
# DEPLOYER_PRIVATE_KEY=private key of a NEW wallet used only for tests
# PRIVATE_KEY=same key, used by the server
# NETWORK=amoy
# RPC_URL=https://polygon-amoy-bor-rpc.publicnode.com

Create your own .env with a random code and start the server in window 3:

echo "API_TOKEN=$(openssl rand -hex 16)" > .env
node server/index.js
Server on http://127.0.0.1:3900, contract 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0

Security note: in this basic version the server uses a single account that holds all roles. In a real supply chain each role has a different account (chapter 6 of the tutorial).

6. Try it: one bottle from start to end

In window 2 (set the TOKEN variable again if you open a new terminal). The referto.txt file is a fake lab report; the server computes its fingerprint, which matches the sha256sum command:

TOKEN=$(grep API_TOKEN .env | cut -d= -f2)
echo "Lab report, invented data, acidity 0.2" > referto.txt
curl -s -X POST localhost:3900/api/hash -H "x-api-token: $TOKEN" -H "content-type: application/octet-stream" --data-binary @referto.txt
echo
sha256sum referto.txt
{"sha256":"0x5c94f9833f90635d7e6979852642be73376ee15c5502d6856961c1d35270d1b6"}
5c94f9833f90635d7e6979852642be73376ee15c5502d6856961c1d35270d1b6  referto.txt

Create lot DEMO-001 (an ERC-721 token number 1):

curl -s -X POST localhost:3900/api/lotto -H "x-api-token: $TOKEN" -H "content-type: application/json" -d '{"codice":"DEMO-001"}'
echo
{"ok":true,"tx":"0xac5e6008abe67e61c2b76e084da431178b7d20eb8304a40d1bd2d4484712e350","id":1}

Record the three phases (for simplicity we use the same sample file for all):

for FASE in Raccolta Molitura Analisi; do
  curl -s -X POST "localhost:3900/api/lotto/DEMO-001/passo?fase=$FASE&descrizione=Step%20$FASE" \
    -H "x-api-token: $TOKEN" -H "content-type: application/octet-stream" --data-binary @referto.txt
  echo
done
{"ok":true,"tx":"0x3a8ed7060ccd069c5af84045d998f5d499471f8d026476a2746d3be1f96eabc8","docHash":"0x5c94f9833f90635d7e6979852642be73376ee15c5502d6856961c1d35270d1b6"}
{"ok":true,"tx":"0x05127f463f0e0420d91f6059393f5d9394a2621f96e56691adb59565f502b4fb","docHash":"0x5c94f9833f90635d7e6979852642be73376ee15c5502d6856961c1d35270d1b6"}
{"ok":true,"tx":"0x2367b0d4277633b2765c2c6e6760862422f1dd0f7753b02d9be7eb14705a0b83","docHash":"0x5c94f9833f90635d7e6979852642be73376ee15c5502d6856961c1d35270d1b6"}

Try repeating the harvest: the contract refuses, because the order has moved on. An unknown lot is refused too:

curl -s -X POST "localhost:3900/api/lotto/DEMO-001/passo?fase=Raccolta" \
  -H "x-api-token: $TOKEN" -H "content-type: application/octet-stream" --data-binary @referto.txt
echo
{"error":"phase out of order"}
curl -s -X POST localhost:3900/api/lotto/DEMO-002/passo?fase=Raccolta -H "x-api-token: $TOKEN" -H "content-type: application/octet-stream" --data-binary @referto.txt
echo
{"error":"lot not found"}

Bottle bottles 1 to 100:

curl -s -X POST "localhost:3900/api/lotto/DEMO-001/imbottiglia?prima=1&ultima=100" \
  -H "x-api-token: $TOKEN" -H "content-type: application/octet-stream" --data-binary @referto.txt
echo
{"ok":true,"tx":"0x2f8c77859e776e1556158de26638909a7a4cd8e473fd02fd66d9950b2da09579","docHash":"0x5c94f9833f90635d7e6979852642be73376ee15c5502d6856961c1d35270d1b6"}

Create the QR code for bottle 7 (a PNG image, openable with any image viewer) and check the file:

curl -s -o bottiglia-7.png localhost:3900/qr/DEMO-001/7.png
file bottiglia-7.png
bottiglia-7.png: PNG image data, 300 x 300, 8-bit/color RGBA, non-interlaced

The QR contains the address of the public page http://127.0.0.1:3900/b/DEMO-001/7. Open it in a browser, or query the page from the terminal. Green for bottles in the range, red for a number outside it and for an unknown lot:

for B in 7 100 101; do
  echo -n "bottle $B: "
  curl -s localhost:3900/b/DEMO-001/$B | grep -o 'class="v [a-z]*">[^<]*'
done
echo -n "unknown lot: "
curl -s localhost:3900/b/NOPE/1 | grep -o 'class="v [a-z]*">[^<]*'
bottle 7: class="v green">GREEN: Valid bottle
bottle 100: class="v green">GREEN: Valid bottle
bottle 101: class="v red">RED: This bottle number does not exist in the lot
unknown lot: class="v red">RED: Unknown lot

Checking a document: the page shows the beginning of the recorded fingerprint. If you compute the fingerprint of the original file you get the same beginning; if somebody changes even one digit of the report, the fingerprint is different:

curl -s localhost:3900/b/DEMO-001/7 | grep -o "document hash 0x[0-9a-f]*" | head -1
sha256sum referto.txt | cut -c1-12
echo "Lab report, invented data, acidity 0.9" > referto-modificato.txt
sha256sum referto-modificato.txt | cut -c1-12
document hash 0x5c94f9833f
5c94f9833f90
90cf4aaf52da

Now the producer recalls the lot: from that moment bottle 7 turns red too, without touching the bottle or the QR code:

curl -s -X POST localhost:3900/api/lotto/DEMO-001/richiama \
  -H "x-api-token: $TOKEN" -H "content-type: application/json" -d '{"motivo":"Demo recall"}'
echo
curl -s localhost:3900/b/DEMO-001/7 | grep -o 'class="v [a-z]*">[^<]*'
{"ok":true,"tx":"0xa90b0d805f0e3d36e78ca1d893700cae0b8af824471c64db1a5ae151c75a1db0"}
class="v red">RED: Recalled lot: do not use

Finally, whoever lacks the secret code cannot write:

curl -s -X POST localhost:3900/api/hash -H "x-api-token: wrong" -d x
echo
{"error":"bad token"}

7. What we really verified / what we did not

Verified, running everything from an empty folder with Node 22 on a Linux server:

Not verified:

The final working project is also in the repository's olio/starter folder, without node_modules and without .env: after npm install you can run everything again.

The full version used in the demonstration also has a trial mode open to everyone, with daily limits: chapter 6 describes it.

This is the basic version. A real supply chain lacks many things, listed in chapter 9; the full version used in the demo also has a database, role-based login and recall handling.

Try it yourself

Change the number of bottles, for example from 1 to 50, and check bottle 51 again. Then read the existing test that checks the lab cannot recall a lot, and write a similar one for the bottler.

What you learned

Learn more