A consumer holding a bag of produce should be able to see where it came from, who held it, and what each holder paid. In a normal supply chain that record sits in several private databases, so nobody can check it independently. FarmLedge puts the record in a Solidity contract on the Arbitrum Sepolia testnet, keeps provenance documents on IPFS, and moves ownership on chain when a Stripe payment clears. It took our team to the National Finalist stage of Smart India Hackathon 2025. This post walks through the contract, the IPFS documents, the relayer, the Stripe webhook and the life of one batch, and it is blunt about where the access control is still open.
Here is the whole system on one board. I will return to it at the end.
One purchase, end to end#
The request that matters is a paid transfer. It touches the browser, the server, Stripe, the webhook, the relayer and the chain, and it is where payment becomes ownership.
/create-checkout-session, /webhook and processCheckoutSession in server/src/index.js.Understand the problem#
Smart India Hackathon problem statements are short and broad. Ours was agricultural traceability: follow a batch of produce from the farmer to the consumer, with prices at each hop, in a form that no single party can quietly edit. I started the prototype on 8 September 2025.
Functional requirements#
- A farmer can register a batch with crop, quantity, price, harvest and expiry dates, and a photo.
- A verifier can inspect a batch and mark it verified, with a note and a rating.
- A distributor, retailer or consumer can buy a verified batch, whole or in part, by card.
- A consumer can read the full trail of a batch: holders, prices, documents.
- A farmer is warned before an unsold batch expires.
Non-functional requirements#
- Tamper evidence. A holder must not be able to edit history after the fact.
- No wallet for buyers. Distributors and consumers should pay by card and never manage keys.
- No double payment effects. A Stripe retry must not move a batch twice.
- Cheap reads. Reading a batch must need no key and no gas.
- Documents off chain. Photos and notes are too large and too private to store in contract state.
- Testnet only. The whole system runs on Arbitrum Sepolia with Stripe in test mode.
Requirements: register, verify, pay and transfer, split, read, expiry alert Constraints : no buyer wallet, retries must be safe, documents off chain Nothing built yet. Next: entities, lifecycle and the data model.
The set-up#
Core entities#
The contract names a batch, its current owner and the people around it, and nothing else. There are no separate farmer or distributor objects: a batch stores one address per hop. A verifier is an address the contract owner has approved.
The life of a batch#
The status numbers are the contract's own: 0 unverified, 1 pending, 2 verified. The server refuses to open checkout unless the status reads verified, first from the chain and then from a local file used as a fallback.
The interface#
The contract is the data layer and the Express server is the interface. These are the routes that matter for this post.
POST /api/register-batch registerBatch / registerBatchFor via the relayer
POST /api/verification-status setVerificationStatus, file fallback
POST /create-checkout-session refuses unless the batch is verified
POST /webhook checkout.session.completed -> transfer or split
POST /api/confirm-payment same job, called from the return page
GET /api/batches every batch, read from the chain
GET /api/batch/:id one batch with its custody trail
POST /api/cron/check-expiry expiring batches -> WhatsApp via n8nThe data model#
The model is the Batch struct. It has 26 fields, which I split into three groups to read them: custody, money and time, and proof and lineage.
batches maps an id to this struct, and batchIds keeps the list. Prices are whole rupees as integers, with no decimals and no token.Build it one requirement at a time#
Each board keeps what the earlier boards built in grey and lights up what is new. The numbers on the edges match the list under each board.
Requirement 1: register a batch#
A farmer fills a form. The browser first pins the photo and a metadata JSON to IPFS through Pinata, then posts the fields and the CID to /api/register-batch. The server sends registerBatchFor from the relayer and reads the new id from the receipt's BatchRegistered log. If the browser could not pin, the server writes a literal meta: string with the fields instead of a CID.
registerBatchFor on the contract (4).- The browser pins the photo and a JSON of crop, quantity, price, dates, location and farmer address.
- It posts the batch fields and the CID to the server.
- The REST route hands the write to the relayer.
- The relayer signs
registerBatchFor, thensetMinPriceInrif a minimum was given.
Requirement 2: verify before anyone can buy#
The verifier page lists batches, fetches each batch's metadata from a Pinata gateway, and lets a verifier write a note and rating, attach a photo, and confirm the quantity they actually saw. That record is pinned as a second JSON and its CID is written on chain as verificationMetadataCID. setVerificationStatus can also overwrite quantityKg with the verified quantity.
/api/verification-status (2), and the relayer calls setVerificationStatus (3, 4). If that chain write fails, the server stores the status in a local JSON file instead (5).- The verifier pins a record: note, rating, photo CID, original and verified quantity.
- The browser posts the status and CID to the server.
- The REST route passes it to the relayer.
- The relayer calls
setVerificationStatuson the contract. - If that write fails, the server stores the status in
server/data/verification.jsonso the demo keeps working.
Requirement 3: pay, then move ownership#
A distributor picks a verified batch and a quantity. The browser asks the server for a Checkout session with the buyer's address and the purchase details in the session metadata. After the card clears, Stripe posts checkout.session.completed, and the server's shared function processCheckoutSession does the on-chain work. The buyer holds no wallet at any point.
transferOwnershipByVerifier (5).- The browser posts line items and metadata to create a session.
- The server creates the session on Stripe and returns its URL.
- Stripe posts the completed event to the webhook.
- The webhook passes the session to the relayer.
- The relayer reads the owner and calls
transferOwnershipByVerifierif the buyer is not yet the owner.
Requirement 4: buy part of a batch#
Real produce is sold in pieces, so a retailer cannot buy batch 7 whole. When the session metadata carries a split quantity, the relayer calls splitBatchByVerifier instead. The contract reduces the parent's quantity and creates a child with parentId set. The server then sets prices on the child with extra transactions: up to four sequential writes for one purchase, each waited on before the next.
splitBatchByVerifier (3) and then sets prices on the child with up to three more transactions.- Stripe reports a purchase whose metadata says
isSplitwith a quantity. - The webhook passes the session to the relayer.
- The relayer calls
splitBatchByVerifier, finds the child id in theBatchSplitlog and sets prices on it.
Requirement 5: read it back and warn before it spoils#
A consumer enters a batch id or scans a QR code and the server returns the struct with its custody trail. Separately, /api/cron/check-expiry walks every batch id, skips batches whose expiry is more than two days away or that the farmer no longer owns, looks up the farmer's phone in MongoDB and posts a payload to an n8n webhook that sends a WhatsApp message.
GET /api/batch/:id (1), which reads the struct from the chain (2). A cron route walks every batch id (3), looks up the farmer's subscription (4) and posts a WhatsApp job to n8n (5).- The consumer's browser calls
GET /api/batch/:id. - The server reads the struct from the contract.
- The cron route reads every batch with
getAllBatchIdsandbatches. - It looks up the farmer's subscription in MongoDB.
- It posts the alert to n8n, which sends the message.
Deep dives#
Deep dive 1: what goes on chain, and who may write it#
Put the documents in contract storage
Maximum tamper evidence and unusable cost: every byte is paid for in gas, and anything stored is public forever, including a farmer's location.
Store a content identifier on chain, the file on IPFS
A CID is a hash of the file, so the pointer pins down exactly what the file must contain. The chain stays small. The contract stores two such pointers per batch: metadataCID from the farmer and verificationMetadataCID from the verifier.
Pointers plus a role-gated write, with every sensitive call checked
The same pointers, but every function that changes custody, price or verification is gated by a role and checks that the batch exists. That is the shape the contract aims for and not what it fully achieves, as the table below shows.
transferOwnershipByVerifier does not check that the batch exists.Deep dive 2: the relayer#
Make every buyer sign
Honest, and impossible to sell: each buyer needs a wallet, a funded address on the right network and an understanding of gas.
One funded relayer key that is also the verifier
The server holds RELAYER_PRIVATE_KEY, builds a viem wallet client for Arbitrum Sepolia and signs every write. A one-time call from the contract owner approves it as a verifier. Buyers pay by card; the chain records the holder. This is what ships, and it makes the server the real root of trust.
A relayer that cannot act without proof
The relayer should only be able to execute what a buyer authorised: a signed order, or an escrow contract that holds the payment reference. Then a stolen key or a forged webhook could not move batches by itself.
Deep dive 3: making a webhook safe to retry#
Do the work on every delivery
A retry repeats the transfer. For a whole-batch purchase this is harmless only because the transfer is naturally repeatable; for a split it creates a second child and cuts the parent twice.
Two in-memory sets of session ids, plus an owner check
Check processedSessions and processingSessions by session id before acting. Before a standard transfer, also read the batch's owner and skip the write if the buyer already holds it. This is what I shipped, and it works for a single process that never restarts.
Persist the event, and make the chain effect idempotent
Store the Stripe event or session id in a database with a unique constraint and a status, record success only after the receipt confirms, and verify the Stripe signature first. For splits, put a payment reference in the contract call so the same reference cannot create a second child.
finally block marks a session processed even when the job failed.Deep dive 4: splitting a batch and keeping lineage#
Register a new batch for the 40 kg
The new batch has no link to the farm. Provenance, the entire point, is lost at the first partial sale.
Create a child that carries a parent pointer
splitBatchByVerifier reduces the parent's quantity and mints a child with parentId and isSplit set. The child copies crop, harvest date, metadata CID, expiry and verification state from the parent, and its role fields are filled from the parent's current holder. A retrace follows parentId until it reaches zero.
Also conserve quantity and price units
A test that the parent and child quantities always sum to the original, and a single definition of whether a price is per kilogram or per batch. Today the contract copies basePriceINR and minPriceINR unchanged while the server scales the carried-over distributor and retailer prices by the quantity fraction.
parentId, copies crop, harvest date, metadata and verification from the parent, and the parent's quantity drops by the split amount.Deep dive 5: who decides the price#
Build the session from what the browser sends
The amount, the receiving address, the role and the split quantity all come from the request body. A buyer can pay a rupee for a full batch or name any address as the recipient.
Gate the session on server-side facts
The server forces INR, enforces a floor of 100 paise and refuses unless the batch reads verified. That is what the checkout route does today, and it stops unverified batches, not a cheap buyer.
Derive the amount from the chain
Read the on-chain price and the quantity on the server, compute the amount there, and put the batch id and buyer in metadata the server itself signed or stored. The browser then only chooses a batch and a quantity.
The final design#
Here is the whole board again, used for the simplest request: a consumer reading a batch. It needs no key and no payment.
AgriTruthChain.sol.- The consumer enters a batch id or scans its QR code, and the browser calls
GET /api/batch/:id. - The server reads the
batchesmapping on Arbitrum Sepolia and returns custody, prices, dates and both CIDs. - The browser fetches the metadata and verification JSON from IPFS and renders the timeline from the chain fields and the documents.
What I would change#
- Verify the Stripe signature. The raw-body parser is already in place. Replace the
JSON.parsewith Stripe's signature check againstSTRIPE_WEBHOOK_SECRET, and drop the bypass header fromtest_webhook.js. - Make the webhook honest about failure. Read the result of
processCheckoutSession, return a non-2xx on failure, and mark a session done only on success, in a database, not memory. - Close the open doors.
/api/transfer,/api/verification-statusand/api/setup-relayer-as-verifierneed authentication.registerBatchForlets anyone register a batch for any farmer, andtransferOwnershipByVerifiershould require that the batch exists. - Derive the amount on the server. Compute the charge from the on-chain price and the quantity, and carry the buyer's address in server-stored data.
- Take the Pinata credentials out of the browser. The keys ship in the frontend bundle as
VITE_variables, and the verifier page has a gateway token written into its source. Pin through the server, rotate what has been committed, and use a gateway without a token in the URL. - Reduce the relayer's power. A signed purchase order or an escrow contract would let the relayer execute only what a buyer authorised.
Test yourself#
Why can a distributor buy a batch without owning a wallet?
- The contract accepts card numbers.
- The buyer pays through Stripe and the server's relayer, which the contract trusts as a verifier, signs the ownership transfer.
- Arbitrum Sepolia has no gas.
- The browser signs with a shared key.
transferOwnershipByVerifier on the buyer's behalf. The cost is that the server is the root of trust.What does storing a CID on chain give you that storing the file URL does not?
- Lower gas than any string.
- Automatic encryption.
- A hash of the file, so the pointer pins down exactly what the file must contain.
- A guarantee the file stays online.
In this codebase, why is a repeated whole-batch transfer harmless while a repeated split is not?
- The transfer path reads the current owner and skips the write if the buyer already holds the batch; the split path has no such check.
- Splits cost more gas.
- Transfers are signed twice.
- Stripe never retries transfers.
processCheckoutSession reads batches, compares the owner and skips. The split branch calls splitBatchByVerifier every time it runs.The webhook returns 200 and marks a session processed even when the transfer failed. What happens on Stripe's next delivery?
- The transfer succeeds.
- The batch is rolled back.
- Stripe stops retrying immediately.
- The handler answers skipped, so the failed transfer is never retried.
finally block moves the id into the processed set, so the retry takes the skipped path. A persisted status that is set only on success fixes it.What is the real consequence of the webhook not verifying the Stripe signature?
- Stripe stops sending events.
- Anyone who can reach the endpoint can post a forged completed event and make the relayer act on it.
- The amounts are rounded wrongly.
- Nothing, because the endpoint is HTTPS.
How does the contract decide that a new holder is a retailer?
- The holder registers a retailer profile.
- The holder pays more than the distributor.
- It is the next non-farmer holder after the distributor, inferred from position in the transfers.
- The verifier sets it.
_updateOwner fills the distributor first, then the retailer, then the consumer, in order. A wrong destination address therefore assigns the wrong role.