From field to plate — Every production batch, transformation, transport and store reception is recorded immutably on the VeChainThor blockchain. The end consumer scans a QR code to view the full product history.
- Overview
- Architecture
- Tech Stack
- VET / VTHO Model
- Fee Delegation (VIP-191)
- Prerequisites
- Installation
- Quick Start
- Running Tests
- Deployment
- Demo Script
- Frontend
- Smart Contracts
- Project Structure
- Known Issues
- License
Chadenn implements a supply chain traceability system on VeChainThor. Each food batch travels through a chain of actors, each recording a checkpoint on-chain:
Producer → Processor → Logistician → Distributor → Consumer (read-only)
Key features:
- Batch split (1 pallet → N boxes) and merge (N ingredients → 1 product)
- Full provenance tree reconstruction across splits and merges
- IPFS references for documents (certificates, photos) — only the CID is stored on-chain
- Public consumer page — no wallet required to view a product's history
- Fee delegation (VIP-191) — actors don't need to hold VTHO; the operator sponsors gas
┌─────────────────────────────────────────────────────────────┐
│ FRONTEND (React + Vite) │
│ │
│ / Landing + role detection │
│ /batch/:id Public consumer page (no wallet) │
│ /producer Create batch │
│ /processor Transform / split / merge │
│ /logistician Record transport checkpoint │
│ /distributor Confirm store reception │
│ /admin Manage roles + generate QR codes │
└─────────────────────────────────────────────────────────────┘
│ @vechain/dapp-kit-react v2
│ ABIContract + Clause (sdk-core)
▼
┌─────────────────────────────────────────────────────────────┐
│ VECHAIN THOR NODE │
│ Solo (local Docker) / Testnet / Mainnet │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SMART CONTRACTS │
│ │
│ RoleManager.sol OpenZeppelin AccessControl │
│ PRODUCER_ROLE PROCESSOR_ROLE │
│ LOGISTICIAN_ROLE DISTRIBUTOR_ROLE │
│ │
│ BatchRegistry.sol Core traceability logic │
│ createBatch() → PRODUCER_ROLE │
│ splitBatch() → PROCESSOR_ROLE (1 → N) │
│ addTransformation() → PROCESSOR_ROLE (N → 1) │
│ addTransport() → LOGISTICIAN_ROLE │
│ addReception() → DISTRIBUTOR_ROLE │
│ getBatchHistory() → public view (free) │
└─────────────────────────────────────────────────────────────┘
Batch {
batchId bytes32 // keccak256(actor + timestamp + nonce)
creator address
productType string
parcelId string
harvestDate uint256 // block.timestamp (±10s precision on VeChainThor)
ipfsRef string // IPFS CID of associated documents
parentIds bytes32[] // source batches (split: 1 parent, merge: N parents)
childIds bytes32[] // derived batches
history Checkpoint[] // ordered chronologically
}
Checkpoint {
cpType enum // CREATION | TRANSFORMATION | TRANSPORT | RECEPTION
actor address
timestamp uint256
ipfsRef string
extraData bytes // ABI-encoded — see table below
}
| Checkpoint type | extraData encoding |
|---|---|
TRANSFORMATION |
abi.encode(bytes32[] inputBatchIds, string process) |
TRANSPORT |
abi.encode(int16 tempMin, int16 tempMax, uint32 durationMinutes) |
RECEPTION |
abi.encode(string storeId, string location) |
| Layer | Technology |
|---|---|
| Smart contracts | Solidity 0.8.20, OpenZeppelin v5 |
| Dev environment | Hardhat + @vechain/sdk-hardhat-plugin v1.2 |
| VeChain SDK | @vechain/sdk-core, @vechain/sdk-network |
| Local node | VeChain Thor solo node via Docker |
| Tests | Hardhat / Mocha / Chai (44 tests) |
| Frontend | React 18, TypeScript, Vite 5 |
| Wallet integration | @vechain/dapp-kit-react v2, VeWorld |
| QR codes | qrcode.react |
| Routing | React Router v6 |
VeChainThor uses a dual-token model:
| Token | Role |
|---|---|
| VET | Value token — used for value transfers between accounts |
| VTHO | Gas token — consumed to execute transactions (smart contracts) |
VTHO is automatically generated by every account holding VET at a rate of 5×10⁻⁸ VTHO per VET per block. This decouples gas costs from the network's value token, keeping transaction fees predictable.
Fee delegation allows a third party (the delegator, typically the system operator) to pay VTHO on behalf of transaction senders. This means actors in the supply chain (farmers, processors, logisticians…) don't need to hold any VTHO to use the system.
How it works:
- The actor signs the transaction
- The delegator co-signs (commits to paying the gas)
- The transaction is broadcast — VTHO is deducted from the delegator, not the actor
Configuration: set DELEGATOR_URL in .env to enable delegation.
Without it, actors pay their own VTHO (standard mode).
Large files (photos, PDF certificates) are not stored on-chain — only the IPFS CID is recorded in ipfsRef. The frontend integrates Pinata's free tier for uploading files directly from the browser.
- Create an account at app.pinata.cloud
- Go to API Keys → + New Key — generate a JWT with
pinFileToIPFSpermission - Add to
frontend/.env:
VITE_PINATA_JWT=eyJhbGci...
VITE_PINATA_GATEWAY=https://gateway.pinata.cloud # optionalEach dashboard form has a file upload button. When a file is selected:
- It is uploaded to Pinata via
POST https://api.pinata.cloud/pinning/pinFileToIPFS - The returned CID is automatically filled in the form
- The CID is stored on-chain in the
ipfsReffield of the checkpoint - The consumer page renders a clickable link to view the file at
gateway.pinata.cloud/ipfs/{CID}
If VITE_PINATA_JWT is not set, the upload button shows a hint and you can paste a CID manually.
| Limit | Value |
|---|---|
| Storage | 1 GB |
| Files | 100 uploads / month |
| Bandwidth | Unlimited (public gateway) |
Sufficient for a pilot with ~300 lots/year at ~3 MB per lot.
- Node.js ≥ 18
- Docker + Docker Compose (for the local solo node)
- VeWorld browser extension (for wallet connection in the frontend)
git clone https://github.com/your-username/chadenn.git
cd chadenn
# Install all dependencies (root + frontend via workspaces)
npm install
# Copy environment file and fill in your values
cp .env.example .env# 1. Start the local VeChain solo node
docker compose up -d
# 2. Compile contracts + generate TypeScript types
npm run compile
# 3. Run the full demo (deploys contracts, records a complete lifecycle, prints the consumer URL)
npm run demo
# 4. Start the frontend
cd frontend
cp .env.example .env # addresses are auto-filled by npm run demo
npm run dev
# → open http://localhost:5173Tip:
npm run demoautomatically writes contract addresses intofrontend/.env.
Just runcd frontend && npm run devafterwards.
Tests run against the built-in Hardhat network — no Docker required.
npm test BatchRegistry
createBatch ✔ 9 tests
addTransformation ✔ 7 tests
splitBatch ✔ 5 tests
addTransport ✔ 4 tests
addReception ✔ 2 tests
full lifecycle ✔ 2 tests
unauthorized writes ✔ 5 tests
RoleManager ✔ 11 tests
44 passing (2s)
Coverage report:
npm run test:coverage# Start Docker first
docker compose up -d
# Set SOLO_PRIVATE_KEY in .env (see pre-funded accounts in VeChain SDK docs)
npm run deploy:solo
# → deployments/solo.json# Fund your account via https://faucet.vecha.in/
# Set TESTNET_PRIVATE_KEY in .env
npm run deploy:testnet
# → deployments/testnet.json
# → verify on https://explore-testnet.vechain.org| Variable | Description |
|---|---|
SOLO_PRIVATE_KEY |
Private key for the local solo node deployer |
SOLO_URL |
Solo node URL (default: http://localhost:8669) |
TESTNET_PRIVATE_KEY |
Private key for testnet deployments |
TESTNET_URL |
Testnet URL (default: https://testnet.vechain.org) |
DELEGATOR_URL |
Fee delegator server URL (optional) |
REPORT_GAS |
Set to true to print gas usage per function |
The demo script simulates a complete product lifecycle on-chain:
Harvest (tomatoes) → Split into lots → Create basil batch
→ Merge Lot A + Basil → Tomato basil sauce
→ Transport to store → Store reception
→ Consumer reads full history + provenance tree
npm run demoOutput includes a ready-to-use consumer URL:
→ http://localhost:5173/batch/0x5477f0071ac5be8755f...
If deployments/solo.json already exists, the demo reuses the deployed contracts instead of redeploying — keeping the consumer URL consistent with the frontend.
After a Docker restart (blockchain state is lost):
rm deployments/solo.json npm run demo
cd frontend
cp .env.example .env # fill VITE_BATCH_REGISTRY_ADDRESS and VITE_ROLE_MANAGER_ADDRESS
npm run dev # → http://localhost:5173| Route | Description | Wallet required |
|---|---|---|
/ |
Landing page + role detection | No |
/batch/:id |
Full product history + QR code | No |
/producer |
Create a new production batch | Yes |
/processor |
Transform, split or merge batches | Yes |
/logistician |
Record transport checkpoint | Yes |
/distributor |
Confirm store reception + generate QR | Yes |
/admin |
Grant/revoke roles + QR code generator | Yes |
| Variable | Description |
|---|---|
VITE_NODE_URL |
VeChain node URL (default: http://localhost:8669) |
VITE_GENESIS |
Network: solo | test | main |
VITE_BATCH_REGISTRY_ADDRESS |
Deployed BatchRegistry address |
VITE_ROLE_MANAGER_ADDRESS |
Deployed RoleManager address |
Extends OpenZeppelin AccessControl. Only the DEFAULT_ADMIN_ROLE can grant or revoke actor roles.
function grantActorRole(bytes32 role, address account) external onlyRole(DEFAULT_ADMIN_ROLE)
function revokeActorRole(bytes32 role, address account) external onlyRole(DEFAULT_ADMIN_ROLE)Accepted roles: PRODUCER_ROLE, PROCESSOR_ROLE, LOGISTICIAN_ROLE, DISTRIBUTOR_ROLE.
// Create a new production batch (PRODUCER_ROLE)
function createBatch(
string calldata productType,
string calldata parcelId,
uint256 harvestDate, // 0 = use block.timestamp
string calldata ipfsRef,
bytes32[] calldata parentIds // empty for fresh batch, [parentId] for split child
) external returns (bytes32 batchId)
// Transform N input batches into 1 output batch (PROCESSOR_ROLE)
function addTransformationCheckpoint(
bytes32[] calldata inputBatchIds,
string calldata productType,
string calldata ipfsRef,
string calldata process
) external returns (bytes32 outputBatchId)
// Split 1 batch into N children — minimum 2 (PROCESSOR_ROLE)
function splitBatch(
bytes32 parentBatchId,
SplitParams[] calldata children
) external returns (bytes32[] memory childBatchIds)
// Record transport (LOGISTICIAN_ROLE)
function addTransportCheckpoint(
bytes32 batchId, int16 tempMin, int16 tempMax,
uint32 durationMinutes, string calldata ipfsRef
) external
// Record store reception (DISTRIBUTOR_ROLE)
function addReceptionCheckpoint(
bytes32 batchId, string calldata storeId,
string calldata location, string calldata ipfsRef
) external
// Read full batch history — public, free, no wallet needed
function getBatchHistory(bytes32 batchId) external view returns (BatchInfo memory)| Event | Emitted by |
|---|---|
BatchCreated(batchId, creator, productType) |
createBatch, addTransformationCheckpoint, splitBatch |
CheckpointAdded(batchId, cpType, actor, timestamp) |
All write functions |
BatchSplit(parentId, childIds[]) |
splitBatch |
BatchMerged(parentIds[], outputId) |
addTransformationCheckpoint (N > 1 inputs) |
chadenn/
├── contracts/
│ ├── interfaces/
│ │ ├── IBatchRegistry.sol Public interface + type definitions
│ │ └── IIPFSStorage.sol Mockable IPFS storage interface
│ ├── RoleManager.sol
│ └── BatchRegistry.sol
│
├── test/
│ ├── RoleManager.test.ts 11 tests
│ └── BatchRegistry.test.ts 33 tests (lifecycle, split/merge, access control)
│
├── scripts/
│ ├── _vechain-deploy.ts VeChain deployment helper (address fix)
│ ├── deploy-solo.ts Deploy to local solo node
│ ├── deploy-testnet.ts Deploy to VeChain testnet
│ └── demo.ts Full lifecycle simulation
│
├── frontend/
│ └── src/
│ ├── hooks/
│ │ ├── useBatchRegistry.ts Contract reads + writes
│ │ └── useRoles.ts Role detection + management
│ ├── pages/
│ │ ├── BatchPublicPage.tsx /batch/:id (public)
│ │ ├── ProducerDashboard.tsx
│ │ ├── ProcessorDashboard.tsx
│ │ ├── LogisticianDashboard.tsx
│ │ ├── DistributorDashboard.tsx
│ │ └── AdminDashboard.tsx
│ ├── components/
│ │ ├── Timeline.tsx Checkpoint timeline
│ │ ├── QRCodeDisplay.tsx QR code generator
│ │ ├── Navbar.tsx
│ │ └── TxStatus.tsx Transaction feedback
│ ├── types/index.ts
│ ├── utils/helpers.ts ABI decoding, formatting
│ └── config.ts Addresses + role hashes
│
├── deployments/ Generated after deploy (gitignored)
├── docker-compose.yml VeChain Thor solo node
├── hardhat.config.ts Networks: vechain_solo, vechain_testnet
├── .env.example
└── README.md
@vechain/sdk-hardhat-plugin v1.x computes deployed contract addresses using the Ethereum formula (keccak256(rlp([sender, nonce]))), but VeChainThor uses a different derivation. As a result, ContractFactory.deploy().getAddress() returns an incorrect address.
Fix: scripts/_vechain-deploy.ts works around this by sending the deployment transaction manually via signer.sendTransaction() and reading the actual address from the VeChain REST API receipt (outputs[0].contractAddress).
The VeChain Hardhat plugin does not populate receipt.logs in the standard Ethers.js format. Events must be fetched from the VeChain REST API (GET /transactions/:txid/receipt) and decoded manually. The helper function getVeChainEvents() in _vechain-deploy.ts handles this.
VeChainThor has a ~10-second block interval. block.timestamp is reliable for ordering but not for sub-minute precision. This is documented in the contract source.
- Fork the repository
- Create a feature branch:
git checkout -b feat/my-feature - Commit your changes (run
npm testfirst) - Open a pull request