Simiyon Vinscent Samuel · Engineering

All posts / Solidity

  • Solidity
  • Blockchain
  • TypeScript
  • Stripe
  • IPFS
  • Smart India Hackathon

FarmLedge: farm-to-consumer traceability on a testnet

FarmLedge, a Smart India Hackathon 2025 national finalist: batches on an Arbitrum Sepolia contract, provenance on IPFS, a relayer that moves ownership after a Stripe payment, and an honest look at the webhook idempotency and access-control gaps.

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.

The server is a relayer, not a database. The numbered orange path is a purchase: the browser asks for a checkout session (1), the server creates it on Stripe (2), Stripe calls the webhook when the card clears (3), the webhook hands the job to the relayer (4), and the relayer writes the transfer to the contract (5). The green edge is the browser pinning documents to IPFS itself. The frontend runs on Vercel; the server has run on Railway and on Render.

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.

The buyer never signs a blockchain transaction. Press play or pick a step. The path follows /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#

  1. A farmer can register a batch with crop, quantity, price, harvest and expiry dates, and a photo.
  2. A verifier can inspect a batch and mark it verified, with a note and a rating.
  3. A distributor, retailer or consumer can buy a verified batch, whole or in part, by card.
  4. A consumer can read the full trail of a batch: holders, prices, documents.
  5. A farmer is warned before an unsold batch expires.

Non-functional requirements#

  1. Tamper evidence. A holder must not be able to edit history after the fact.
  2. No wallet for buyers. Distributors and consumers should pay by card and never manage keys.
  3. No double payment effects. A Stripe retry must not move a batch twice.
  4. Cheap reads. Reading a batch must need no key and no gas.
  5. Documents off chain. Photos and notes are too large and too private to store in contract state.
  6. Testnet only. The whole system runs on Arbitrum Sepolia with Stripe in test mode.
On the board so far
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.

One struct carries every role. The contract has no separate farmer, distributor or retailer objects: a batch stores one address per hop and a current owner. Documents never enter the struct; only their content identifiers do.

The life of a batch#

Verification is the gate to payment. A batch is registered unverified, a verifier moves it to pending and then verified, and only then does the server open a checkout session. After that each hop repeats the pay-and-transfer path, and a partial purchase takes the split branch.

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.

Text
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 n8n

The 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.

Twenty-six fields, one mapping. batches maps an id to this struct, and batchIds keeps the list. Prices are whole rupees as integers, with no decimals and no token.
A small contract behind a large server. Most of the logic that decides who gets a batch lives in the Express file, not in Solidity.

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.

The browser pins, the server writes. The farmer's browser pins the photo and a metadata JSON to IPFS (1) and posts the resulting CID with the batch fields (2). The server hands the call to its relayer (3), which signs registerBatchFor on the contract (4).
  1. The browser pins the photo and a JSON of crop, quantity, price, dates, location and farmer address.
  2. It posts the batch fields and the CID to the server.
  3. The REST route hands the write to the relayer.
  4. The relayer signs registerBatchFor, then setMinPriceInr if 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.

Verification writes a status and a second CID. The verifier pins an inspection record (1), posts it to /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).
  1. The verifier pins a record: note, rating, photo CID, original and verified quantity.
  2. The browser posts the status and CID to the server.
  3. The REST route passes it to the relayer.
  4. The relayer calls setVerificationStatus on the contract.
  5. If that write fails, the server stores the status in server/data/verification.json so 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.

Payment is off chain; ownership is on chain. The two halves meet in the webhook. Stripe reports the payment (3), and the relayer, not the buyer, writes transferOwnershipByVerifier (5).
  1. The browser posts line items and metadata to create a session.
  2. The server creates the session on Stripe and returns its URL.
  3. Stripe posts the completed event to the webhook.
  4. The webhook passes the session to the relayer.
  5. The relayer reads the owner and calls transferOwnershipByVerifier if 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.

The same path, a different contract call. When the session metadata says a quantity less than the whole, the relayer calls splitBatchByVerifier (3) and then sets prices on the child with up to three more transactions.
  1. Stripe reports a purchase whose metadata says isSplit with a quantity.
  2. The webhook passes the session to the relayer.
  3. The relayer calls splitBatchByVerifier, finds the child id in the BatchSplit log 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.

Two readers: a consumer and a timer. The consumer calls 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).
  1. The consumer's browser calls GET /api/batch/:id.
  2. The server reads the struct from the contract.
  3. The cron route reads every batch with getAllBatchIds and batches.
  4. It looks up the farmer's subscription in MongoDB.
  5. 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.

The chain holds pointers and ownership, not documents. A content identifier is a hash of the file, so storing it on chain pins down what the file must say. Payment and login stay off chain, which is also where the weakest parts of the system sit.
Almost every sensitive call is gated by the verifier role. That role is held by the relayer key, so the contract trusts one server key. The two register functions are open to anyone, and 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.

The server learns the new id from the receipt. Registering and setting the minimum price are two separate transactions, each waited on before the next. If the browser fails to pin, the farmer is told and the batch is registered without a CID.

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.

The retry is caught; the second entrance is not. The confirm route retrieves the session from Stripe, checks it is paid and calls the shared function, but it never reads or writes the sets. For a whole-batch purchase the owner check makes the replay harmless. For a split there is no such check.
The guard is real but narrow. It stops Stripe's own retries from running the job twice while the process stays up. It does not cover the second entrance, it forgets everything on restart, and the 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.

A small pack still points at the farm batch it came from. The child gets a new id and 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 server checks the gate, not the price. It insists the batch is verified and that the amount is a valid INR figure, but the amount itself, and the address that receives the batch, arrive from the browser.

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.

Reading needs no key and no payment. The path the consumer takes is read-only: the struct from the chain, the two documents from IPFS. The greyed nodes are the write side, which a consumer never touches.
Every panel maps to a field or an event in the contract. The values are example data for one batch; the event names and field names come from AgriTruthChain.sol.
  1. The consumer enters a batch id or scans its QR code, and the browser calls GET /api/batch/:id.
  2. The server reads the batches mapping on Arbitrum Sepolia and returns custody, prices, dates and both CIDs.
  3. 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.parse with Stripe's signature check against STRIPE_WEBHOOK_SECRET, and drop the bypass header from test_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-status and /api/setup-relayer-as-verifier need authentication. registerBatchFor lets anyone register a batch for any farmer, and transferOwnershipByVerifier should 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?

  1. The contract accepts card numbers.
  2. The buyer pays through Stripe and the server's relayer, which the contract trusts as a verifier, signs the ownership transfer.
  3. Arbitrum Sepolia has no gas.
  4. The browser signs with a shared key.
B. The relayer key is approved as a verifier, so it can call 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?

  1. Lower gas than any string.
  2. Automatic encryption.
  3. A hash of the file, so the pointer pins down exactly what the file must contain.
  4. A guarantee the file stays online.
C. A content identifier is derived from the file, so a fetched file can be compared to the on-chain pointer. It does not keep the file available.

In this codebase, why is a repeated whole-batch transfer harmless while a repeated split is not?

  1. 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.
  2. Splits cost more gas.
  3. Transfers are signed twice.
  4. Stripe never retries transfers.
A. The standard branch of 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?

  1. The transfer succeeds.
  2. The batch is rolled back.
  3. Stripe stops retrying immediately.
  4. The handler answers skipped, so the failed transfer is never retried.
D. The 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?

  1. Stripe stops sending events.
  2. Anyone who can reach the endpoint can post a forged completed event and make the relayer act on it.
  3. The amounts are rounded wrongly.
  4. Nothing, because the endpoint is HTTPS.
B. The signature is the only proof that Stripe sent the event. Without it the handler trusts any JSON body, including the batch id and buyer address in the metadata.

How does the contract decide that a new holder is a retailer?

  1. The holder registers a retailer profile.
  2. The holder pays more than the distributor.
  3. It is the next non-farmer holder after the distributor, inferred from position in the transfers.
  4. The verifier sets it.
C. _updateOwner fills the distributor first, then the retailer, then the consumer, in order. A wrong destination address therefore assigns the wrong role.