// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /** * ============================================================================ * Smart_Contracts.sol * Supply Chain Traceability PoC — Cocoa Export Provenance & Escrow Payments * ---------------------------------------------------------------------------- * electroSOFT — Blockchain Solutions service line * Project: Supply Chain Traceability PoC (April 2026) * * Deployed to: Ethereum Sepolia testnet (see PoC_Documentation.pdf, Section 8) * * Overview * -------- * This file contains the full on-chain component of the PoC: a role-based * actor registry, a batch-provenance ledger that records each of the 12 * verification points a cocoa batch passes through between farm and export, * and an escrow contract that automatically releases payment to farmers and * cooperatives once a batch clears inspection. * * Contracts in this file: * 1. AccessControl — minimal, self-contained role management * (no external imports, so this file compiles * standalone; see PoC_Documentation.pdf Section 6 * for the rationale on avoiding a third-party * dependency in the PoC). * 2. ReentrancyGuard — minimal reentrancy protection for escrow release. * 3. ActorRegistry — onboarding & role assignment for the 5 supply * chain actors: Farmer, Cooperative, Logistics * Provider, Inspector, Buyer. * 4. BatchTraceability — the core provenance ledger: batch creation, * the 12 verification-point checkpoints, and * status transitions. * 5. EscrowPayment — buyer funds are locked in escrow at purchase * and automatically released to the farmer/ * cooperative once BatchTraceability confirms * the batch passed final inspection. * ============================================================================ */ // ============================================================================= // 1. AccessControl — minimal role management // ============================================================================= contract AccessControl { bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant FARMER_ROLE = keccak256("FARMER_ROLE"); bytes32 public constant COOPERATIVE_ROLE = keccak256("COOPERATIVE_ROLE"); bytes32 public constant LOGISTICS_ROLE = keccak256("LOGISTICS_ROLE"); bytes32 public constant INSPECTOR_ROLE = keccak256("INSPECTOR_ROLE"); bytes32 public constant BUYER_ROLE = keccak256("BUYER_ROLE"); mapping(bytes32 => mapping(address => bool)) private _roles; event RoleGranted(bytes32 indexed role, address indexed account, address indexed grantedBy); event RoleRevoked(bytes32 indexed role, address indexed account, address indexed revokedBy); modifier onlyRole(bytes32 role) { require(_roles[role][msg.sender], "AccessControl: missing role"); _; } constructor() { _grantRole(ADMIN_ROLE, msg.sender); } function hasRole(bytes32 role, address account) public view returns (bool) { return _roles[role][account]; } function grantRole(bytes32 role, address account) external onlyRole(ADMIN_ROLE) { _grantRole(role, account); } function revokeRole(bytes32 role, address account) external onlyRole(ADMIN_ROLE) { require(_roles[role][account], "AccessControl: role not held"); _roles[role][account] = false; emit RoleRevoked(role, account, msg.sender); } function _grantRole(bytes32 role, address account) internal { _roles[role][account] = true; emit RoleGranted(role, account, msg.sender); } } // ============================================================================= // 2. ReentrancyGuard — minimal reentrancy protection // ============================================================================= contract ReentrancyGuard { uint256 private constant _NOT_ENTERED = 1; uint256 private constant _ENTERED = 2; uint256 private _status; constructor() { _status = _NOT_ENTERED; } modifier nonReentrant() { require(_status != _ENTERED, "ReentrancyGuard: reentrant call"); _status = _ENTERED; _; _status = _NOT_ENTERED; } } // ============================================================================= // 3. ActorRegistry — onboarding for the 5 supply chain actors // ============================================================================= contract ActorRegistry is AccessControl { enum ActorType { None, Farmer, Cooperative, LogisticsProvider, Inspector, Buyer } struct Actor { ActorType actorType; string name; string location; // free-text region/country, e.g. "Ashanti, Ghana" bool isActive; uint256 registeredAt; } mapping(address => Actor) public actors; address[] public actorAddresses; event ActorRegistered(address indexed account, ActorType actorType, string name); event ActorDeactivated(address indexed account, address indexed deactivatedBy); event ActorReactivated(address indexed account, address indexed reactivatedBy); modifier onlyActiveActor(ActorType requiredType) { require(actors[msg.sender].isActive, "ActorRegistry: actor not active"); require(actors[msg.sender].actorType == requiredType, "ActorRegistry: wrong actor type"); _; } /// @notice Registers a new supply-chain actor. Restricted to ADMIN_ROLE so /// that onboarding (e.g. of a pilot cooperative's field agents) is a /// deliberate, auditable action rather than open self-registration. function registerActor( address account, ActorType actorType, string calldata name, string calldata location ) external onlyRole(ADMIN_ROLE) { require(account != address(0), "ActorRegistry: zero address"); require(actorType != ActorType.None, "ActorRegistry: invalid actor type"); require(actors[account].actorType == ActorType.None, "ActorRegistry: already registered"); actors[account] = Actor({ actorType: actorType, name: name, location: location, isActive: true, registeredAt: block.timestamp }); actorAddresses.push(account); bytes32 role = _roleForActorType(actorType); _grantRole(role, account); emit ActorRegistered(account, actorType, name); } function deactivateActor(address account) external onlyRole(ADMIN_ROLE) { require(actors[account].isActive, "ActorRegistry: already inactive"); actors[account].isActive = false; emit ActorDeactivated(account, msg.sender); } function reactivateActor(address account) external onlyRole(ADMIN_ROLE) { require(!actors[account].isActive, "ActorRegistry: already active"); require(actors[account].actorType != ActorType.None, "ActorRegistry: never registered"); actors[account].isActive = true; emit ActorReactivated(account, msg.sender); } function totalActors() external view returns (uint256) { return actorAddresses.length; } function _roleForActorType(ActorType actorType) internal pure returns (bytes32) { if (actorType == ActorType.Farmer) return FARMER_ROLE; if (actorType == ActorType.Cooperative) return COOPERATIVE_ROLE; if (actorType == ActorType.LogisticsProvider) return LOGISTICS_ROLE; if (actorType == ActorType.Inspector) return INSPECTOR_ROLE; if (actorType == ActorType.Buyer) return BUYER_ROLE; revert("ActorRegistry: unmapped actor type"); } } // ============================================================================= // 4. BatchTraceability — the core provenance ledger // ============================================================================= contract BatchTraceability is ActorRegistry { /// @dev The 12 verification points identified during the requirements & /// use-case mapping milestone (PoC_Documentation.pdf, Section 4), /// spanning farm to export. Recorded in strict, enforced sequence per /// batch so a checkpoint cannot be skipped or recorded out of order. enum CheckpointType { FarmHarvest, // 0 — Farmer records harvest date, weight, plot FarmQualityGrading, // 1 — Farmer/Cooperative grades bean quality CooperativeIntake, // 2 — Cooperative aggregates farmer batches CooperativeStorage, // 3 — Cooperative confirms storage conditions FirstMileTransport, // 4 — Logistics: farm/co-op -> regional depot DepotInspection, // 5 — Inspector: moisture, defect sampling FumigationTreatment, // 6 — Logistics: pre-export fumigation record PortHandling, // 7 — Logistics: depot -> port of export CustomsClearance, // 8 — Inspector: export customs documentation ExportQualityCertification, // 9 — Inspector: final certificate of origin/quality VesselLoading, // 10 — Logistics: container/vessel loading BuyerReceiptConfirmation // 11 — Buyer: goods received at destination port } enum BatchStatus { Created, InTransit, UnderInspection, Certified, Rejected, Delivered } struct Checkpoint { CheckpointType checkpointType; address recordedBy; uint256 timestamp; string dataHash; // IPFS/off-chain hash of supporting documents/photos string notes; } struct Batch { uint256 id; address farmer; address cooperative; uint256 harvestWeightKg; string originPlotId; BatchStatus status; uint256 createdAt; uint8 nextExpectedCheckpoint; // index into CheckpointType, enforces order } uint256 private _nextBatchId = 1; mapping(uint256 => Batch) public batches; mapping(uint256 => Checkpoint[]) public batchCheckpoints; event BatchCreated(uint256 indexed batchId, address indexed farmer, uint256 harvestWeightKg, string originPlotId); event CheckpointRecorded(uint256 indexed batchId, CheckpointType indexed checkpointType, address indexed recordedBy, string dataHash); event BatchStatusChanged(uint256 indexed batchId, BatchStatus oldStatus, BatchStatus newStatus); event BatchRejected(uint256 indexed batchId, address indexed rejectedBy, string reason); modifier batchExists(uint256 batchId) { require(batches[batchId].id != 0, "BatchTraceability: batch does not exist"); _; } /// @notice Farmer creates a new batch record at harvest — checkpoint 0. function createBatch( uint256 harvestWeightKg, string calldata originPlotId, string calldata dataHash ) external onlyActiveActor(ActorType.Farmer) returns (uint256 batchId) { require(harvestWeightKg > 0, "BatchTraceability: weight must be > 0"); batchId = _nextBatchId++; batches[batchId] = Batch({ id: batchId, farmer: msg.sender, cooperative: address(0), harvestWeightKg: harvestWeightKg, originPlotId: originPlotId, status: BatchStatus.Created, createdAt: block.timestamp, nextExpectedCheckpoint: 1 // FarmHarvest (0) is recorded implicitly below }); batchCheckpoints[batchId].push(Checkpoint({ checkpointType: CheckpointType.FarmHarvest, recordedBy: msg.sender, timestamp: block.timestamp, dataHash: dataHash, notes: "Initial harvest record" })); emit BatchCreated(batchId, msg.sender, harvestWeightKg, originPlotId); emit CheckpointRecorded(batchId, CheckpointType.FarmHarvest, msg.sender, dataHash); } /// @notice Records the next expected checkpoint for a batch. Enforces /// strict sequential ordering across the 12 verification points and /// restricts each checkpoint type to the actor role responsible for it, /// per the requirements mapping in PoC_Documentation.pdf, Section 4. function recordCheckpoint( uint256 batchId, CheckpointType checkpointType, string calldata dataHash, string calldata notes ) external batchExists(batchId) { Batch storage batch = batches[batchId]; require(batch.status != BatchStatus.Rejected, "BatchTraceability: batch rejected"); require(batch.status != BatchStatus.Delivered, "BatchTraceability: batch already delivered"); require(uint8(checkpointType) == batch.nextExpectedCheckpoint, "BatchTraceability: out-of-sequence checkpoint"); require(_isAuthorizedForCheckpoint(checkpointType, msg.sender), "BatchTraceability: actor not authorized for this checkpoint"); if (checkpointType == CheckpointType.CooperativeIntake) { batch.cooperative = msg.sender; } batchCheckpoints[batchId].push(Checkpoint({ checkpointType: checkpointType, recordedBy: msg.sender, timestamp: block.timestamp, dataHash: dataHash, notes: notes })); batch.nextExpectedCheckpoint += 1; _updateStatusForCheckpoint(batch, checkpointType); emit CheckpointRecorded(batchId, checkpointType, msg.sender, dataHash); } /// @notice An Inspector may reject a batch at the DepotInspection, /// CustomsClearance, or ExportQualityCertification checkpoints if it /// fails quality or compliance criteria. function rejectBatch(uint256 batchId, string calldata reason) external batchExists(batchId) onlyActiveActor(ActorType.Inspector) { Batch storage batch = batches[batchId]; require(batch.status != BatchStatus.Delivered, "BatchTraceability: cannot reject a delivered batch"); require(batch.status != BatchStatus.Rejected, "BatchTraceability: already rejected"); BatchStatus old = batch.status; batch.status = BatchStatus.Rejected; emit BatchStatusChanged(batchId, old, BatchStatus.Rejected); emit BatchRejected(batchId, msg.sender, reason); } function getCheckpointCount(uint256 batchId) external view returns (uint256) { return batchCheckpoints[batchId].length; } function getCheckpoint(uint256 batchId, uint256 index) external view returns (Checkpoint memory) { return batchCheckpoints[batchId][index]; } function isFullyCertified(uint256 batchId) public view returns (bool) { return batches[batchId].nextExpectedCheckpoint > uint8(CheckpointType.BuyerReceiptConfirmation); } /// @notice Convenience accessor so external contracts (e.g. EscrowPayment) /// can read a batch's status without destructuring the full Batch tuple. function getBatchStatus(uint256 batchId) external view batchExists(batchId) returns (BatchStatus) { return batches[batchId].status; } function _isAuthorizedForCheckpoint(CheckpointType checkpointType, address account) internal view returns (bool) { if (checkpointType == CheckpointType.FarmQualityGrading) { return hasRole(FARMER_ROLE, account) || hasRole(COOPERATIVE_ROLE, account); } if (checkpointType == CheckpointType.CooperativeIntake || checkpointType == CheckpointType.CooperativeStorage) { return hasRole(COOPERATIVE_ROLE, account); } if ( checkpointType == CheckpointType.FirstMileTransport || checkpointType == CheckpointType.FumigationTreatment || checkpointType == CheckpointType.PortHandling || checkpointType == CheckpointType.VesselLoading ) { return hasRole(LOGISTICS_ROLE, account); } if ( checkpointType == CheckpointType.DepotInspection || checkpointType == CheckpointType.CustomsClearance || checkpointType == CheckpointType.ExportQualityCertification ) { return hasRole(INSPECTOR_ROLE, account); } if (checkpointType == CheckpointType.BuyerReceiptConfirmation) { return hasRole(BUYER_ROLE, account); } return false; } function _updateStatusForCheckpoint(Batch storage batch, CheckpointType checkpointType) internal { BatchStatus old = batch.status; BatchStatus updated = old; if (checkpointType == CheckpointType.FirstMileTransport) { updated = BatchStatus.InTransit; } else if (checkpointType == CheckpointType.DepotInspection) { updated = BatchStatus.UnderInspection; } else if (checkpointType == CheckpointType.ExportQualityCertification) { updated = BatchStatus.Certified; } else if (checkpointType == CheckpointType.BuyerReceiptConfirmation) { updated = BatchStatus.Delivered; } if (updated != old) { batch.status = updated; emit BatchStatusChanged(batch.id, old, updated); } } } // ============================================================================= // 5. EscrowPayment — automatic payment release on certified delivery // ============================================================================= contract EscrowPayment is ReentrancyGuard { BatchTraceability public immutable traceability; enum EscrowStatus { None, Funded, Released, Refunded } struct Escrow { uint256 batchId; address buyer; address payable payee; // farmer or cooperative, set at funding time uint256 amount; EscrowStatus status; uint256 fundedAt; uint256 resolvedAt; } mapping(uint256 => Escrow) public escrows; // batchId => Escrow uint256 public platformFeeBps = 150; // 1.50% PoC placeholder fee address payable public feeCollector; event EscrowFunded(uint256 indexed batchId, address indexed buyer, address indexed payee, uint256 amount); event EscrowReleased(uint256 indexed batchId, address indexed payee, uint256 payeeAmount, uint256 feeAmount); event EscrowRefunded(uint256 indexed batchId, address indexed buyer, uint256 amount); constructor(address traceabilityAddress, address payable feeCollectorAddress) { require(traceabilityAddress != address(0), "EscrowPayment: zero traceability address"); require(feeCollectorAddress != address(0), "EscrowPayment: zero fee collector address"); traceability = BatchTraceability(traceabilityAddress); feeCollector = feeCollectorAddress; } /// @notice Buyer locks funds in escrow against a specific batch, naming /// the farmer or cooperative that should be paid once the batch is /// confirmed received. Funds sit in the contract, not with electroSOFT /// or the platform operator, until release conditions are met on-chain. function fundEscrow(uint256 batchId, address payable payee) external payable { require(msg.value > 0, "EscrowPayment: no funds sent"); require(payee != address(0), "EscrowPayment: zero payee address"); require(escrows[batchId].status == EscrowStatus.None, "EscrowPayment: escrow already exists for batch"); (, address farmer, address cooperative,,,,, ) = traceability.batches(batchId); require(payee == farmer || payee == cooperative, "EscrowPayment: payee must be the batch's farmer or cooperative"); escrows[batchId] = Escrow({ batchId: batchId, buyer: msg.sender, payee: payee, amount: msg.value, status: EscrowStatus.Funded, fundedAt: block.timestamp, resolvedAt: 0 }); emit EscrowFunded(batchId, msg.sender, payee, msg.value); } /// @notice Releases escrowed funds to the payee once BatchTraceability /// confirms BuyerReceiptConfirmation (checkpoint 12) has been recorded — /// i.e. the batch is fully certified and delivered. Callable by anyone /// once the on-chain condition is met, so release cannot be withheld by /// a single party. function releaseEscrow(uint256 batchId) external nonReentrant { Escrow storage escrow = escrows[batchId]; require(escrow.status == EscrowStatus.Funded, "EscrowPayment: escrow not in funded state"); require(traceability.isFullyCertified(batchId), "EscrowPayment: batch not yet fully certified/delivered"); escrow.status = EscrowStatus.Released; escrow.resolvedAt = block.timestamp; uint256 feeAmount = (escrow.amount * platformFeeBps) / 10000; uint256 payeeAmount = escrow.amount - feeAmount; (bool feeSent, ) = feeCollector.call{value: feeAmount}(""); require(feeSent, "EscrowPayment: fee transfer failed"); (bool payeeSent, ) = escrow.payee.call{value: payeeAmount}(""); require(payeeSent, "EscrowPayment: payee transfer failed"); emit EscrowReleased(batchId, escrow.payee, payeeAmount, feeAmount); } /// @notice Allows the buyer to reclaim escrowed funds if the batch was /// rejected by an Inspector (see BatchTraceability.rejectBatch), rather /// than leaving funds locked indefinitely against a failed shipment. function refundEscrow(uint256 batchId) external nonReentrant { Escrow storage escrow = escrows[batchId]; require(escrow.status == EscrowStatus.Funded, "EscrowPayment: escrow not in funded state"); require(msg.sender == escrow.buyer, "EscrowPayment: only the buyer can request a refund"); require( traceability.getBatchStatus(batchId) == BatchTraceability.BatchStatus.Rejected, "EscrowPayment: batch has not been rejected" ); escrow.status = EscrowStatus.Refunded; escrow.resolvedAt = block.timestamp; (bool sent, ) = payable(escrow.buyer).call{value: escrow.amount}(""); require(sent, "EscrowPayment: refund transfer failed"); emit EscrowRefunded(batchId, escrow.buyer, escrow.amount); } function setPlatformFee(uint256 newFeeBps) external { require(msg.sender == feeCollector, "EscrowPayment: only fee collector may update the fee"); require(newFeeBps <= 500, "EscrowPayment: fee cannot exceed 5%"); platformFeeBps = newFeeBps; } }