matiru
Blockchain-Based Supply Chain Transparency for Agricultural Producegit clone https://github.com/maydayv7/matiru
fabric/chaincode/index.js (17966B)
1"use strict";
2const { Contract } = require("fabric-contract-api");
3
4class ProduceContract extends Contract {
5 // Utilities
6 async _getState(ctx, id) {
7 const data = await ctx.stub.getState(id);
8 if (!data || data.length === 0) {
9 throw new Error(`Asset ${id} does not exist`);
10 }
11 return JSON.parse(data.toString());
12 }
13
14 async _putState(ctx, id, obj) {
15 await ctx.stub.putState(id, Buffer.from(JSON.stringify(obj)));
16 }
17
18 // Deterministic timestamp from tx context
19 _txTimestampISO(ctx) {
20 const ts = ctx.stub.getTxTimestamp();
21 // ts.seconds may be a Long object in some environments
22 const seconds =
23 typeof ts.seconds === "object" &&
24 typeof ts.seconds.toNumber === "function"
25 ? ts.seconds.toNumber()
26 : Number(ts.seconds);
27 const millis = seconds * 1000 + Math.floor((ts.nanos || 0) / 1e6);
28 return new Date(millis).toISOString();
29 }
30
31 // Deterministic action item
32 _actionItem(ctx, action, location, owner, meta) {
33 return {
34 timestamp: this._txTimestampISO(ctx),
35 action,
36 currentLocation: location || "",
37 currentOwner: owner || "",
38 meta: meta || {},
39 };
40 }
41
42 // Find user state by ID
43 async _findUserKeyById(ctx, id) {
44 if (!id) return null;
45 const roles = ["FARMER", "DISTRIBUTOR", "RETAILER", "INSPECTOR"];
46 for (const r of roles) {
47 const key = `${r}-${id}`;
48 const data = await ctx.stub.getState(key);
49 if (data && data.length > 0) return key;
50 }
51 return null;
52 }
53
54 async _getUserById(ctx, id) {
55 const key = await this._findUserKeyById(ctx, id);
56 if (!key) return null;
57 const data = await ctx.stub.getState(key);
58 if (!data || data.length === 0) return null;
59 return { key, user: JSON.parse(data.toString()) };
60 }
61
62 async _addOwnedProduceToUser(ctx, userKey, userObj, produceId) {
63 userObj.ownedProduce = userObj.ownedProduce || [];
64 if (!userObj.ownedProduce.includes(produceId)) {
65 userObj.ownedProduce.push(produceId);
66 await this._putState(ctx, userKey, userObj);
67 }
68 }
69
70 async _removeOwnedProduceFromUser(ctx, userKey, userObj, produceId) {
71 userObj.ownedProduce = userObj.ownedProduce || [];
72 const idx = userObj.ownedProduce.indexOf(produceId);
73 if (idx >= 0) {
74 userObj.ownedProduce.splice(idx, 1);
75 await this._putState(ctx, userKey, userObj);
76 }
77 }
78
79 _validateInput(data, requiredFields) {
80 for (const field of requiredFields) {
81 if (
82 data[field] === undefined ||
83 data[field] === null ||
84 data[field] === ""
85 ) {
86 throw new Error(`Validation Error: Field '${field}' is required`);
87 }
88 }
89 if (data.qty && (typeof data.qty !== "number" || data.qty <= 0))
90 throw new Error("Validation Error: 'qty' must be a positive number");
91
92 if (
93 data.pricePerUnit &&
94 (typeof data.pricePerUnit !== "number" || data.pricePerUnit < 0)
95 ) {
96 throw new Error(
97 "Validation Error: 'pricePerUnit' must be a non-negative number"
98 );
99 }
100 }
101
102 // Initialize ledger
103 async initLedger(ctx) {
104 console.info("Ledger initialized");
105 }
106
107 // Functions
108 // Produce Registration
109 async registerProduce(ctx, farmerId, detailsStr) {
110 const clientMspId = ctx.clientIdentity.getMSPID();
111 if (clientMspId !== "Org1MSP")
112 throw new Error(
113 `Client from ${clientMspId} is not authorized to register produce. Only Org1MSP is allowed.`
114 );
115
116 const farmerKey = `FARMER-${farmerId}`;
117 const farmerState = await ctx.stub.getState(farmerKey);
118 if (!farmerState || farmerState.length === 0)
119 throw new Error(`Farmer ${farmerId} is not registered`);
120
121 const farmer = JSON.parse(farmerState.toString());
122 const details = JSON.parse(detailsStr || "{}");
123
124 this._validateInput(details, [
125 "cropType",
126 "qty",
127 "qtyUnit",
128 "pricePerUnit",
129 "harvestDate",
130 ]);
131
132 const txId = ctx.stub.getTxID();
133 const now = this._txTimestampISO(ctx);
134 const id = `PRODUCE-${txId}`;
135 const produce = {
136 id,
137 parentId: null,
138 children: [],
139 qty: details.qty,
140 qtyUnit: details.qtyUnit,
141 pricePerUnit: details.pricePerUnit,
142 totalPrice: details.pricePerUnit * details.qty,
143 currentOwner: farmerId,
144 currentLocation: details.location || "",
145 actionHistory: [],
146 saleHistory: [],
147 cropType: details.cropType,
148 harvestDate: details.harvestDate || now,
149 quality: details.quality || null,
150 expiryDate: details.expiryDate || null,
151 storageConditions: details.storageConditions || [],
152 imageUrl: details.imageUrl || null,
153 certification: details.certification || farmer.certification || [],
154 status: "Harvested",
155 isAvailable: true,
156 notAvailableReason: null,
157 };
158
159 produce.actionHistory.push(
160 this._actionItem(ctx, "REGISTER", produce.currentLocation, farmerId, {
161 note: details.note || "",
162 })
163 );
164
165 await this._putState(ctx, id, produce);
166
167 const farmerObj = farmer;
168 farmerObj.registeredProduce = farmerObj.registeredProduce || [];
169 if (!farmerObj.registeredProduce.includes(id))
170 farmerObj.registeredProduce.push(id);
171
172 farmerObj.ownedProduce = farmerObj.ownedProduce || [];
173 if (!farmerObj.ownedProduce.includes(id)) farmerObj.ownedProduce.push(id);
174
175 await this._putState(ctx, farmerKey, farmerObj);
176 return produce;
177 }
178
179 // Update location
180 async updateLocation(ctx, produceId, actorId, newLocation) {
181 const produce = await this._getState(ctx, produceId);
182 if (newLocation === "In Transit") produce.status = "In Transit";
183 else {
184 produce.currentLocation = newLocation;
185 if (produce.status === "Harvested") produce.status = "In Transit";
186 }
187
188 produce.actionHistory.push(
189 this._actionItem(ctx, "MOVE", produce.currentLocation, actorId, {})
190 );
191 await this._putState(ctx, produceId, produce);
192 return produce;
193 }
194
195 // Mark as unavailable due to some reason
196 async markAsUnavailable(ctx, produceId, actorId, reason, newStatus) {
197 const produce = await this._getState(ctx, produceId);
198 produce.isAvailable = false;
199 produce.notAvailableReason = reason || "";
200 produce.status = newStatus || "Removed";
201
202 produce.actionHistory.push(
203 this._actionItem(ctx, "REMOVED", produce.currentLocation, actorId, {
204 reason,
205 })
206 );
207 await this._putState(ctx, produceId, produce);
208 return produce;
209 }
210
211 // Produce Inspection
212 async inspectProduce(ctx, produceId, inspectorId, qualityUpdateStr) {
213 const clientMspId = ctx.clientIdentity.getMSPID();
214 if (clientMspId !== "Org4MSP")
215 throw new Error(
216 `Client from ${clientMspId} is not authorized to inspect produce. Only Org4MSP is allowed.`
217 );
218
219 const qualityUpdate = JSON.parse(qualityUpdateStr || "{}");
220 const produce = await this._getState(ctx, produceId);
221
222 if (qualityUpdate.quality !== undefined)
223 produce.quality = qualityUpdate.quality;
224 if (qualityUpdate.expiryDate !== undefined)
225 produce.expiryDate = qualityUpdate.expiryDate;
226 if (qualityUpdate.storageConditions !== undefined)
227 produce.storageConditions = qualityUpdate.storageConditions;
228
229 produce.actionHistory.push(
230 this._actionItem(
231 ctx,
232 "INSPECT",
233 produce.currentLocation,
234 inspectorId,
235 qualityUpdate
236 )
237 );
238
239 if (qualityUpdate.failed === true) {
240 produce.isAvailable = false;
241 produce.notAvailableReason = qualityUpdate.reason || "Failed Inspection";
242 produce.status = "Failed Inspection";
243 produce.actionHistory.push(
244 this._actionItem(ctx, "REMOVED", produce.currentLocation, inspectorId, {
245 reason: produce.notAvailableReason,
246 })
247 );
248 }
249
250 await this._putState(ctx, produceId, produce);
251
252 const inspectorKey = `INSPECTOR-${inspectorId}`;
253 const inspectorState = await ctx.stub.getState(inspectorKey);
254 if (inspectorState && inspectorState.length > 0) {
255 const inspectorObj = JSON.parse(inspectorState.toString());
256 inspectorObj.inspectedProduce = inspectorObj.inspectedProduce || [];
257 if (!inspectorObj.inspectedProduce.includes(produceId)) {
258 inspectorObj.inspectedProduce.push(produceId);
259 await this._putState(ctx, inspectorKey, inspectorObj);
260 }
261 }
262 return produce;
263 }
264
265 // Update added details
266 async updateDetails(ctx, produceId, actorId, detailsStr) {
267 const details = JSON.parse(detailsStr || "{}");
268 const produce = await this._getState(ctx, produceId);
269
270 if (produce.currentOwner !== actorId)
271 throw new Error("Only current owner can update details");
272
273 if (details.pricePerUnit !== undefined)
274 produce.pricePerUnit = details.pricePerUnit;
275 if (details.storageConditions !== undefined)
276 produce.storageConditions = details.storageConditions;
277 if (details.imageUrl !== undefined) produce.imageUrl = details.imageUrl;
278 if (details.certification !== undefined)
279 produce.certification = details.certification;
280
281 produce.totalPrice = (produce.pricePerUnit || 0) * (produce.qty || 0);
282 produce.actionHistory.push(
283 this._actionItem(
284 ctx,
285 "UPDATED",
286 produce.currentLocation,
287 actorId,
288 details
289 )
290 );
291 await this._putState(ctx, produceId, produce);
292 return produce;
293 }
294
295 // Split into smaller batches
296 async splitProduce(ctx, produceId, qtyStr, ownerId) {
297 const qty = parseFloat(qtyStr);
298 const produce = await this._getState(ctx, produceId);
299
300 if (produce.currentOwner !== ownerId)
301 throw new Error("Only current owner can split produce");
302 if (qty <= 0 || qty > produce.qty)
303 throw new Error("Invalid quantity to split");
304
305 produce.qty -= qty;
306 produce.totalPrice = (produce.pricePerUnit || 0) * produce.qty;
307
308 const childId = `${produce.id}-${ctx.stub.getTxID()}-CHILD`;
309 const child = JSON.parse(JSON.stringify(produce));
310 child.id = childId;
311 child.parentId = produce.id;
312 child.children = [];
313 child.qty = qty;
314 child.totalPrice = (child.pricePerUnit || 0) * qty;
315 child.actionHistory = [];
316 child.saleHistory = [];
317 child.actionHistory.push(
318 this._actionItem(ctx, "SPLIT", produce.currentLocation, ownerId, { qty })
319 );
320
321 produce.children = produce.children || [];
322 produce.children.push(childId);
323 produce.actionHistory.push(
324 this._actionItem(ctx, "SPLIT", produce.currentLocation, ownerId, {
325 createdChild: childId,
326 qty,
327 })
328 );
329
330 await this._putState(ctx, produce.id, produce);
331 await this._putState(ctx, childId, child);
332
333 const ownerRes = await this._getUserById(ctx, ownerId);
334 if (ownerRes) {
335 await this._addOwnedProduceToUser(
336 ctx,
337 ownerRes.key,
338 ownerRes.user,
339 childId
340 );
341 }
342 return { parent: produce, child };
343 }
344
345 // Transfer ownership
346 async transferOwnership(ctx, produceId, newOwnerId, qtyStr, salePriceStr) {
347 const qty = parseFloat(qtyStr);
348 const salePrice = parseFloat(salePriceStr);
349 const produce = await this._getState(ctx, produceId);
350 const now = this._txTimestampISO(ctx);
351 const currentOwner = produce.currentOwner;
352
353 if (!produce.isAvailable) throw new Error("Asset not available");
354 if (qty <= 0 || qty > produce.qty) throw new Error("Invalid qty");
355
356 let resultAssetId = produceId;
357 if (qty < produce.qty) {
358 // Partial Transfer
359 const splitRes = await this.splitProduce(
360 ctx,
361 produceId,
362 "" + qty,
363 produce.currentOwner
364 );
365 const child = splitRes.child;
366 child.currentOwner = newOwnerId;
367 child.actionHistory.push(
368 this._actionItem(ctx, "SALE", child.currentLocation, newOwnerId, {
369 qty,
370 salePrice,
371 })
372 );
373 child.saleHistory = child.saleHistory || [];
374 child.saleHistory.push({
375 timestamp: now,
376 prevOwner: currentOwner,
377 newOwner: newOwnerId,
378 salePrice,
379 qtyBought: qty,
380 paymentStatus: "PENDING",
381 });
382 await this._putState(ctx, child.id, child);
383 resultAssetId = child.id;
384
385 const prevOwnerRes = await this._getUserById(ctx, currentOwner);
386 if (prevOwnerRes) {
387 await this._removeOwnedProduceFromUser(
388 ctx,
389 prevOwnerRes.key,
390 prevOwnerRes.user,
391 child.id
392 );
393 }
394 const newOwnerRes = await this._getUserById(ctx, newOwnerId);
395 if (newOwnerRes) {
396 await this._addOwnedProduceToUser(
397 ctx,
398 newOwnerRes.key,
399 newOwnerRes.user,
400 child.id
401 );
402 }
403 } else {
404 // Full Transfer
405 produce.currentOwner = newOwnerId;
406 produce.actionHistory.push(
407 this._actionItem(ctx, "SALE", produce.currentLocation, newOwnerId, {
408 qty,
409 salePrice,
410 })
411 );
412 produce.saleHistory = produce.saleHistory || [];
413 produce.saleHistory.push({
414 timestamp: now,
415 prevOwner: currentOwner,
416 newOwner: newOwnerId,
417 salePrice,
418 qtyBought: qty,
419 paymentStatus: "PENDING",
420 });
421 await this._putState(ctx, produceId, produce);
422 resultAssetId = produceId;
423
424 const prevOwnerRes = await this._getUserById(ctx, currentOwner);
425 if (prevOwnerRes) {
426 await this._removeOwnedProduceFromUser(
427 ctx,
428 prevOwnerRes.key,
429 prevOwnerRes.user,
430 produceId
431 );
432 }
433 const newOwnerRes = await this._getUserById(ctx, newOwnerId);
434 if (newOwnerRes) {
435 await this._addOwnedProduceToUser(
436 ctx,
437 newOwnerRes.key,
438 newOwnerRes.user,
439 produceId
440 );
441 }
442 }
443 return { newAssetId: resultAssetId };
444 }
445
446 // Record payment for transfer
447 async recordPayment(
448 ctx,
449 produceId,
450 transactionId,
451 paymentStatus,
452 paymentMethod,
453 paymentRef
454 ) {
455 const produce = await this._getState(ctx, produceId);
456 const now = this._txTimestampISO(ctx);
457 produce.saleHistory = produce.saleHistory || [];
458
459 if (produce.saleHistory.length === 0) {
460 produce.saleHistory.push({
461 timestamp: now,
462 prevOwner: null,
463 newOwner: produce.currentOwner,
464 salePrice: produce.totalPrice,
465 qtyBought: produce.qty,
466 paymentStatus,
467 });
468 } else {
469 const last = produce.saleHistory[produce.saleHistory.length - 1];
470 last.paymentStatus = paymentStatus;
471 last.paymentMethod = paymentMethod;
472 last.paymentRef = paymentRef || transactionId;
473 }
474
475 produce.paymentStatus = paymentStatus;
476 produce.paymentMethod = paymentMethod;
477 produce.paymentRef = paymentRef || transactionId;
478
479 produce.actionHistory.push(
480 this._actionItem(
481 ctx,
482 "PAYMENT",
483 produce.currentLocation,
484 produce.currentOwner,
485 {
486 transactionId,
487 paymentStatus,
488 paymentMethod,
489 paymentRef,
490 }
491 )
492 );
493 await this._putState(ctx, produceId, produce);
494 return produce;
495 }
496
497 // Queries
498 async getProduceById(ctx, produceId) {
499 return await this._getState(ctx, produceId);
500 }
501
502 async getProduceByOwner(ctx, ownerId) {
503 const iterator = await ctx.stub.getStateByRange("", "");
504 const results = [];
505 while (true) {
506 const res = await iterator.next();
507 if (res.value && res.value.key) {
508 if (res.value.key.startsWith("PRODUCE-")) {
509 const obj = JSON.parse(res.value.value.toString("utf8"));
510 if (obj.currentOwner === ownerId) results.push(obj);
511 }
512 }
513 if (res.done) {
514 await iterator.close();
515 break;
516 }
517 }
518 return results;
519 }
520
521 // Governance
522 async registerUser(ctx, role, detailsStr) {
523 const clientMspId = ctx.clientIdentity.getMSPID();
524 const details = JSON.parse(detailsStr || "{}");
525 const roleUpper = role.toUpperCase();
526
527 const roleToOrgMap = {
528 FARMER: "Org1MSP",
529 DISTRIBUTOR: "Org2MSP",
530 RETAILER: "Org3MSP",
531 INSPECTOR: "Org4MSP",
532 };
533
534 const expectedMspId = roleToOrgMap[roleUpper];
535 if (!expectedMspId || clientMspId !== expectedMspId)
536 throw new Error(
537 `Client from ${clientMspId} cannot register a ${role}. Expected admin from ${expectedMspId}.`
538 );
539
540 const id = details.id || `USER-${ctx.stub.getTxID()}`;
541 const key = `${roleUpper}-${id}`;
542
543 const base = {
544 role,
545 id,
546 name: details.name || "",
547 location: details.location || "",
548 walletId: details.walletId || "",
549 };
550
551 let user = null;
552 if (roleUpper === "FARMER") {
553 user = {
554 ...base,
555 registeredProduce: details.registeredProduce || [],
556 ownedProduce: details.ownedProduce || [],
557 certification: details.certification || [],
558 };
559 } else if (roleUpper === "DISTRIBUTOR" || roleUpper === "RETAILER") {
560 user = {
561 ...base,
562 ownedProduce: details.ownedProduce || [],
563 };
564 } else if (roleUpper === "INSPECTOR") {
565 user = {
566 ...base,
567 inspectedProduce: details.inspectedProduce || [],
568 };
569 }
570
571 await this._putState(ctx, key, user);
572 return user;
573 }
574
575 async getUserDetails(ctx, userKey) {
576 const data = await ctx.stub.getState(userKey);
577 if (!data || data.length === 0)
578 throw new Error(`User ${userKey} not found`);
579 return JSON.parse(data.toString());
580 }
581
582 async updateUser(ctx, userKey, detailsStr) {
583 const data = await ctx.stub.getState(userKey);
584 if (!data || data.length === 0)
585 throw new Error(`User ${userKey} not found`);
586
587 const user = JSON.parse(data.toString());
588 const details = JSON.parse(detailsStr || "{}");
589 Object.assign(user, details);
590
591 await this._putState(ctx, userKey, user);
592 return user;
593 }
594}
595
596module.exports.contracts = [ProduceContract];