Simiyon Vinscent Samuel · Engineering

All posts / Blockchain

  • Blockchain
  • Arbitrum
  • Next.js
  • IoT
  • Solidity
  • System design

HoneyLedge: honey provenance on Arbitrum Sepolia with a hive-health dashboard

How HoneyLedge records honey batches in a Solidity contract, splits them into child batches that keep their lineage, settles Cashfree payments through a relayer, and raises hive alerts from a sensor simulator, plus the three-check QR gate and ESP32 sensors that are still only a proposal.

Honey is one of the easiest foods to fake and one of the hardest to check. A jar on a shelf gives you a label and nothing else, and a beekeeper who sells real honey cannot prove it to a buyer who cannot tell the difference. HoneyLedge is the project I built for that gap: a Next.js app that writes each honey batch to a smart contract on Arbitrum Sepolia, tracks who owns it and how it was split from a bigger batch, attaches a geotagged photo, and shows a hive-health dashboard fed by a sensor simulator. This post walks through how it works one requirement at a time, where it is solid, and where the proposal runs ahead of the code.

Everything between the portals and the chain is a handful of route handlers. There is no separate backend server. One relayer wallet signs every write, so users never hold keys; reads go straight to the contract. Numbers 1 to 3 are a harvest being registered, 4 and 5 are a consumer reading it back.
The request that matters is a purchase that becomes a child batch. The photo and registration are one-time; the purchase-and-split repeats at every hand-off. Press play or pick a step.

Understand the problem#

A buyer of raw honey faces three questions the label cannot answer: where it came from, whether it was tampered with on the way, and who held it. The hackathon proposal that started this project cites the 2020 CSE investigation, in which 17 of 22 tested honey samples were found adulterated with sugar syrup. I built the traceability half end to end on a testnet: the registry, the custody chain, the payments and the verification page. The sensor half is a simulation.

Functional requirements#

  1. A beekeeper registers a batch with honey type, quantity, base price, extraction and expiry dates, a geotagged photo and a minimum portion size.
  2. Anyone can read a batch, its current owner and its lab verification status from the chain.
  3. A distributor can buy a whole batch or a portion, and a portion becomes a child batch that records its parent.
  4. A buyer pays by card or UPI in a sandbox, and ownership moves only after the server confirms the payment.
  5. A jar carries a QR code that opens its traceability page.
  6. A hive panel shows temperature, humidity, acoustic frequency and weight change, and alerts the beekeeper on risk.

Non-functional requirements#

  1. Tamper evidence. Custody and quantity changes must be public and permanent, with the transaction visible on a block explorer.
  2. No wallets for users. A beekeeper or a shopper should not need a browser wallet or testnet ETH.
  3. Fast pages. Listing batches must not wait several seconds for chain reads on every navigation.
  4. One deployable. Frontend and API in a single Vercel project, no extra servers to run.
  5. Works for the audience. A phone-first layout, an installable PWA and an English and Hindi toggle.
On the board so far
Requirements: register, read, split, pay, QR, hive alerts
Constraints:  no user wallets, fast lists, one Vercel project
Missing:      anything that checks a claim before it is written
The HoneyLedge landing page with a hero reading Know your honey, from hive to home
The landing page. Blockchain, IoT and traceability in one pitch, with a Verify a Jar button that goes straight to the passport page.

The set-up#

Core entities#

The model is smaller than the problem sounds. There is one record type, the batch, and four roles that can hold it: beekeeper, distributor, retailer and consumer. A verifier is an address the contract trusts to move honey and set lab status on behalf of others; in this app it is the relayer. A split batch points at its parent.

Provenance is a linked list of batches. A jar traces back through its parents to the beekeeper who registered the root batch.

The interface#

The API is a set of Next.js route handlers, and the interface that matters is the contract. These are the real entry points, trimmed to what the app calls.

Text
POST /api/register-batch        { honeyType, quantityKg, basePriceINR, minSplitKg, ... } -> { ok, batchId, tx }
POST /api/mock-payment          { batchId, toAddress, isSplit, splitQuantity }           -> { success, newBatchId, tx }
POST /api/cashfree/create-order { batchId, role, quantityKg, ... }                      -> { orderId, paymentSessionId }
GET  /api/cashfree/verify-order ?order_id=...                                           -> { isPaid, onChainSettlement }
GET  /api/batches               ?fresh=true                                             -> { batches, total, cached }
GET  /api/verification-status/:id
POST /api/hive/iot/simulate     { temp, humidity, acoustics, weightChange, batchId }    -> { reading: { riskLevel, alerts } }
GET  /api/qr                    ?batchId=1&format=svg|png|json

contract HoneyChain
  registerBatchFor(beekeeper, honeyType, quantityKg, basePriceINR, extractionDate, metadataCID, expiryDate)
  splitBatchByVerifier(parentBatchId, splitQuantity, newOwner) returns (newBatchId)
  transferOwnershipByVerifier(batchId, to)
  setVerificationStatus(batchId, status, verificationMetadataCID, verifiedQuantity)
  setMinPriceInr / setPriceByDistributorInr / setPriceByRetailerInr

The data model#

Everything on chain is one 26-field Batch struct in a mapping keyed by id, plus an array of ids for enumeration. I draw it as three tables because the fields fall into three jobs: what the honey is, who held it when, and whether it was checked and where it came from.

The minimum portion size is not a contract field. It travels inside metadataCID as a small JSON string, which the next sections explain. Verification status is 0 unverified, 1 pending, 2 verified.
Small on purpose. The whole write path is one contract and a few route handlers; most of the project is the screens around it.

Build it one requirement at a time#

1. Register a batch#

A beekeeper fills in the harvest form and takes a photo. The browser asks for GPS with navigator.geolocation, and /api/ipfs/upload-photo pins the image to Pinata with the latitude, longitude and beekeeper id as key-values. The returned CID goes into register-batch, which wraps it, the minimum portion size and the harvest details into a meta: JSON string and asks the relayer to call registerBatchFor. The contract assigns the next id, makes the beekeeper the current owner and emits BatchRegistered.

The first board is the write path for one batch. Everything here is new; later boards grey these parts out.
  1. The camera frame and GPS fix go to upload-photo.
  2. Pinata pins the file and returns a CID.
  3. The form posts the harvest details with that CID to register-batch.
  4. The handler packs the details into the metadata string.
  5. The relayer signs registerBatchFor; the handler reads the batch id out of the BatchRegistered log.

After the receipt the handler calls invalidateBatchesCache(), so the next list request sees the new batch instead of a cached copy.

2. Read a batch and verify it#

Reading needs no wallet and no relayer. /api/batches calls getAllBatchIds, then reads each batch with a batches(id) call in parallel and turns the tuple into named fields. The verify page takes a batch id or a scanned QR link and draws a six-stage timeline: survey and hive inspection, harvest and extraction, a lab purity assay, on-chain anchoring, distributor transit and sealing to the consumer. The last two stages light up as the contract's distributor and consumer slots fill.

Reads are public and cheap, but not instant. A short in-memory cache and a shared in-flight request keep ten simultaneous visitors from causing ten rounds of chain reads.
  1. The page asks /api/batches or /api/verification-status/:id.
  2. The handler returns the cached list if it is under two seconds old, or joins a read already in flight.
  3. Otherwise it reads ids, then each batch, from the contract and decodes the metadata string.

3. Split a batch and keep the lineage#

The distributor portal shows each batch with a minimum purchase, which the beekeeper set at registration. Buying the full lot is a plain ownership transfer. Buying a portion calls splitBatchByVerifier, which does four things in one transaction: it checks the parent holds strictly more than the portion, subtracts the portion from the parent, creates a child with the same honey type, dates, photo CID and verification fields, and sets parentId and isSplit on the child. The child's holder slots depend on who the parent's holder is: beekeeper to distributor, distributor to retailer, retailer to consumer.

The split is atomic on chain. The server validates the minimum portion before spending gas, but the contract is what keeps the quantities consistent.
  1. The portal sends a portion and the buyer address; the handler reads the parent from chain.
  2. It rejects a portion that is zero, not smaller than the stock, or below the minimum portion in the metadata string.
  3. The relayer sends splitBatchByVerifier(parentId, quantity, newOwner).
  4. The contract decrements the parent and mints the child in the same transaction.
  5. The handler decodes the BatchSplit receipt log to return newBatchId.

4. Pay, then settle#

The checkout is a Cashfree sandbox hosted-checkout order. create-order stores the batch, role, quantity and destination address, both as order tags and in a local fallback because the sandbox sometimes drops tags. After the buyer pays, verify-order does not trust the redirect: it fetches the order status from Cashfree's API, and only when isPaid is true does it call the relayer to transfer or split. Transient sandbox errors are retried with a linear back-off of 600 ms per attempt.

The browser redirect is a hint, never proof. The settlement is guarded by a map of settled order ids so a refresh does not pay out twice, but that map lives in memory, which I come back to below.

5. Jar QR codes#

/api/qr renders a honey-coloured QR in SVG, PNG or JSON, defaulting to dark brown on pale yellow, and encodes /verify?id=<batch> on the app's own URL. A consumer jar is just another batch id, so the code on a sealed jar leads to a page that shows the child batch, its parent and the root beekeeper.

The verify page showing a sealed jar owned by a masked consumer wallet and a Pinata IPFS sealed photo
A sealed jar. Batch 9 is a child split from batch 6, held by a masked consumer address, with its Arbitrum block number and a Pinata-sealed photo. The consumer address is masked on purpose.

6. Hive telemetry and alerts#

The hive panel has four sliders, each with a range and a shaded normal band: brood temperature from 25 to 45 C, humidity from 30 to 95 percent, acoustic frequency from 150 to 700 Hz and weight change from minus 10 to plus 10 kg. A button posts the four values to /api/hive/iot/simulate, which applies fixed thresholds and returns a risk level, a list of alerts and a disease hint. For anything above LOW it also posts to an ntfy topic, with priority urgent for CRITICAL, high for HIGH and default otherwise, so the phone buzzes.

No hardware yet, but the alert path is real. The readings are slider values; the rules run on the server and the push notification reaches an actual phone.
  1. The panel sends temperature, humidity, acoustics and weight change.
  2. The handler walks four rules and sets riskLevel.
  3. A non-LOW result is posted to ntfy with a priority header.
  4. The phone receives the push; the response shows the alert list on screen.
The Hive Intelligence dashboard with four simulated sensor sliders and an AI diagnostics panel
The hive panel. Defaults are a stressed hive: 38.5 C, 82 percent humidity, 480 Hz and a 3.2 kg drop. The Simulated tag is on the screen.
Example values from the panel defaults. The text of each alert is the string the handler returns; the weight rule is the last to run and sets CRITICAL.

Deep dive 1: one string carries the metadata#

Redeploy with a minSplitKg field

This is the clean design, and it would put the rule in the one place nobody can bypass. It also costs a new contract address, which means re-seeding every demo batch and breaking every printed QR link that carries an old address. During the build I redeployed the contract more than once for seed-data reasons, so the cost was not hypothetical.

Keep it in a database

A separate table would work, but the project's constraint was one Vercel project with no database. It would also make the minimum a private fact: a buyer reading the chain could not see the beekeeper's rule.

Pack it into metadataCID as JSON

The contract already stores a free-form string per batch. The server writes meta:{...} with the photo CID, minSplitKg, honey type, quantity, price and dates, and every reader parses it. The rule is public, costs no new field and survives a redeploy of the UI. It is enforced by the server rather than the contract, which is its real weakness: anyone calling the contract directly as a verifier could split below the minimum.

Three readers, two defaults. The list defaults the minimum to 5 kg when the string is missing; the payment handler defaults to 1 kg. The two paths agree for every batch registered through the app, because it always writes the value, but they would diverge for a batch registered some other way.

Deep dive 2: a chain that is slow to read#

Read the chain on every request

Each page load costs one id read plus one call per batch, so the cost grows with both traffic and the number of batches. Under a demo with a few people clicking between tabs this feels slow, and it burns the public RPC's rate limits.

A time-based cache

A fixed TTL fixes repeated reads but leaves a gap: right after a registration or a split, the cache is stale, and the user who just bought the honey sees the old owner.

Short TTL, shared in-flight promise, explicit invalidation

lib/batchCache.ts holds the last result for 2,000 ms and one in-flight promise. A second request arriving mid-read awaits the same promise instead of starting its own. Every write handler calls invalidateBatchesCache() after the receipt, and ?fresh=true bypasses the cache. The price is that it is per server instance, which is fine for a demo and wrong at scale.

Coalescing turns a stampede into one request. The same trick appears in any read-through cache over something slow.

Deep dive 3: settling a payment without double-paying#

Settle when the redirect lands

The redirect URL is user-controlled. Anyone could open it with another order id, or without paying at all. It also fires every time the page reloads.

Verify with the gateway, then settle

This is what verify-order does: it asks Cashfree for the order status, treats PAID or a successful payment as paid, and only then writes. It closes the forgery hole.

Verify, then guard with a durable idempotency key

The order id is a natural key. A settlement record keyed by order id, stored where every instance can see it, makes a repeat call return the earlier receipt. The code has the key and a map, but the map is a module-level Map, so it forgets on a cold start and is not shared across instances. That is the gap between the second and third rows.

The built version stops forged redirects but not a cold-start replay. A stored order id, for example in Redis or a table, would close the last gap.

Deep dive 4: the relayer, and who may move honey#

Make every user bring a wallet

It is the purest design and the worst product. A beekeeper in a field is not going to install a browser wallet and fund it with testnet ETH.

One shared signer inside the server

Users act through the app, and the app signs. The contract's verifiers mapping lists addresses allowed to call transferOwnershipByVerifier, splitBatchByVerifier and setVerificationStatus. This is what I built, with a fallback transport over three RPC endpoints so a flaky endpoint does not drop a transaction.

Per-role signing, or account abstraction

The better design gives each role its own key or smart account, so the chain records who actually acted. It is more work than a prototype needed, and it is the first thing I would add next.

The contract trusts the relayer completely. The check is on the caller address, so the security of the whole supply chain is the security of that one key.

Deep dive 5: telemetry rules, and the proposal's three-check gate#

Record what the beekeeper types

This is what the app does today. The contract stores quantity and a photo CID, and nothing stops an inflated quantity.

Alert on hive anomalies

The simulator applies thresholds to temperature, humidity, sound and weight change and raises an alert. It protects the colony, not the ledger.

Gate the QR on three checks (planned)

The SIH 2026 proposal issues a batch QR only if three checks pass: the hive's sensor-recorded weight gain must cover the declared harvest, a processor cannot ship more than it received, and a lab report against FSSAI honey parameters must be attached. Any failure flags the batch to the admin dashboard instead of issuing a QR. This is the idea that makes the ledger trustworthy, and it is not in the contract yet.

Trust comes from checks that happen before the write. The gate is a design from the SIH 2026 proposal; the contract today stores a verification status and a quantity override but not these rules.

The proposal pairs the gate with ESP32 sensor nodes covering about ten boxes each, at a bill-of-materials estimate of around 3,000 rupees per node, so about 300 rupees per box. It stores the hash of each record on Arbitrum Sepolia and keeps full records off chain, which fits the data-protection law the proposal cites. The first pilot it describes is one KVIC cluster of about 100 boxes with 10 sensor nodes and one lab-linked processor. The 36-hour MVP in the proposal is the simulated sensor feed, the batch registry, the gate and the scan page; the code in this post covers the registry, the scan page and the simulated feed.

A hackathon slide showing the planned system architecture with an IoT data layer, intelligence layer, database layer and a blockchain trust layer, plus an offline-first data path
The planned architecture. From the SIH 2026 proposal: an ESP32 sensor layer, ML analytics, PostgreSQL and Redis, GCS or IPFS storage, and the blockchain plus QR trust layer. The shipped app is the right-hand slice of this.
Glowing nodes are the proposal; grey ones run today. Today the sliders feed a rules engine, and the chain stores whatever the registration form says.

The final design#

Pulling the parts together, the shipped system is a stateless web tier in front of a single contract, with the relayer as the only writer and IPFS as the only file store. Reads are public; writes are verified by the app first.

Nothing in this board holds state except the chain and IPFS. The cache is a convenience and the settled-orders map is the one piece of memory that should become durable.

One request, one rebuild#

  1. The distributor chooses a 10 kg portion of a 50 kg batch and pays at Cashfree's hosted checkout.
  2. The redirect lands on verify-order, which asks Cashfree for the status and finds it paid.
  3. The relayer sends splitBatchByVerifier with the parent id, 10 and the distributor address.
  4. The contract lowers the parent to 40 kg, mints a child of 10 kg with parentId set, and emits BatchSplit.
  5. The handler decodes the child id, clears the batch cache and returns the receipt.
  6. The next list request misses the cache, reads the chain once and every viewer sees the new owner.

What I would change#

  • Enforce the rules in the contract. The minimum portion, the three-check gate and the lab status belong in Solidity, not in route handlers that a direct caller can skip. I would add a proper minSplitKg field and a require on it.
  • Return the real verification status. /api/verification-status/:id returns a constant "verified" alongside the batch and does not return the contract's verification fields. The README describes a live lab-assay lookup that the handler does not do. I would return the real status, verifier and timestamp.
  • Fix the risk-level order. In the simulator each rule assigns riskLevel directly, so a later, milder rule can overwrite a worse one: a hot, humid hive ends at MEDIUM unless weight forces CRITICAL. A max over levels would fix it.
  • Make the two rule sets agree. The sliders show a normal band of 34 to 36 C and the server alerts at below 32 and above 37; the picture and the rules should come from one table.
  • Durable settlement. Store the settled order id in Redis or a table, and add a contract-side idempotency check.
  • Real roles. The login page issues fixed demo addresses with a prefilled one-time code. Real sign-in, and per-role signing keys, come before any pilot.
  • A genuine sensor path. Replace the sliders with an ESP32 posting to an ingest route, as the proposal describes, and let the three-check gate read that data.
  • Remove the baked-in key. Fail closed when RELAYER_PRIVATE_KEY is missing, and rotate any key that was ever committed.
The payment integration landed in one long day and the fixes took longer. Most of the later commits are about stale caches, tuple indices and the split math.
The distributor procurement hub listing honey batches with minimum purchase, lab purity and Buy Full or Portion actions
The distributor market. Each card shows the farmer base price, stock, the minimum purchase set by the beekeeper and the lab purity, with Buy Full and Portion actions.
A hackathon slide describing HoneyChain and the three-check QR gate
The proposal in one slide. The three-check gate is the part of this design that is not yet in the code.

Test yourself#

Why does HoneyLedge use a relayer wallet instead of user wallets?

  1. The contract can only be called by one address.
  2. Beekeepers and shoppers should not need keys or gas, so the server signs and the contract trusts it as a verifier.
  3. IPFS requires a wallet.
  4. Cashfree sends the ETH.
B. The contract exposes verifier-only functions, and the relayer is registered as a verifier. Reads stay public and need no wallet.

What does splitBatchByVerifier do to the parent batch?

  1. Deletes it and creates two children.
  2. Leaves it unchanged and creates a copy.
  3. Subtracts the portion from it and creates a child with parentId pointing back.
  4. Marks it expired.
C. The parent keeps the remainder, the child gets the portion and the same origin data, and isSplit and parentId record the lineage. It requires the parent to hold strictly more than the portion.

Why does verify-order call Cashfree instead of trusting the redirect?

  1. The redirect is user-controlled, so the server asks the gateway for the real order status before writing on chain.
  2. Cashfree charges per redirect.
  3. The contract rejects redirects.
  4. It is faster.
A. A redirect can be forged or replayed. The gateway status is the source of truth, and the settled-order map prevents a repeat in the same instance.

Where does the minimum portion size live?

  1. As a field in the Batch struct.
  2. In a Postgres table.
  3. In an environment variable.
  4. Inside the metadataCID string as JSON, enforced by the server.
D. The contract has no minSplitKg field, so the server writes it into the metadata string and checks it before calling the relayer. That means a direct verifier call could skip it.

What is the weakest guarantee in the current code?

  1. The contract stores every batch.
  2. Nothing checks a claim, such as quantity or lab result, before it is written; the ledger records what it is told.
  3. IPFS pins photos.
  4. The QR code is SVG.
B. The proposal answers this with a three-check gate (yield ceiling, mass balance, lab test), which is planned and not enforced by the contract today.

What is the hive panel today?

  1. A real ESP32 sensor feed.
  2. A trained machine-learning model.
  3. Four sliders feeding a threshold rules engine that can push alerts to a phone.
  4. A blockchain oracle.
C. The readings are simulated and the analysis is a fixed rule table; the ESP32 nodes and ML analytics are in the proposal, not the code.