Blockchain Smart Contracts for Traceability
Deploy Solidity smart contracts for Ayurvedic herb supply chain traceability.
Introduction
30 Second Summary
When you pick up an Ayurvedic supplement, how do you know the herbs inside are authentic? In traditional supply chains, that answer is buried in paperwork that anyone can alter.
In this project, you will build and deploy smart contracts on the Polygon blockchain that create an immutable record of every step in an Ayurvedic herb's journey from farm to shelf.
What You'll Build
A three-contract system deployed on Polygon Amoy testnet that registers herb species, tracks batch lifecycle through six supply chain stages, and provides consumer-facing verification.
By the end of this project, you'll have:
- A HerbRegistry contract that stores herb species with metadata hashes linking to botanical data.
- A BatchLifecycle contract enforcing sequential state transitions from harvest to retail with role-based access control.
- A Verification contract that returns the full provenance chain for any batch in a single read call.
- Secret Mission: Add an event-driven audit log that emits granular supply chain events for off-chain analytics indexing.
Are there any prerequisites?
This project assumes familiarity with Solidity and smart contract concepts. You should have Node.js (v18+) installed and the MetaMask browser extension ready to go.
Set Up the Hardhat Project
Before you can write any smart contract code, you need a development environment that can compile Solidity, run tests, and deploy to a live blockchain network.
In this step, you will set up a Hardhat project with all the dependencies needed to build, test, and deploy your Ayurvedic traceability contracts to the Polygon Amoy testnet.
In this step, get ready to:
- Initialize a Hardhat JavaScript project with all required dependencies.
- Configure Hardhat for Polygon Amoy testnet deployment and contract verification.
- Fund your MetaMask wallet with test POL tokens from the Alchemy faucet.
Create the project directory and initialize Hardhat
Every Hardhat project starts as a Node.js project with Hardhat installed as a dev dependency. You will create a dedicated folder, initialize it with npm, and then scaffold the Hardhat environment.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir ayurtrace-contracts
cd ayurtrace-contracts
- Initialize a new Node.js project by running:
npm init -y
What does this command do?
The npm init -y command creates a package.json file that tracks your project's metadata and dependencies. The -y flag accepts all the defaults so you don't have to answer prompts.
- Install Hardhat and all required dependencies by running:
npm install --save-dev hardhat@2.28.6 @nomicfoundation/hardhat-toolbox @openzeppelin/contracts@5.6.1 dotenv
What are these packages?
You are installing four packages. hardhat@2.28.6 is the smart contract development framework. @nomicfoundation/hardhat-toolbox bundles testing, deployment, and verification plugins together.
The @openzeppelin/contracts@5.6.1 package provides audited, reusable contract building blocks like access control. dotenv loads environment variables from a .env file so you never hardcode secrets.
- Initialize the Hardhat project by running:
npx hardhat init
What will Hardhat ask you?
Hardhat will display a welcome message and present a menu of project templates. Select Create a JavaScript project when prompted. Accept the default project root and say yes to adding a .gitignore.
- Select Create a JavaScript project from the menu.
- Press Enter to accept the default project root.
- Type y and press Enter to add a .gitignore.
- Verify your setup compiles successfully by running:
npx hardhat compile
You should see a compilation message with no errors. Hardhat may compile a sample Lock.sol contract that was scaffolded during init.
Seeing compilation errors?
Make sure you ran the install command before npx hardhat compile. If you see a missing dependency error, delete node_modules and package-lock.json, then re-run the install command.
Help me troubleshoot my Hardhat compilation error.
Configure Hardhat for Polygon Amoy testnet
With the project scaffolded, you need to tell Hardhat where to deploy your contracts. This means adding the Polygon Amoy network details and your Polygonscan API key for contract verification.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the ayurtrace-contracts folder on your Desktop, then click Open.
- Create a new file by clicking File then New File.
- Save it immediately with Cmd+S (macOS) or Ctrl+S (Windows) and name it .env.
- Paste the following placeholder content into .env:
PRIVATE_KEY=your-private-key-here
POLYGONSCAN_API_KEY=your-polygonscan-api-key-here
What are these placeholders for?
The PRIVATE_KEY is your MetaMask wallet's private key, which Hardhat uses to sign deployment transactions. You will export this from MetaMask before deploying.
The POLYGONSCAN_API_KEY lets Hardhat verify your contract source code on Polygonscan so anyone can read it publicly. You will get a free key from Polygonscan before deploying.
Never commit your .env file
Your .env file will contain your private key. The .gitignore that Hardhat created already excludes .env from version control. Never share or commit this file.
- Save .env with Cmd+S (macOS) or Ctrl+S (Windows).
- Now open hardhat.config.js in the Explorer sidebar on the left.
- Replace the entire contents of hardhat.config.js with the following configuration:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: {
version: "0.8.24",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
networks: {
polygon_amoy: {
url: "https://rpc-amoy.polygon.technology",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 80002,
},
},
etherscan: {
apiKey: {
polygonAmoy: process.env.POLYGONSCAN_API_KEY || "",
},
},
};
What does this configuration do?
The solidity section sets the compiler version to 0.8.24 and enables the optimizer to reduce deployment gas costs.
The networks.polygon_amoy block tells Hardhat how to connect to the Polygon Amoy testnet. It uses the official RPC endpoint, your wallet key for signing transactions, and chain ID 80002.
The etherscan block configures automatic contract source code verification on Polygonscan after deployment.
- Save hardhat.config.js with Cmd+S (macOS) or Ctrl+S (Windows).
- Confirm the config works by running this in your terminal:
npx hardhat compile
You should see a successful compilation with no errors. This confirms that dotenv and the toolbox plugin are loading correctly.
Seeing a require error when compiling?
If you see an error about dotenv not being found, confirm that your terminal is inside the ayurtrace-contracts folder. Run pwd to check your current directory.
Help me fix my hardhat.config.js error.
✔️ Awesome, I've got everything!
Great. Make sure both .env and hardhat.config.js are saved before moving on.
ⓧ I'd like to double check the full code
Here is what your .env should look like:
PRIVATE_KEY=your-private-key-here
POLYGONSCAN_API_KEY=your-polygonscan-api-key-here
And here is what your hardhat.config.js should look like:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
module.exports = {
solidity: {
version: "0.8.24",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
networks: {
polygon_amoy: {
url: "https://rpc-amoy.polygon.technology",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 80002,
},
},
etherscan: {
apiKey: {
polygonAmoy: process.env.POLYGONSCAN_API_KEY || "",
},
},
};
Get test POL tokens from the Alchemy Amoy faucet
Deploying contracts to a blockchain requires gas fees. On the Amoy testnet, you pay gas in test POL tokens that have no real value. You need to add the Amoy network to MetaMask and then request tokens from a faucet.
- Open MetaMask by clicking its fox icon in your browser toolbar.
- Click the network dropdown at the top of the MetaMask popup (it likely says Ethereum Mainnet).
- Click Add a network manually at the bottom of the network list.
- Fill in the following network details:
- Network Name: Polygon Amoy Testnet
- New RPC URL: https://rpc-amoy.polygon.technology/
- Chain ID: 80002
- Currency Symbol: POL
- Block Explorer URL: https://amoy.polygonscan.com/
- Click Save to add the network.
MetaMask should switch to the Polygon Amoy Testnet. You will see a 0 POL balance.
What is a testnet?
A testnet is a copy of a real blockchain network used for development. It behaves identically to the mainnet but uses tokens with no financial value. This lets you deploy and test contracts without spending real money.
- Copy your MetaMask wallet address by clicking on it at the top of the MetaMask popup.
- Open the Alchemy Polygon Amoy faucet in your browser:
https://www.alchemy.com/faucets/polygon-amoy
- Paste your wallet address into the Wallet Address field on the faucet page.
- Click Send 0.1 POL to request test tokens.
- Wait a few seconds, then check your MetaMask balance. You should see 0.1 POL appear.
What if the faucet says I'm not eligible?
The Alchemy faucet requires your wallet to have at least 0.001 ETH on Ethereum Mainnet and sufficient mainnet transaction history. If you don't qualify, try alternative faucets listed on the Polygon faucet documentation such as the QuickNode or GetBlock faucets.
Not seeing any POL in MetaMask?
Make sure MetaMask is switched to the Polygon Amoy Testnet network, not Ethereum Mainnet. Tokens only appear when viewing the correct network.
If the faucet transaction shows as confirmed but your balance is still zero, wait 30 seconds and try switching networks back and forth in MetaMask to force a refresh.
Help me get test POL tokens for Polygon Amoy.
Your development environment is fully configured. Next up, you will write the HerbRegistry smart contract that stores Ayurvedic herb species on-chain.
Build the Smart Contracts
Your Hardhat project is configured and ready for contracts. The Ayurvedic traceability system needs three interconnected smart contracts that together model the full herb supply chain lifecycle.
Each contract handles a different responsibility. HerbRegistry stores species data, BatchLifecycle tracks state transitions from farm to shelf, and Verification provides a unified read interface for consumers.
In this step, get ready to:
- Create HerbRegistry.sol with role-based species registration.
- Create BatchLifecycle.sol with a supply chain state machine.
- Create Verification.sol for consumer-facing batch verification.
Create HerbRegistry.sol with species registration
The first contract is the species registry. It maps each Ayurvedic herb species to a metadata hash that links to off-chain data (like your 200-species PDF).
Why metadata hashes instead of full data on-chain?
Storing strings and documents directly on the blockchain is expensive. Instead, you store a bytes32 hash that points to the full data stored off-chain (e.g., on IPFS).
This keeps gas costs minimal while guaranteeing data integrity. If the off-chain data changes, the hash won't match, so tampering is detectable.
- Create a new file called HerbRegistry.sol inside the contracts/ folder of your ayurtrace-contracts project.
- Paste the following code into HerbRegistry.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
contract HerbRegistry is AccessControl {
// Role for authorized species registrars
bytes32 public constant REGISTRAR_ROLE = keccak256("REGISTRAR_ROLE");
// Each herb species has an ID, names, a metadata hash, and active status
struct HerbSpecies {
uint256 id;
string name;
string scientificName;
bytes32 metadataHash;
bool active;
}
// Storage: maps species ID to its data
mapping(uint256 => HerbSpecies) private _species;
uint256 private _speciesCount;
// Emitted when a new species is registered
event SpeciesRegistered(uint256 indexed id, string name, string scientificName, bytes32 metadataHash);
// Grant admin and registrar roles to the deployer
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(REGISTRAR_ROLE, admin);
}
// Register a new herb species (restricted to REGISTRAR_ROLE)
function registerSpecies(
string calldata name,
string calldata scientificName,
bytes32 metadataHash
) external onlyRole(REGISTRAR_ROLE) returns (uint256) {
_speciesCount++;
uint256 newId = _speciesCount;
_species[newId] = HerbSpecies({
id: newId,
name: name,
scientificName: scientificName,
metadataHash: metadataHash,
active: true
});
emit SpeciesRegistered(newId, name, scientificName, metadataHash);
return newId;
}
// Retrieve a species by ID
function getSpecies(uint256 speciesId) external view returns (HerbSpecies memory) {
require(_species[speciesId].id != 0, "Species does not exist");
return _species[speciesId];
}
// Return total number of registered species
function getSpeciesCount() external view returns (uint256) {
return _speciesCount;
}
}
What does this contract do?
- REGISTRAR_ROLE restricts who can register new species. OpenZeppelin's AccessControl enforces this with the onlyRole modifier.
- The HerbSpecies struct stores each species' name, scientific name, and a bytes32 hash pointing to richer off-chain metadata.
- registerSpecies() increments a counter for unique IDs and emits a SpeciesRegistered event so off-chain systems can listen for new registrations.
- The constructor grants both DEFAULT_ADMIN_ROLE and REGISTRAR_ROLE to the deployer address for initial setup convenience.
- Save contracts/HerbRegistry.sol.
- Compile the contract by running:
npx hardhat compile
You should see a message like Compiled 1 Solidity file successfully in the terminal output.
Seeing a compilation error?
- Check that your pragma solidity ^0.8.20 line is present at the top. Your hardhat.config.js uses Solidity 0.8.24 which satisfies ^0.8.20.
- Make sure the import path is exactly @openzeppelin/contracts/access/AccessControl.sol. A typo here causes an "unresolved import" error.
- Confirm @openzeppelin/contracts appears in your node_modules folder. If not, re-run the install from Step 1.
Help me debug my HerbRegistry.sol compilation error
Create BatchLifecycle.sol with the supply chain state machine
The BatchLifecycle contract is the core of the traceability system. It models an herb batch's journey through six sequential states, from harvest to retail shelf.
What is a state machine?
A state machine enforces that transitions only happen in a valid order. A batch must be Harvested before it can be Processing, and Processing before Manufactured.
Combined with role-based access control, this means a Retailer cannot mark a batch as harvested, and a Farmer cannot ship a batch. The contract enforces the real-world supply chain rules on-chain.
- Create a new file called BatchLifecycle.sol inside the contracts/ folder.
- Paste the following code into BatchLifecycle.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
contract BatchLifecycle is AccessControl {
// Four supply chain roles controlling state transitions
bytes32 public constant FARMER_ROLE = keccak256("FARMER_ROLE");
bytes32 public constant MANUFACTURER_ROLE = keccak256("MANUFACTURER_ROLE");
bytes32 public constant DISTRIBUTOR_ROLE = keccak256("DISTRIBUTOR_ROLE");
bytes32 public constant RETAILER_ROLE = keccak256("RETAILER_ROLE");
// Sequential states a batch moves through
enum BatchState {
Harvested,
Processing,
Manufactured,
InTransit,
Delivered,
OnShelf
}
// All data for a single herb batch
struct Batch {
uint256 batchId;
uint256 speciesId;
address farmer;
BatchState currentState;
uint256 harvestedAt;
uint256 processingAt;
uint256 manufacturedAt;
uint256 inTransitAt;
uint256 deliveredAt;
uint256 onShelfAt;
bytes32 harvestMetadata;
bytes32 processMetadata;
bytes32 manufacturingMetadata;
bytes32 shippingMetadata;
}
// Storage: maps batch ID to its data
mapping(uint256 => Batch) private _batches;
uint256 private _batchCount;
// Emitted on every state transition
event BatchStateChanged(
uint256 indexed batchId,
BatchState previousState,
BatchState newState,
address indexed actor,
uint256 timestamp
);
// Only admin role granted at deploy (other roles granted later)
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
// Farmer creates a new batch at harvest time
function harvestBatch(
uint256 speciesId,
bytes32 metadata
) external onlyRole(FARMER_ROLE) returns (uint256) {
_batchCount++;
uint256 newBatchId = _batchCount;
_batches[newBatchId] = Batch({
batchId: newBatchId,
speciesId: speciesId,
farmer: msg.sender,
currentState: BatchState.Harvested,
harvestedAt: block.timestamp,
processingAt: 0,
manufacturedAt: 0,
inTransitAt: 0,
deliveredAt: 0,
onShelfAt: 0,
harvestMetadata: metadata,
processMetadata: bytes32(0),
manufacturingMetadata: bytes32(0),
shippingMetadata: bytes32(0)
});
emit BatchStateChanged(newBatchId, BatchState.Harvested, BatchState.Harvested, msg.sender, block.timestamp);
return newBatchId;
}
// Manufacturer starts processing a harvested batch
function startProcessing(uint256 batchId, bytes32 metadata) external onlyRole(MANUFACTURER_ROLE) {
Batch storage batch = _batches[batchId];
require(batch.batchId != 0, "Batch does not exist");
require(batch.currentState == BatchState.Harvested, "Invalid state transition");
BatchState previousState = batch.currentState;
batch.currentState = BatchState.Processing;
batch.processingAt = block.timestamp;
batch.processMetadata = metadata;
emit BatchStateChanged(batchId, previousState, BatchState.Processing, msg.sender, block.timestamp);
}
// Manufacturer completes manufacturing
function manufactureBatch(uint256 batchId, bytes32 metadata) external onlyRole(MANUFACTURER_ROLE) {
Batch storage batch = _batches[batchId];
require(batch.batchId != 0, "Batch does not exist");
require(batch.currentState == BatchState.Processing, "Invalid state transition");
BatchState previousState = batch.currentState;
batch.currentState = BatchState.Manufactured;
batch.manufacturedAt = block.timestamp;
batch.manufacturingMetadata = metadata;
emit BatchStateChanged(batchId, previousState, BatchState.Manufactured, msg.sender, block.timestamp);
}
// Distributor ships the manufactured batch
function shipBatch(uint256 batchId, bytes32 metadata) external onlyRole(DISTRIBUTOR_ROLE) {
Batch storage batch = _batches[batchId];
require(batch.batchId != 0, "Batch does not exist");
require(batch.currentState == BatchState.Manufactured, "Invalid state transition");
BatchState previousState = batch.currentState;
batch.currentState = BatchState.InTransit;
batch.inTransitAt = block.timestamp;
batch.shippingMetadata = metadata;
emit BatchStateChanged(batchId, previousState, BatchState.InTransit, msg.sender, block.timestamp);
}
// Distributor confirms delivery
function deliverBatch(uint256 batchId) external onlyRole(DISTRIBUTOR_ROLE) {
Batch storage batch = _batches[batchId];
require(batch.batchId != 0, "Batch does not exist");
require(batch.currentState == BatchState.InTransit, "Invalid state transition");
BatchState previousState = batch.currentState;
batch.currentState = BatchState.Delivered;
batch.deliveredAt = block.timestamp;
emit BatchStateChanged(batchId, previousState, BatchState.Delivered, msg.sender, block.timestamp);
}
// Retailer places batch on shelf for consumers
function shelfBatch(uint256 batchId) external onlyRole(RETAILER_ROLE) {
Batch storage batch = _batches[batchId];
require(batch.batchId != 0, "Batch does not exist");
require(batch.currentState == BatchState.Delivered, "Invalid state transition");
BatchState previousState = batch.currentState;
batch.currentState = BatchState.OnShelf;
batch.onShelfAt = block.timestamp;
emit BatchStateChanged(batchId, previousState, BatchState.OnShelf, msg.sender, block.timestamp);
}
// Retrieve full batch data by ID
function getBatch(uint256 batchId) external view returns (Batch memory) {
require(_batches[batchId].batchId != 0, "Batch does not exist");
return _batches[batchId];
}
// Return total number of batches created
function getBatchCount() external view returns (uint256) {
return _batchCount;
}
}
What does this contract do?
- Four roles (FARMER_ROLE, MANUFACTURER_ROLE, DISTRIBUTOR_ROLE, RETAILER_ROLE) map to real supply chain actors.
- The BatchState enum defines six ordered states. Each transition function checks that the batch is in the correct previous state before advancing.
- harvestBatch() creates a new batch and records the farmer's address. All subsequent functions modify the existing batch in storage.
- Every state change emits BatchStateChanged with the previous state, new state, actor, and timestamp for full auditability.
- Save contracts/BatchLifecycle.sol.
- Compile again to verify no errors by running:
npx hardhat compile
You should see Compiled 2 Solidity files successfully (or similar) confirming both contracts pass.
Getting a struct or enum error?
- Check that the Batch struct field names match exactly. A missing comma between fields causes a parse error.
- Make sure BatchState enum values have no trailing comma after OnShelf.
- Verify every require statement uses batch.currentState == BatchState.X with double equals.
Help me debug my BatchLifecycle.sol compilation error
Create Verification.sol for consumer-facing reads
The final contract provides a single entry point for consumers to look up a batch's full provenance. It reads from both HerbRegistry and BatchLifecycle without storing any state of its own.
- Create a new file called Verification.sol inside the contracts/ folder.
- Paste the following code into Verification.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {HerbRegistry} from "./HerbRegistry.sol";
import {BatchLifecycle} from "./BatchLifecycle.sol";
contract Verification {
// Immutable references to the other two contracts
HerbRegistry private immutable _herbRegistry;
BatchLifecycle private immutable _batchLifecycle;
// Combined data returned to consumers
struct FullBatchData {
uint256 batchId;
uint256 speciesId;
string speciesName;
string scientificName;
address farmer;
BatchLifecycle.BatchState currentState;
uint256 harvestedAt;
uint256 processingAt;
uint256 manufacturedAt;
uint256 inTransitAt;
uint256 deliveredAt;
uint256 onShelfAt;
}
// Store references to both contracts at deploy time
constructor(address herbRegistryAddress, address batchLifecycleAddress) {
_herbRegistry = HerbRegistry(herbRegistryAddress);
_batchLifecycle = BatchLifecycle(batchLifecycleAddress);
}
// Return full batch + species data in a single call
function verifyBatch(uint256 batchId) external view returns (FullBatchData memory) {
BatchLifecycle.Batch memory batch = _batchLifecycle.getBatch(batchId);
HerbRegistry.HerbSpecies memory species = _herbRegistry.getSpecies(batch.speciesId);
return FullBatchData({
batchId: batch.batchId,
speciesId: batch.speciesId,
speciesName: species.name,
scientificName: species.scientificName,
farmer: batch.farmer,
currentState: batch.currentState,
harvestedAt: batch.harvestedAt,
processingAt: batch.processingAt,
manufacturedAt: batch.manufacturedAt,
inTransitAt: batch.inTransitAt,
deliveredAt: batch.deliveredAt,
onShelfAt: batch.onShelfAt
});
}
// Return all six state-transition timestamps as an array
function getBatchHistory(uint256 batchId) external view returns (uint256[6] memory timestamps) {
BatchLifecycle.Batch memory batch = _batchLifecycle.getBatch(batchId);
timestamps[0] = batch.harvestedAt;
timestamps[1] = batch.processingAt;
timestamps[2] = batch.manufacturedAt;
timestamps[3] = batch.inTransitAt;
timestamps[4] = batch.deliveredAt;
timestamps[5] = batch.onShelfAt;
return timestamps;
}
}
What does this contract do?
- The contract uses immutable references to save gas. These addresses are set once at deployment and never change.
- verifyBatch() joins batch data from BatchLifecycle with species data from HerbRegistry into one FullBatchData struct.
- getBatchHistory() returns a fixed-size array of six timestamps representing each stage's completion time.
- This contract has no AccessControl and no write functions. Anyone can call it to verify a batch's provenance.
- Save contracts/Verification.sol.
- Compile the full suite by running:
npx hardhat compile
You should see all three contracts compile successfully with no errors.
Import errors for HerbRegistry or BatchLifecycle?
- Check that the import paths use ./HerbRegistry.sol and ./BatchLifecycle.sol (relative paths with ./ prefix).
- Confirm all three .sol files are in the same contracts/ folder.
- Verify there are no typos in the contract names. The filenames must match the contract names exactly.
Help me fix my Verification.sol import error
✔️ Awesome, I've got everything!
Great. Double check you've saved all three contract files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each contract file.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
contract HerbRegistry is AccessControl {
bytes32 public constant REGISTRAR_ROLE = keccak256("REGISTRAR_ROLE");
struct HerbSpecies {
uint256 id;
string name;
string scientificName;
bytes32 metadataHash;
bool active;
}
mapping(uint256 => HerbSpecies) private _species;
uint256 private _speciesCount;
event SpeciesRegistered(uint256 indexed id, string name, string scientificName, bytes32 metadataHash);
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(REGISTRAR_ROLE, admin);
}
function registerSpecies(
string calldata name,
string calldata scientificName,
bytes32 metadataHash
) external onlyRole(REGISTRAR_ROLE) returns (uint256) {
_speciesCount++;
uint256 newId = _speciesCount;
_species[newId] = HerbSpecies({
id: newId,
name: name,
scientificName: scientificName,
metadataHash: metadataHash,
active: true
});
emit SpeciesRegistered(newId, name, scientificName, metadataHash);
return newId;
}
function getSpecies(uint256 speciesId) external view returns (HerbSpecies memory) {
require(_species[speciesId].id != 0, "Species does not exist");
return _species[speciesId];
}
function getSpeciesCount() external view returns (uint256) {
return _speciesCount;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
contract BatchLifecycle is AccessControl {
bytes32 public constant FARMER_ROLE = keccak256("FARMER_ROLE");
bytes32 public constant MANUFACTURER_ROLE = keccak256("MANUFACTURER_ROLE");
bytes32 public constant DISTRIBUTOR_ROLE = keccak256("DISTRIBUTOR_ROLE");
bytes32 public constant RETAILER_ROLE = keccak256("RETAILER_ROLE");
enum BatchState {
Harvested,
Processing,
Manufactured,
InTransit,
Delivered,
OnShelf
}
struct Batch {
uint256 batchId;
uint256 speciesId;
address farmer;
BatchState currentState;
uint256 harvestedAt;
uint256 processingAt;
uint256 manufacturedAt;
uint256 inTransitAt;
uint256 deliveredAt;
uint256 onShelfAt;
bytes32 harvestMetadata;
bytes32 processMetadata;
bytes32 manufacturingMetadata;
bytes32 shippingMetadata;
}
mapping(uint256 => Batch) private _batches;
uint256 private _batchCount;
event BatchStateChanged(
uint256 indexed batchId,
BatchState previousState,
BatchState newState,
address indexed actor,
uint256 timestamp
);
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
function harvestBatch(
uint256 speciesId,
bytes32 metadata
) external onlyRole(FARMER_ROLE) returns (uint256) {
_batchCount++;
uint256 newBatchId = _batchCount;
_batches[newBatchId] = Batch({
batchId: newBatchId,
speciesId: speciesId,
farmer: msg.sender,
currentState: BatchState.Harvested,
harvestedAt: block.timestamp,
processingAt: 0,
manufacturedAt: 0,
inTransitAt: 0,
deliveredAt: 0,
onShelfAt: 0,
harvestMetadata: metadata,
processMetadata: bytes32(0),
manufacturingMetadata: bytes32(0),
shippingMetadata: bytes32(0)
});
emit BatchStateChanged(newBatchId, BatchState.Harvested, BatchState.Harvested, msg.sender, block.timestamp);
return newBatchId;
}
function startProcessing(uint256 batchId, bytes32 metadata) external onlyRole(MANUFACTURER_ROLE) {
Batch storage batch = _batches[batchId];
require(batch.batchId != 0, "Batch does not exist");
require(batch.currentState == BatchState.Harvested, "Invalid state transition");
BatchState previousState = batch.currentState;
batch.currentState = BatchState.Processing;
batch.processingAt = block.timestamp;
batch.processMetadata = metadata;
emit BatchStateChanged(batchId, previousState, BatchState.Processing, msg.sender, block.timestamp);
}
function manufactureBatch(uint256 batchId, bytes32 metadata) external onlyRole(MANUFACTURER_ROLE) {
Batch storage batch = _batches[batchId];
require(batch.batchId != 0, "Batch does not exist");
require(batch.currentState == BatchState.Processing, "Invalid state transition");
BatchState previousState = batch.currentState;
batch.currentState = BatchState.Manufactured;
batch.manufacturedAt = block.timestamp;
batch.manufacturingMetadata = metadata;
emit BatchStateChanged(batchId, previousState, BatchState.Manufactured, msg.sender, block.timestamp);
}
function shipBatch(uint256 batchId, bytes32 metadata) external onlyRole(DISTRIBUTOR_ROLE) {
Batch storage batch = _batches[batchId];
require(batch.batchId != 0, "Batch does not exist");
require(batch.currentState == BatchState.Manufactured, "Invalid state transition");
BatchState previousState = batch.currentState;
batch.currentState = BatchState.InTransit;
batch.inTransitAt = block.timestamp;
batch.shippingMetadata = metadata;
emit BatchStateChanged(batchId, previousState, BatchState.InTransit, msg.sender, block.timestamp);
}
function deliverBatch(uint256 batchId) external onlyRole(DISTRIBUTOR_ROLE) {
Batch storage batch = _batches[batchId];
require(batch.batchId != 0, "Batch does not exist");
require(batch.currentState == BatchState.InTransit, "Invalid state transition");
BatchState previousState = batch.currentState;
batch.currentState = BatchState.Delivered;
batch.deliveredAt = block.timestamp;
emit BatchStateChanged(batchId, previousState, BatchState.Delivered, msg.sender, block.timestamp);
}
function shelfBatch(uint256 batchId) external onlyRole(RETAILER_ROLE) {
Batch storage batch = _batches[batchId];
require(batch.batchId != 0, "Batch does not exist");
require(batch.currentState == BatchState.Delivered, "Invalid state transition");
BatchState previousState = batch.currentState;
batch.currentState = BatchState.OnShelf;
batch.onShelfAt = block.timestamp;
emit BatchStateChanged(batchId, previousState, BatchState.OnShelf, msg.sender, block.timestamp);
}
function getBatch(uint256 batchId) external view returns (Batch memory) {
require(_batches[batchId].batchId != 0, "Batch does not exist");
return _batches[batchId];
}
function getBatchCount() external view returns (uint256) {
return _batchCount;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {HerbRegistry} from "./HerbRegistry.sol";
import {BatchLifecycle} from "./BatchLifecycle.sol";
contract Verification {
HerbRegistry private immutable _herbRegistry;
BatchLifecycle private immutable _batchLifecycle;
struct FullBatchData {
uint256 batchId;
uint256 speciesId;
string speciesName;
string scientificName;
address farmer;
BatchLifecycle.BatchState currentState;
uint256 harvestedAt;
uint256 processingAt;
uint256 manufacturedAt;
uint256 inTransitAt;
uint256 deliveredAt;
uint256 onShelfAt;
}
constructor(address herbRegistryAddress, address batchLifecycleAddress) {
_herbRegistry = HerbRegistry(herbRegistryAddress);
_batchLifecycle = BatchLifecycle(batchLifecycleAddress);
}
function verifyBatch(uint256 batchId) external view returns (FullBatchData memory) {
BatchLifecycle.Batch memory batch = _batchLifecycle.getBatch(batchId);
HerbRegistry.HerbSpecies memory species = _herbRegistry.getSpecies(batch.speciesId);
return FullBatchData({
batchId: batch.batchId,
speciesId: batch.speciesId,
speciesName: species.name,
scientificName: species.scientificName,
farmer: batch.farmer,
currentState: batch.currentState,
harvestedAt: batch.harvestedAt,
processingAt: batch.processingAt,
manufacturedAt: batch.manufacturedAt,
inTransitAt: batch.inTransitAt,
deliveredAt: batch.deliveredAt,
onShelfAt: batch.onShelfAt
});
}
function getBatchHistory(uint256 batchId) external view returns (uint256[6] memory timestamps) {
BatchLifecycle.Batch memory batch = _batchLifecycle.getBatch(batchId);
timestamps[0] = batch.harvestedAt;
timestamps[1] = batch.processingAt;
timestamps[2] = batch.manufacturedAt;
timestamps[3] = batch.inTransitAt;
timestamps[4] = batch.deliveredAt;
timestamps[5] = batch.onShelfAt;
return timestamps;
}
}
All three contracts are compiling and ready for testing. Next up, you'll write unit and integration tests to verify that the access control, state machine, and cross-contract reads all work correctly before deploying to a live network.
Write and Run Tests
Your three contracts compile cleanly, but compiling only proves the code is syntactically valid. It does not prove the logic works correctly.
In this step, you will write a comprehensive test suite that exercises access control, state machine transitions, and cross-contract reads. Running these tests locally catches bugs before you spend real gas deploying to Polygon Amoy.
In this step, get ready to:
- Write unit tests for HerbRegistry covering role enforcement and data integrity.
- Write integration tests for BatchLifecycle covering the full state machine.
- Write end-to-end tests for the Verification contract and run the full suite with coverage.
Write unit tests for HerbRegistry
The HerbRegistry contract uses OpenZeppelin's AccessControl to restrict who can register species. Your tests need to prove two things: authorized accounts succeed, and unauthorized accounts are rejected with the correct custom error.
- Create a new file test/HerbRegistry.test.js inside your ayurtrace-contracts folder and paste the following test code:
const { expect } = require("chai");
const { ethers } = require("hardhat");
const {
loadFixture,
} = require("@nomicfoundation/hardhat-toolbox/network-helpers");
describe("HerbRegistry", function () {
// Deploy a fresh HerbRegistry before each test group
async function deployRegistryFixture() {
const [admin, registrar, unauthorized] = await ethers.getSigners();
const HerbRegistry = await ethers.getContractFactory("HerbRegistry");
const registry = await HerbRegistry.deploy(admin.address);
// Grant REGISTRAR_ROLE to a dedicated test account
const REGISTRAR_ROLE = await registry.REGISTRAR_ROLE();
await registry.grantRole(REGISTRAR_ROLE, registrar.address);
return { registry, admin, registrar, unauthorized, REGISTRAR_ROLE };
}
describe("Access Control", function () {
it("allows REGISTRAR_ROLE to register species", async function () {
const { registry, registrar } = await loadFixture(deployRegistryFixture);
const metadataHash = ethers.keccak256(
ethers.toUtf8Bytes("ashwagandha-page-1")
);
await expect(
registry
.connect(registrar)
.registerSpecies("Ashwagandha", "Withania somnifera", metadataHash)
).to.not.be.reverted;
});
it("rejects unauthorized accounts with AccessControlUnauthorizedAccount", async function () {
const { registry, unauthorized, REGISTRAR_ROLE } =
await loadFixture(deployRegistryFixture);
const metadataHash = ethers.keccak256(ethers.toUtf8Bytes("test"));
await expect(
registry
.connect(unauthorized)
.registerSpecies("Test", "Test species", metadataHash)
)
.to.be.revertedWithCustomError(
registry,
"AccessControlUnauthorizedAccount"
)
.withArgs(unauthorized.address, REGISTRAR_ROLE);
});
});
describe("Species Registration", function () {
it("stores species data correctly after registration", async function () {
const { registry, registrar } = await loadFixture(deployRegistryFixture);
const metadataHash = ethers.keccak256(
ethers.toUtf8Bytes("tulsi-page-5")
);
await registry
.connect(registrar)
.registerSpecies("Tulsi", "Ocimum tenuiflorum", metadataHash);
// Verify stored data matches what was registered
const species = await registry.getSpecies(1);
expect(species.id).to.equal(1);
expect(species.name).to.equal("Tulsi");
expect(species.scientificName).to.equal("Ocimum tenuiflorum");
expect(species.metadataHash).to.equal(metadataHash);
expect(species.active).to.be.true;
});
it("increments species count correctly", async function () {
const { registry, registrar } = await loadFixture(deployRegistryFixture);
const hash1 = ethers.keccak256(ethers.toUtf8Bytes("species-1"));
const hash2 = ethers.keccak256(ethers.toUtf8Bytes("species-2"));
// Count starts at zero
expect(await registry.getSpeciesCount()).to.equal(0);
await registry
.connect(registrar)
.registerSpecies("Neem", "Azadirachta indica", hash1);
expect(await registry.getSpeciesCount()).to.equal(1);
await registry
.connect(registrar)
.registerSpecies("Turmeric", "Curcuma longa", hash2);
expect(await registry.getSpeciesCount()).to.equal(2);
});
});
});
What does this test file do?
The deployRegistryFixture function deploys a fresh HerbRegistry before each test and grants REGISTRAR_ROLE to a separate signer. Hardhat's loadFixture snapshots the blockchain state after the first run and resets to that snapshot for subsequent tests. This keeps each test isolated and fast.
The Access Control tests prove that OpenZeppelin's onlyRole modifier works as expected. The revertedWithCustomError matcher checks for the OZ 5.x custom error AccessControlUnauthorizedAccount and verifies both the unauthorized address and the required role are passed as arguments.
- Save test/HerbRegistry.test.js.
- Run a quick check to confirm this file passes by running:
npx hardhat test test/HerbRegistry.test.js
You should see 4 passing tests in your terminal output.
Seeing a compilation error?
Make sure your contracts/ folder still contains all three .sol files from the previous step. Hardhat compiles all contracts before running tests. If a contract is missing or has a syntax error, the test runner will fail before reaching your assertions.
help me debug my HerbRegistry test compilation error
Write integration tests for BatchLifecycle
The BatchLifecycle contract is a state machine. Each batch must advance through states in order, and only the account with the correct role can trigger each transition. Your tests need to prove that the happy path works end-to-end, that skipping states reverts, and that wrong roles are rejected.
- Create test/BatchLifecycle.test.js and paste the following test code:
const { expect } = require("chai");
const { ethers } = require("hardhat");
const {
loadFixture,
} = require("@nomicfoundation/hardhat-toolbox/network-helpers");
const {
anyValue,
} = require("@nomicfoundation/hardhat-chai-matchers/withArgs");
describe("BatchLifecycle", function () {
// Deploy BatchLifecycle and grant each supply chain role to a unique signer
async function deployLifecycleFixture() {
const [admin, farmer, manufacturer, distributor, retailer, unauthorized] =
await ethers.getSigners();
const BatchLifecycle =
await ethers.getContractFactory("BatchLifecycle");
const lifecycle = await BatchLifecycle.deploy(admin.address);
// Retrieve role hashes from the deployed contract
const FARMER_ROLE = await lifecycle.FARMER_ROLE();
const MANUFACTURER_ROLE = await lifecycle.MANUFACTURER_ROLE();
const DISTRIBUTOR_ROLE = await lifecycle.DISTRIBUTOR_ROLE();
const RETAILER_ROLE = await lifecycle.RETAILER_ROLE();
// Grant each role to its respective signer
await lifecycle.grantRole(FARMER_ROLE, farmer.address);
await lifecycle.grantRole(MANUFACTURER_ROLE, manufacturer.address);
await lifecycle.grantRole(DISTRIBUTOR_ROLE, distributor.address);
await lifecycle.grantRole(RETAILER_ROLE, retailer.address);
const metadata = ethers.keccak256(ethers.toUtf8Bytes("batch-metadata"));
return {
lifecycle,
admin,
farmer,
manufacturer,
distributor,
retailer,
unauthorized,
FARMER_ROLE,
MANUFACTURER_ROLE,
DISTRIBUTOR_ROLE,
RETAILER_ROLE,
metadata,
};
}
describe("Full Lifecycle Journey", function () {
it("advances a batch from Harvested to OnShelf", async function () {
const { lifecycle, farmer, manufacturer, distributor, retailer, metadata } =
await loadFixture(deployLifecycleFixture);
// Farmer harvests a new batch
await lifecycle.connect(farmer).harvestBatch(1, metadata);
let batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(0); // Harvested
// Manufacturer starts processing
await lifecycle.connect(manufacturer).startProcessing(1, metadata);
batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(1); // Processing
// Manufacturer completes manufacturing
await lifecycle.connect(manufacturer).manufactureBatch(1, metadata);
batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(2); // Manufactured
// Distributor ships the batch
await lifecycle.connect(distributor).shipBatch(1, metadata);
batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(3); // InTransit
// Distributor delivers
await lifecycle.connect(distributor).deliverBatch(1);
batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(4); // Delivered
// Retailer places on shelf
await lifecycle.connect(retailer).shelfBatch(1);
batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(5); // OnShelf
});
});
describe("State Transition Enforcement", function () {
it("reverts on out-of-order state transitions", async function () {
const { lifecycle, farmer, manufacturer, distributor, metadata } =
await loadFixture(deployLifecycleFixture);
await lifecycle.connect(farmer).harvestBatch(1, metadata);
// Skip Processing and try to Manufacture directly
await expect(
lifecycle.connect(manufacturer).manufactureBatch(1, metadata)
).to.be.revertedWith("Invalid state transition");
// Skip Manufacturing and try to Ship
await expect(
lifecycle.connect(distributor).shipBatch(1, metadata)
).to.be.revertedWith("Invalid state transition");
});
it("reverts when wrong role attempts a state transition", async function () {
const { lifecycle, distributor, metadata, FARMER_ROLE } =
await loadFixture(deployLifecycleFixture);
// Distributor cannot harvest - wrong role
await expect(
lifecycle.connect(distributor).harvestBatch(1, metadata)
)
.to.be.revertedWithCustomError(
lifecycle,
"AccessControlUnauthorizedAccount"
)
.withArgs(distributor.address, FARMER_ROLE);
});
});
describe("Event Emission", function () {
it("emits BatchStateChanged with correct parameters", async function () {
const { lifecycle, farmer, manufacturer, metadata } =
await loadFixture(deployLifecycleFixture);
// Harvest emits event with both states as Harvested (initial creation)
await expect(lifecycle.connect(farmer).harvestBatch(1, metadata))
.to.emit(lifecycle, "BatchStateChanged")
.withArgs(1, 0, 0, farmer.address, anyValue);
// Processing emits event showing transition from Harvested to Processing
await expect(
lifecycle.connect(manufacturer).startProcessing(1, metadata)
)
.to.emit(lifecycle, "BatchStateChanged")
.withArgs(1, 0, 1, manufacturer.address, anyValue);
});
});
});
What does this test file do?
The fixture creates six signers representing each supply chain actor. Each role constant (FARMER_ROLE, MANUFACTURER_ROLE, etc.) is read from the deployed contract to ensure the keccak256 hashes match exactly.
The full lifecycle test proves a batch can advance through all six states in order. The state enforcement tests prove the contract reverts with "Invalid state transition" when you skip a step. The event test uses anyValue for the timestamp argument because block.timestamp is unpredictable in tests.
- Save test/BatchLifecycle.test.js.
- Run this test file by running:
npx hardhat test test/BatchLifecycle.test.js
You should see 4 passing tests in your terminal output.
Seeing "Cannot find module" for anyValue?
The anyValue predicate comes from @nomicfoundation/hardhat-chai-matchers which is included with @nomicfoundation/hardhat-toolbox. If the import fails, the toolbox may not have installed its peer dependencies correctly. Reinstall it by running npm install --save-dev @nomicfoundation/hardhat-toolbox and try again.
help me fix my anyValue import error in Hardhat tests
Write end-to-end tests for Verification and run coverage
The Verification contract reads data from both HerbRegistry and BatchLifecycle. This end-to-end test deploys all three contracts together, simulates a full supply chain journey, and then verifies the joined data is returned correctly.
- Create test/Verification.test.js and paste the following test code:
const { expect } = require("chai");
const { ethers } = require("hardhat");
const {
loadFixture,
} = require("@nomicfoundation/hardhat-toolbox/network-helpers");
describe("Verification", function () {
// Deploy all three contracts and wire them together
async function deployFullFixture() {
const [admin, farmer, manufacturer, distributor, retailer] =
await ethers.getSigners();
// Deploy HerbRegistry
const HerbRegistry = await ethers.getContractFactory("HerbRegistry");
const registry = await HerbRegistry.deploy(admin.address);
// Deploy BatchLifecycle
const BatchLifecycle =
await ethers.getContractFactory("BatchLifecycle");
const lifecycle = await BatchLifecycle.deploy(admin.address);
// Deploy Verification with both contract addresses
const Verification = await ethers.getContractFactory("Verification");
const verification = await Verification.deploy(
await registry.getAddress(),
await lifecycle.getAddress()
);
// Grant roles for the full supply chain flow
const FARMER_ROLE = await lifecycle.FARMER_ROLE();
const MANUFACTURER_ROLE = await lifecycle.MANUFACTURER_ROLE();
const DISTRIBUTOR_ROLE = await lifecycle.DISTRIBUTOR_ROLE();
const RETAILER_ROLE = await lifecycle.RETAILER_ROLE();
await lifecycle.grantRole(FARMER_ROLE, farmer.address);
await lifecycle.grantRole(MANUFACTURER_ROLE, manufacturer.address);
await lifecycle.grantRole(DISTRIBUTOR_ROLE, distributor.address);
await lifecycle.grantRole(RETAILER_ROLE, retailer.address);
return {
registry,
lifecycle,
verification,
admin,
farmer,
manufacturer,
distributor,
retailer,
};
}
it("returns correct joined FullBatchData for a completed batch", async function () {
const {
registry,
lifecycle,
verification,
admin,
farmer,
manufacturer,
distributor,
retailer,
} = await loadFixture(deployFullFixture);
// Register a species in HerbRegistry
const metadataHash = ethers.keccak256(
ethers.toUtf8Bytes("ashwagandha-data")
);
await registry
.connect(admin)
.registerSpecies("Ashwagandha", "Withania somnifera", metadataHash);
// Run batch through complete supply chain
const batchMeta = ethers.keccak256(ethers.toUtf8Bytes("batch-1"));
await lifecycle.connect(farmer).harvestBatch(1, batchMeta);
await lifecycle.connect(manufacturer).startProcessing(1, batchMeta);
await lifecycle.connect(manufacturer).manufactureBatch(1, batchMeta);
await lifecycle.connect(distributor).shipBatch(1, batchMeta);
await lifecycle.connect(distributor).deliverBatch(1);
await lifecycle.connect(retailer).shelfBatch(1);
// Verify the joined data returned by Verification contract
const fullData = await verification.verifyBatch(1);
expect(fullData.batchId).to.equal(1);
expect(fullData.speciesId).to.equal(1);
expect(fullData.speciesName).to.equal("Ashwagandha");
expect(fullData.scientificName).to.equal("Withania somnifera");
expect(fullData.farmer).to.equal(farmer.address);
expect(fullData.currentState).to.equal(5); // OnShelf
});
it("returns correct batch history timestamps", async function () {
const {
registry,
lifecycle,
verification,
admin,
farmer,
manufacturer,
distributor,
retailer,
} = await loadFixture(deployFullFixture);
// Register species and advance batch through all states
const metadataHash = ethers.keccak256(ethers.toUtf8Bytes("neem-data"));
await registry
.connect(admin)
.registerSpecies("Neem", "Azadirachta indica", metadataHash);
const batchMeta = ethers.keccak256(ethers.toUtf8Bytes("batch-2"));
await lifecycle.connect(farmer).harvestBatch(1, batchMeta);
await lifecycle.connect(manufacturer).startProcessing(1, batchMeta);
await lifecycle.connect(manufacturer).manufactureBatch(1, batchMeta);
await lifecycle.connect(distributor).shipBatch(1, batchMeta);
await lifecycle.connect(distributor).deliverBatch(1);
await lifecycle.connect(retailer).shelfBatch(1);
// All six timestamps should be non-zero after full lifecycle
const timestamps = await verification.getBatchHistory(1);
for (let i = 0; i < 6; i++) {
expect(timestamps[i]).to.be.greaterThan(0);
}
});
});
What does this test file do?
The fixture deploys all three contracts and wires them together by passing HerbRegistry and BatchLifecycle addresses into the Verification constructor. This mirrors exactly how the contracts will be deployed on-chain.
The first test registers a species, advances a batch through all six states, then calls verifyBatch() to confirm the Verification contract joins batch data with species data correctly. The second test checks that getBatchHistory() returns non-zero timestamps for all six lifecycle stages.
- Save test/Verification.test.js.
- Run the full test suite across all three files by running:
npx hardhat test
You should see 10 passing tests across the three describe blocks.
- Check your code coverage by running:
npx hardhat coverage
You should see 90%+ statement coverage across all three contracts. The coverage report prints a table showing statements, branches, functions, and lines covered for each file.
Tests failing with "Batch does not exist"?
This error means you are calling a state transition on a batch ID that has not been created yet. Make sure harvestBatch() is called first to create the batch before any subsequent transitions. The speciesId passed to harvestBatch() must also match a registered species if you are testing the Verification contract.
help me debug my BatchLifecycle or Verification test failures
✔️ Awesome, I've got everything!
Great. Double check you have saved all three test files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each test file:
const { expect } = require("chai");
const { ethers } = require("hardhat");
const {
loadFixture,
} = require("@nomicfoundation/hardhat-toolbox/network-helpers");
describe("HerbRegistry", function () {
async function deployRegistryFixture() {
const [admin, registrar, unauthorized] = await ethers.getSigners();
const HerbRegistry = await ethers.getContractFactory("HerbRegistry");
const registry = await HerbRegistry.deploy(admin.address);
const REGISTRAR_ROLE = await registry.REGISTRAR_ROLE();
await registry.grantRole(REGISTRAR_ROLE, registrar.address);
return { registry, admin, registrar, unauthorized, REGISTRAR_ROLE };
}
describe("Access Control", function () {
it("allows REGISTRAR_ROLE to register species", async function () {
const { registry, registrar } = await loadFixture(deployRegistryFixture);
const metadataHash = ethers.keccak256(
ethers.toUtf8Bytes("ashwagandha-page-1")
);
await expect(
registry
.connect(registrar)
.registerSpecies("Ashwagandha", "Withania somnifera", metadataHash)
).to.not.be.reverted;
});
it("rejects unauthorized accounts with AccessControlUnauthorizedAccount", async function () {
const { registry, unauthorized, REGISTRAR_ROLE } =
await loadFixture(deployRegistryFixture);
const metadataHash = ethers.keccak256(ethers.toUtf8Bytes("test"));
await expect(
registry
.connect(unauthorized)
.registerSpecies("Test", "Test species", metadataHash)
)
.to.be.revertedWithCustomError(
registry,
"AccessControlUnauthorizedAccount"
)
.withArgs(unauthorized.address, REGISTRAR_ROLE);
});
});
describe("Species Registration", function () {
it("stores species data correctly after registration", async function () {
const { registry, registrar } = await loadFixture(deployRegistryFixture);
const metadataHash = ethers.keccak256(
ethers.toUtf8Bytes("tulsi-page-5")
);
await registry
.connect(registrar)
.registerSpecies("Tulsi", "Ocimum tenuiflorum", metadataHash);
const species = await registry.getSpecies(1);
expect(species.id).to.equal(1);
expect(species.name).to.equal("Tulsi");
expect(species.scientificName).to.equal("Ocimum tenuiflorum");
expect(species.metadataHash).to.equal(metadataHash);
expect(species.active).to.be.true;
});
it("increments species count correctly", async function () {
const { registry, registrar } = await loadFixture(deployRegistryFixture);
const hash1 = ethers.keccak256(ethers.toUtf8Bytes("species-1"));
const hash2 = ethers.keccak256(ethers.toUtf8Bytes("species-2"));
expect(await registry.getSpeciesCount()).to.equal(0);
await registry
.connect(registrar)
.registerSpecies("Neem", "Azadirachta indica", hash1);
expect(await registry.getSpeciesCount()).to.equal(1);
await registry
.connect(registrar)
.registerSpecies("Turmeric", "Curcuma longa", hash2);
expect(await registry.getSpeciesCount()).to.equal(2);
});
});
});
const { expect } = require("chai");
const { ethers } = require("hardhat");
const {
loadFixture,
} = require("@nomicfoundation/hardhat-toolbox/network-helpers");
const {
anyValue,
} = require("@nomicfoundation/hardhat-chai-matchers/withArgs");
describe("BatchLifecycle", function () {
async function deployLifecycleFixture() {
const [admin, farmer, manufacturer, distributor, retailer, unauthorized] =
await ethers.getSigners();
const BatchLifecycle =
await ethers.getContractFactory("BatchLifecycle");
const lifecycle = await BatchLifecycle.deploy(admin.address);
const FARMER_ROLE = await lifecycle.FARMER_ROLE();
const MANUFACTURER_ROLE = await lifecycle.MANUFACTURER_ROLE();
const DISTRIBUTOR_ROLE = await lifecycle.DISTRIBUTOR_ROLE();
const RETAILER_ROLE = await lifecycle.RETAILER_ROLE();
await lifecycle.grantRole(FARMER_ROLE, farmer.address);
await lifecycle.grantRole(MANUFACTURER_ROLE, manufacturer.address);
await lifecycle.grantRole(DISTRIBUTOR_ROLE, distributor.address);
await lifecycle.grantRole(RETAILER_ROLE, retailer.address);
const metadata = ethers.keccak256(ethers.toUtf8Bytes("batch-metadata"));
return {
lifecycle,
admin,
farmer,
manufacturer,
distributor,
retailer,
unauthorized,
FARMER_ROLE,
MANUFACTURER_ROLE,
DISTRIBUTOR_ROLE,
RETAILER_ROLE,
metadata,
};
}
describe("Full Lifecycle Journey", function () {
it("advances a batch from Harvested to OnShelf", async function () {
const { lifecycle, farmer, manufacturer, distributor, retailer, metadata } =
await loadFixture(deployLifecycleFixture);
await lifecycle.connect(farmer).harvestBatch(1, metadata);
let batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(0);
await lifecycle.connect(manufacturer).startProcessing(1, metadata);
batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(1);
await lifecycle.connect(manufacturer).manufactureBatch(1, metadata);
batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(2);
await lifecycle.connect(distributor).shipBatch(1, metadata);
batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(3);
await lifecycle.connect(distributor).deliverBatch(1);
batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(4);
await lifecycle.connect(retailer).shelfBatch(1);
batch = await lifecycle.getBatch(1);
expect(batch.currentState).to.equal(5);
});
});
describe("State Transition Enforcement", function () {
it("reverts on out-of-order state transitions", async function () {
const { lifecycle, farmer, manufacturer, distributor, metadata } =
await loadFixture(deployLifecycleFixture);
await lifecycle.connect(farmer).harvestBatch(1, metadata);
await expect(
lifecycle.connect(manufacturer).manufactureBatch(1, metadata)
).to.be.revertedWith("Invalid state transition");
await expect(
lifecycle.connect(distributor).shipBatch(1, metadata)
).to.be.revertedWith("Invalid state transition");
});
it("reverts when wrong role attempts a state transition", async function () {
const { lifecycle, distributor, metadata, FARMER_ROLE } =
await loadFixture(deployLifecycleFixture);
await expect(
lifecycle.connect(distributor).harvestBatch(1, metadata)
)
.to.be.revertedWithCustomError(
lifecycle,
"AccessControlUnauthorizedAccount"
)
.withArgs(distributor.address, FARMER_ROLE);
});
});
describe("Event Emission", function () {
it("emits BatchStateChanged with correct parameters", async function () {
const { lifecycle, farmer, manufacturer, metadata } =
await loadFixture(deployLifecycleFixture);
await expect(lifecycle.connect(farmer).harvestBatch(1, metadata))
.to.emit(lifecycle, "BatchStateChanged")
.withArgs(1, 0, 0, farmer.address, anyValue);
await expect(
lifecycle.connect(manufacturer).startProcessing(1, metadata)
)
.to.emit(lifecycle, "BatchStateChanged")
.withArgs(1, 0, 1, manufacturer.address, anyValue);
});
});
});
const { expect } = require("chai");
const { ethers } = require("hardhat");
const {
loadFixture,
} = require("@nomicfoundation/hardhat-toolbox/network-helpers");
describe("Verification", function () {
async function deployFullFixture() {
const [admin, farmer, manufacturer, distributor, retailer] =
await ethers.getSigners();
const HerbRegistry = await ethers.getContractFactory("HerbRegistry");
const registry = await HerbRegistry.deploy(admin.address);
const BatchLifecycle =
await ethers.getContractFactory("BatchLifecycle");
const lifecycle = await BatchLifecycle.deploy(admin.address);
const Verification = await ethers.getContractFactory("Verification");
const verification = await Verification.deploy(
await registry.getAddress(),
await lifecycle.getAddress()
);
const FARMER_ROLE = await lifecycle.FARMER_ROLE();
const MANUFACTURER_ROLE = await lifecycle.MANUFACTURER_ROLE();
const DISTRIBUTOR_ROLE = await lifecycle.DISTRIBUTOR_ROLE();
const RETAILER_ROLE = await lifecycle.RETAILER_ROLE();
await lifecycle.grantRole(FARMER_ROLE, farmer.address);
await lifecycle.grantRole(MANUFACTURER_ROLE, manufacturer.address);
await lifecycle.grantRole(DISTRIBUTOR_ROLE, distributor.address);
await lifecycle.grantRole(RETAILER_ROLE, retailer.address);
return {
registry,
lifecycle,
verification,
admin,
farmer,
manufacturer,
distributor,
retailer,
};
}
it("returns correct joined FullBatchData for a completed batch", async function () {
const {
registry,
lifecycle,
verification,
admin,
farmer,
manufacturer,
distributor,
retailer,
} = await loadFixture(deployFullFixture);
const metadataHash = ethers.keccak256(
ethers.toUtf8Bytes("ashwagandha-data")
);
await registry
.connect(admin)
.registerSpecies("Ashwagandha", "Withania somnifera", metadataHash);
const batchMeta = ethers.keccak256(ethers.toUtf8Bytes("batch-1"));
await lifecycle.connect(farmer).harvestBatch(1, batchMeta);
await lifecycle.connect(manufacturer).startProcessing(1, batchMeta);
await lifecycle.connect(manufacturer).manufactureBatch(1, batchMeta);
await lifecycle.connect(distributor).shipBatch(1, batchMeta);
await lifecycle.connect(distributor).deliverBatch(1);
await lifecycle.connect(retailer).shelfBatch(1);
const fullData = await verification.verifyBatch(1);
expect(fullData.batchId).to.equal(1);
expect(fullData.speciesId).to.equal(1);
expect(fullData.speciesName).to.equal("Ashwagandha");
expect(fullData.scientificName).to.equal("Withania somnifera");
expect(fullData.farmer).to.equal(farmer.address);
expect(fullData.currentState).to.equal(5);
});
it("returns correct batch history timestamps", async function () {
const {
registry,
lifecycle,
verification,
admin,
farmer,
manufacturer,
distributor,
retailer,
} = await loadFixture(deployFullFixture);
const metadataHash = ethers.keccak256(ethers.toUtf8Bytes("neem-data"));
await registry
.connect(admin)
.registerSpecies("Neem", "Azadirachta indica", metadataHash);
const batchMeta = ethers.keccak256(ethers.toUtf8Bytes("batch-2"));
await lifecycle.connect(farmer).harvestBatch(1, batchMeta);
await lifecycle.connect(manufacturer).startProcessing(1, batchMeta);
await lifecycle.connect(manufacturer).manufactureBatch(1, batchMeta);
await lifecycle.connect(distributor).shipBatch(1, batchMeta);
await lifecycle.connect(distributor).deliverBatch(1);
await lifecycle.connect(retailer).shelfBatch(1);
const timestamps = await verification.getBatchHistory(1);
for (let i = 0; i < 6; i++) {
expect(timestamps[i]).to.be.greaterThan(0);
}
});
});
Your contracts now have a proven test suite backing every access control rule and state transition. Next up, you will deploy these contracts to Polygon Amoy testnet and verify them on Polygonscan so anyone can inspect the source code.
Deploy and Verify on Polygon Amoy
Your contracts are tested and ready for the real world. In this step, you will deploy the entire AyurTrace contract suite to a live blockchain network where anyone can interact with it.
You will use Hardhat Ignition to define a deployment module that handles the correct deployment order and passes constructor arguments between contracts automatically.
In this step, get ready to:
- Create the Hardhat Ignition deployment module.
- Deploy all three contracts to Polygon Amoy testnet.
- Verify contract source code on Polygonscan.
Create the Hardhat Ignition deployment module
Hardhat Ignition uses deployment modules to describe what contracts to deploy and in what order. Each module creates Future objects that represent planned deployment steps without executing them immediately.
- Create the ignition/modules directory inside your ayurtrace-contracts folder by running this command:
mkdir -p ignition/modules
- Confirm the directories were created by running:
ls ignition
You should see modules listed in the output.
- Create the file ignition/modules/AyurTrace.js and add the following deployment module:
const { buildModule } = require("@nomicfoundation/hardhat-ignition/modules");
module.exports = buildModule("AyurTrace", (m) => {
// Get the deployer account to use as admin for access control
const deployer = m.getAccount(0);
// Deploy HerbRegistry first, passing deployer as admin
const herbRegistry = m.contract("HerbRegistry", [deployer]);
// Deploy BatchLifecycle, also with deployer as admin
const batchLifecycle = m.contract("BatchLifecycle", [deployer]);
// Deploy Verification last, passing both contract references
const verification = m.contract("Verification", [herbRegistry, batchLifecycle]);
return { herbRegistry, batchLifecycle, verification };
});
What does this code do?
The buildModule function defines a deployment plan called AyurTrace. Inside the callback, you describe the contracts and their constructor arguments.
Hardhat Ignition figures out the deployment order from the dependencies. Since Verification depends on herbRegistry and batchLifecycle, Ignition deploys those two first and passes their resolved addresses to Verification automatically.
The deployer address receives DEFAULT_ADMIN_ROLE and REGISTRAR_ROLE (in HerbRegistry) or just DEFAULT_ADMIN_ROLE (in BatchLifecycle) through each contract's constructor.
- Save ignition/modules/AyurTrace.js.
Seeing a module not found error?
Make sure the file path is exactly ignition/modules/AyurTrace.js inside your project root. The ignition folder should be at the same level as your contracts folder.
help me fix my Hardhat Ignition module file path
✔️ Awesome, I've got everything!
Great. Make sure you have saved ignition/modules/AyurTrace.js before moving on.
ⓧ I'd like to double check the full code
const { buildModule } = require("@nomicfoundation/hardhat-ignition/modules");
module.exports = buildModule("AyurTrace", (m) => {
const deployer = m.getAccount(0);
const herbRegistry = m.contract("HerbRegistry", [deployer]);
const batchLifecycle = m.contract("BatchLifecycle", [deployer]);
const verification = m.contract("Verification", [herbRegistry, batchLifecycle]);
return { herbRegistry, batchLifecycle, verification };
});
Deploy to Polygon Amoy testnet
With the module ready, you can now deploy to the live Polygon Amoy testnet. This sends real transactions using the POL tokens you received from the faucet earlier.
What happens during deployment?
Hardhat Ignition compiles your contracts, sends deployment transactions to the Polygon Amoy RPC endpoint, and waits for confirmations. It deploys in batches based on dependencies.
HerbRegistry and BatchLifecycle deploy in batch 1 (no dependencies on each other). Verification deploys in batch 2 because it needs their addresses as constructor arguments.
- Make sure your .env file has your real private key and Polygonscan API key filled in (replacing the placeholder values).
- Deploy all three contracts by running this command:
npx hardhat ignition deploy ./ignition/modules/AyurTrace.js --network polygon_amoy
What should I see?
Hardhat Ignition will prompt you to confirm the deployment to a live network. Type yes and press Enter to proceed.
After confirmation, you should see output showing batches being executed and deployed addresses listed at the end:
Hardhat Ignition
Deploying [ AyurTrace ]
Batch #1
Executed AyurTrace#HerbRegistry
Executed AyurTrace#BatchLifecycle
Batch #2
Executed AyurTrace#Verification
[ AyurTrace ] successfully deployed
Deployed Addresses
AyurTrace#HerbRegistry - 0x...
AyurTrace#BatchLifecycle - 0x...
AyurTrace#Verification - 0x...
- Copy and save the three deployed contract addresses from the output. You will need them for verification.
- Open https://amoy.polygonscan.com/ in your browser.
- Paste one of your contract addresses into the search bar and press Enter.
- Confirm you see a page showing the contract with its creation transaction listed.
Deployment failed or timed out?
Make sure your .env file contains your actual MetaMask private key (not the placeholder text). Confirm you have at least 0.1 POL in your wallet for gas fees.
If the RPC times out, the Polygon Amoy public endpoint may be congested. Wait a minute and try again.
help me debug my Hardhat Ignition deployment to Polygon Amoy
Verify contracts on Polygonscan
Verification makes your contract source code publicly readable on Polygonscan. Anyone can inspect exactly what logic is running on-chain. It also enables the Read Contract and Write Contract tabs for direct interaction.
- Verify HerbRegistry by running this command (replace the address with yours):
npx hardhat verify --network polygon_amoy [[HERBREGISTRY_ADDRESS="your HerbRegistry contract address"]]
- Verify BatchLifecycle by running:
npx hardhat verify --network polygon_amoy [[BATCHLIFECYCLE_ADDRESS="your BatchLifecycle contract address"]]
Why don't these need constructor arguments?
HerbRegistry and BatchLifecycle each take a single address constructor argument (the deployer). Hardhat's verify plugin auto-detects constructor arguments from the deployment artifacts stored in the ignition/deployments/ directory.
You only need to pass constructor arguments explicitly when auto-detection fails (like for the Verification contract below).
- Verify the Verification contract by running this command with the HerbRegistry and BatchLifecycle addresses as constructor arguments:
npx hardhat verify --network polygon_amoy [[VERIFICATION_ADDRESS="your Verification contract address"]] [[HERBREGISTRY_ADDRESS="your HerbRegistry contract address"]] [[BATCHLIFECYCLE_ADDRESS="your BatchLifecycle contract address"]]
What does this command do?
The verify task submits your source code to Polygonscan's API along with the compiler settings and constructor arguments. Polygonscan recompiles the code and checks that the bytecode matches what is deployed on-chain.
If it matches, the source is published publicly. This is what makes the Read Contract and Write Contract tabs functional on Polygonscan.
- After each verification succeeds, you should see a success message in the terminal.
- Return to https://amoy.polygonscan.com/ and search for any of your contract addresses.
- Click on the Contract tab. You should see a green checkmark indicating the source code is verified.
- Confirm the Read Contract and Write Contract tabs are visible and functional.
Verification failed?
If you see an error about a missing API key, confirm your POLYGONSCAN_API_KEY is set correctly in .env. You can get a free key by creating an account at https://amoy.polygonscan.com/ and navigating to API Keys in your profile.
If verification says the bytecode does not match, make sure you have not modified any contract source files after deployment. The compiled code must be identical.
help me fix my Hardhat contract verification on Polygonscan
Secret mission
Add an Event-Driven Audit Log
Your contracts record state changes, but production systems need a dedicated audit trail for off-chain analytics. In this secret mission, you will create a centralized AuditLog contract that emits granular events at every lifecycle transition. This enables future indexing tools like The Graph to power your analytics dashboard.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on Polygon Amoy testnet, so there are no ongoing costs. Decide whether to keep your local project files for future reference or remove them from your machine.
Resources you used:
- Local ayurtrace-contracts project folder on your Desktop.
- Smart contracts deployed to Polygon Amoy testnet (free, no ongoing cost).
- Test POL tokens in your MetaMask wallet (no real monetary value).
- Polygon Amoy network configuration in MetaMask.
Keep everything running
No action needed. Choose this if you plan to build on these contracts in a future project.
- Your testnet contracts persist indefinitely at no cost. They remain deployed and queryable on Polygonscan.
- Your local ayurtrace-contracts folder stays on your Desktop for reference.
- Your test POL tokens remain in MetaMask. They have no real monetary value.
Pause - I'll come back to this later
Since this project uses a free testnet, there is nothing to pause. Your contracts remain live at zero cost.
- Your deployed contracts stay active on Polygon Amoy indefinitely. No fees accumulate.
- Keep your local ayurtrace-contracts folder so you can pick up where you left off.
- Your MetaMask configuration and test POL tokens will be there when you return.
Delete - I don't want to use this again
Remove your local project files. Testnet contracts cannot be deleted from the blockchain, but they cost nothing to leave in place.
- Delete your local project folder by running the following command (macOS/Linux):
- On Windows, run the equivalent command in PowerShell.
cd ~/Desktop
rm -rf ayurtrace-contracts
On Windows, run this in PowerShell:
cd ~\Desktop
Remove-Item -Recurse -Force ayurtrace-contracts
- To remove the Polygon Amoy network from MetaMask, open MetaMask, click the network dropdown at the top, click the three dots next to Polygon Amoy Testnet, and select Delete.
- Your deployed contracts remain on the Polygon Amoy testnet permanently. This is by design. Blockchain deployments are immutable and cost nothing on a testnet.
Nice Work!
Nice Work!
You just built and deployed a production-ready blockchain traceability system for Ayurvedic herbs on Polygon Amoy testnet. Anyone can now query your contracts on Polygonscan to trace a herb batch from farm to shelf.
You've learned how to:
- Built and deployed a multi-contract Solidity system on Polygon Amoy testnet using Hardhat Ignition for reproducible deployments.
- Implemented role-based access control with OpenZeppelin AccessControl, enforcing that only authorized supply chain actors (Farmer, Manufacturer, Distributor, Retailer) can perform their respective operations.
- Created a state machine that tracks batches from harvest to retail shelf with timestamped transitions, and a consumer-facing Verification contract that returns full provenance data.
- Secret Mission: Added an event-driven AuditLog contract that emits granular supply chain events for off-chain analytics indexing.
Ready to quiz yourself?