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.
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#
- A beekeeper registers a batch with honey type, quantity, base price, extraction and expiry dates, a geotagged photo and a minimum portion size.
- Anyone can read a batch, its current owner and its lab verification status from the chain.
- A distributor can buy a whole batch or a portion, and a portion becomes a child batch that records its parent.
- A buyer pays by card or UPI in a sandbox, and ownership moves only after the server confirms the payment.
- A jar carries a QR code that opens its traceability page.
- A hive panel shows temperature, humidity, acoustic frequency and weight change, and alerts the beekeeper on risk.
Non-functional requirements#
- Tamper evidence. Custody and quantity changes must be public and permanent, with the transaction visible on a block explorer.
- No wallets for users. A beekeeper or a shopper should not need a browser wallet or testnet ETH.
- Fast pages. Listing batches must not wait several seconds for chain reads on every navigation.
- One deployable. Frontend and API in a single Vercel project, no extra servers to run.
- Works for the audience. A phone-first layout, an installable PWA and an English and Hindi toggle.
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 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.
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.
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 / setPriceByRetailerInrThe 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.
metadataCID as a small JSON string, which the next sections explain. Verification status is 0 unverified, 1 pending, 2 verified.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 camera frame and GPS fix go to
upload-photo. - Pinata pins the file and returns a CID.
- The form posts the harvest details with that CID to
register-batch. - The handler packs the details into the metadata string.
- The relayer signs
registerBatchFor; the handler reads the batch id out of theBatchRegisteredlog.
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.
- The page asks
/api/batchesor/api/verification-status/:id. - The handler returns the cached list if it is under two seconds old, or joins a read already in flight.
- 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 portal sends a portion and the buyer address; the handler reads the parent from chain.
- It rejects a portion that is zero, not smaller than the stock, or below the minimum portion in the metadata string.
- The relayer sends
splitBatchByVerifier(parentId, quantity, newOwner). - The contract decrements the parent and mints the child in the same transaction.
- The handler decodes the
BatchSplitreceipt log to returnnewBatchId.
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.
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.
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.
- The panel sends temperature, humidity, acoustics and weight change.
- The handler walks four rules and sets
riskLevel. - A non-LOW result is posted to ntfy with a priority header.
- The phone receives the push; the response shows the alert list on screen.
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.
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.
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.
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.
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.
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.
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.
One request, one rebuild#
- The distributor chooses a 10 kg portion of a 50 kg batch and pays at Cashfree's hosted checkout.
- The redirect lands on
verify-order, which asks Cashfree for the status and finds it paid. - The relayer sends
splitBatchByVerifierwith the parent id, 10 and the distributor address. - The contract lowers the parent to 40 kg, mints a child of 10 kg with
parentIdset, and emitsBatchSplit. - The handler decodes the child id, clears the batch cache and returns the receipt.
- 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
minSplitKgfield and arequireon it. - Return the real verification status.
/api/verification-status/:idreturns 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
riskLeveldirectly, 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_KEYis missing, and rotate any key that was ever committed.
Test yourself#
Why does HoneyLedge use a relayer wallet instead of user wallets?
- The contract can only be called by one address.
- Beekeepers and shoppers should not need keys or gas, so the server signs and the contract trusts it as a verifier.
- IPFS requires a wallet.
- Cashfree sends the ETH.
What does splitBatchByVerifier do to the parent batch?
- Deletes it and creates two children.
- Leaves it unchanged and creates a copy.
- Subtracts the portion from it and creates a child with
parentIdpointing back. - Marks it expired.
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?
- The redirect is user-controlled, so the server asks the gateway for the real order status before writing on chain.
- Cashfree charges per redirect.
- The contract rejects redirects.
- It is faster.
Where does the minimum portion size live?
- As a field in the Batch struct.
- In a Postgres table.
- In an environment variable.
- Inside the
metadataCIDstring as JSON, enforced by the server.
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?
- The contract stores every batch.
- Nothing checks a claim, such as quantity or lab result, before it is written; the ledger records what it is told.
- IPFS pins photos.
- The QR code is SVG.
What is the hive panel today?
- A real ESP32 sensor feed.
- A trained machine-learning model.
- Four sliders feeding a threshold rules engine that can push alerts to a phone.
- A blockchain oracle.