📜 Code Source & Architecture Smart Contract

Contrat de gestion de cycle AgriFiCycle v1.0 sur le réseau Polygon.

----CODE COMPLET----

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

/**
 * @title AgriFiCycle - Agricultural Investment Cycle Management
 * @notice This contract manages investment cycles for agricultural projects with dual payment channels:
 *         - USDT/USDC (on-chain payments with automatic profit distribution)
 *         - Fiat (off-chain payments with proof-of-ownership NFTs)
 * @dev Important: No payment is required in mint functions. All payments are processed off-chain.
 *      The contract serves as a ledger and profit distribution mechanism only.
 */
contract AgriFiCycle is ERC1155, Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    // --- CONSTANTS ---
    uint256 public constant PARCELLE_USDT = 1;  // NFT for on-chain investors with profit rights
    uint256 public constant PARCELLE_FIAT = 2;  // NFT for off-chain investors (proof only)
    uint256 public constant FEE_PERCENTAGE = 100; // 10% = 100/1000
    uint256 public constant FEE_SCALE = 1000;

    // --- ENUMS ---
    enum CycleStatus { 
        SALE_OPEN,      // Investment phase active
        SALE_CLOSED,    // Investment phase completed
        HARVESTING,     // Agricultural cycle in progress
        PAYOUT_READY,   // Profits available for distribution
        COMPLETED       // Cycle fully completed
    }

    // --- IMMUTABLES ---
    uint256 public immutable MAX_TOTAL_SUPPLY;
    address public immutable paymentToken;      // USDT or USDC address
    address public immutable treasuryAddress;   // Trusted treasury for fiat funds
    uint256 public immutable claimPeriodSeconds;

    // --- STATE ---
    CycleStatus public currentStatus = CycleStatus.SALE_OPEN;
    uint256 public usdtSharesSold = 0;
    uint256 public fiatSharesSold = 0;
    uint256 public totalSharesSold = 0;
    uint256 public claimDeadline;
    uint256 public cycleStartDate;
    
    // USDT profit accounting
    uint256 public usdtProfitsReceived = 0;
    uint256 public usdtProfitsDistributed = 0;
    mapping(address => uint256) public usdtInvestorClaims;
    
    // Anti-collision for parcel IDs
    mapping(uint256 => bool) public parcelIdUsed;

    // --- EVENTS ---
    event ParcelSold(address indexed investor, uint256 tokenType, uint256 shares, uint256 parcelId);
    event StatusUpdated(CycleStatus newStatus);
    event ProfitDeposited(uint256 grossAmount, uint256 feeAmount, uint256 usdtNetAmount, uint256 fiatShareToTreasury);
    event ProfitClaimed(address indexed claimant, uint256 amount);
    event TreasuryFundsRecovered(uint256 amount, string reason);
    event ForeignTokensRecovered(address indexed token, uint256 amount);
    event URIUpdated(string newURI);

    // ============================================
    // CONSTRUCTOR
    // ============================================
    /**
     * @notice Initializes the AgriFi investment cycle
     * @param _maxTotalSupply Maximum number of shares available
     * @param uri_ Metadata URI for the NFTs
     * @param _paymentTokenAddress ERC20 token used for on-chain payments (USDT/USDC)
     * @param _treasuryAddress Trusted treasury address for fiat funds
     * @param _claimPeriodSeconds Time window for profit claims
     */
    constructor(
        uint256 _maxTotalSupply,
        string memory uri_,
        address _paymentTokenAddress,
        address _treasuryAddress,
        uint256 _claimPeriodSeconds
    ) ERC1155(uri_) Ownable(msg.sender) {
        require(_treasuryAddress != address(0), "Treasury cannot be zero address");
        require(_maxTotalSupply > 0, "Max supply must be positive");
        require(_claimPeriodSeconds > 0, "Claim period must be positive");

        MAX_TOTAL_SUPPLY = _maxTotalSupply;
        paymentToken = _paymentTokenAddress;
        treasuryAddress = _treasuryAddress;
        claimPeriodSeconds = _claimPeriodSeconds;
        cycleStartDate = block.timestamp;
    }

    // ============================================
    // MODIFIERS
    // ============================================
    modifier onlySaleOpen() { 
        require(currentStatus == CycleStatus.SALE_OPEN, "Sale not open"); 
        _; 
    }
    
    modifier onlyHarvesting() { 
        require(currentStatus == CycleStatus.HARVESTING, "Not harvesting"); 
        _; 
    }
    
    modifier onlyPayoutReady() { 
        require(currentStatus == CycleStatus.PAYOUT_READY, "Payout not ready"); 
        _; 
    }
    
    modifier onlyCompleted() { 
        require(currentStatus == CycleStatus.COMPLETED, "Cycle not completed"); 
        _; 
    }
    
    modifier validParcelId(uint256 parcelId) {
        require(!parcelIdUsed[parcelId], "Parcel ID already used");
        _;
    }

    // ============================================
    // SALE FUNCTIONS (OFF-CHAIN PAYMENTS)
    // ============================================
    /**
     * @notice Registers an on-chain investment (USDT/USDC payment processed off-chain)
     * @dev IMPORTANT: No payment is collected in this function. USDT must be sent separately.
     * @param investor Address receiving the investment shares
     * @param parcelId Unique identifier for the parcel (prevents double-minting)
     * @param shares Number of shares allocated to the investor
     */
    function sellParcelUSDT(
        address investor, 
        uint256 parcelId, 
        uint256 shares
    ) external onlyOwner onlySaleOpen nonReentrant validParcelId(parcelId) {
        require(investor != address(0), "Invalid investor address");
        require(totalSharesSold + shares <= MAX_TOTAL_SUPPLY, "Exceeds total supply");
        require(shares > 0, "Shares must be positive");

        _mint(investor, PARCELLE_USDT, shares, "");
        parcelIdUsed[parcelId] = true;
        usdtSharesSold += shares;
        totalSharesSold += shares;

        emit ParcelSold(investor, PARCELLE_USDT, shares, parcelId);
    }

    /**
     * @notice Registers a fiat investment (bank transfer processed off-chain)
     * @dev Mints proof-of-ownership NFT only. No on-chain funds are handled.
     * @param investor Address receiving the investment shares
     * @param parcelId Unique identifier for the parcel (prevents double-minting)
     * @param shares Number of shares allocated to the investor
     */
    function sellParcelFiat(
        address investor, 
        uint256 parcelId, 
        uint256 shares
    ) external onlyOwner onlySaleOpen validParcelId(parcelId) {
        require(investor != address(0), "Invalid investor address");
        require(totalSharesSold + shares <= MAX_TOTAL_SUPPLY, "Exceeds total supply");
        require(shares > 0, "Shares must be positive");

        _mint(investor, PARCELLE_FIAT, shares, "");
        parcelIdUsed[parcelId] = true;
        fiatSharesSold += shares;
        totalSharesSold += shares;

        emit ParcelSold(investor, PARCELLE_FIAT, shares, parcelId);
    }

    // ============================================
    // PROFIT MANAGEMENT FUNCTIONS
    // ============================================
    /**
     * @notice Deposits profits from agricultural sales
     * @dev Automatically splits profits between USDT investors (on-chain) and fiat investors (off-chain)
     *      Fiat portion is immediately transferred to treasury for manual distribution
     * @param grossAmount Total profit amount before fees (in paymentToken units)
     */
    function depositProfits(uint256 grossAmount) external onlyOwner onlyHarvesting nonReentrant {
        require(grossAmount > 0, "Amount must be positive");
        require(totalSharesSold > 0, "No shares sold");

        // 1. Calculate fees (10%)
        uint256 feeAmount = (grossAmount * FEE_PERCENTAGE) / FEE_SCALE;
        uint256 netAmount = grossAmount - feeAmount;

        // 2. Transfer gross amount to contract (assumes prior approval)
        IERC20(paymentToken).safeTransferFrom(msg.sender, address(this), grossAmount);

        // 3. Allocate profits based on share distribution
        uint256 usdtShare = 0;
        if (usdtSharesSold > 0) {
            usdtShare = (netAmount * usdtSharesSold) / totalSharesSold;
            usdtProfitsReceived += usdtShare;
        }

        // 4. Send fiat share + fees to treasury for off-chain distribution
        uint256 fiatShare = netAmount - usdtShare;
        uint256 totalToTreasury = feeAmount + fiatShare;
        
        if (totalToTreasury > 0) {
            IERC20(paymentToken).safeTransfer(treasuryAddress, totalToTreasury);
        }

        emit ProfitDeposited(grossAmount, feeAmount, usdtShare, totalToTreasury);
    }

    /**
     * @notice Claims profits for USDT investors
     * @dev Uses CEI pattern (Checks-Effects-Interactions) to prevent reentrancy
     *      No front-running protection needed as claims are proportional to holdings
     */
    function claimProfits() external nonReentrant {
        // Allow claims during PAYOUT_READY and COMPLETED phases
        require(
            currentStatus == CycleStatus.PAYOUT_READY || currentStatus == CycleStatus.COMPLETED,
            "Profits not available for claim"
        );
        
        require(usdtSharesSold > 0, "No USDT shares exist");
        
        uint256 tokenBalance = balanceOf(msg.sender, PARCELLE_USDT);
        require(tokenBalance > 0, "No USDT shares owned");
        
        uint256 totalDue = (usdtProfitsReceived * tokenBalance) / usdtSharesSold;
        uint256 alreadyClaimed = usdtInvestorClaims[msg.sender];
        
        require(totalDue > alreadyClaimed, "Nothing to claim");
        
        uint256 claimable = totalDue - alreadyClaimed;
        
        // Effects before interactions (CEI pattern)
        usdtInvestorClaims[msg.sender] = totalDue;
        usdtProfitsDistributed += claimable;
        
        // Interaction
        IERC20(paymentToken).safeTransfer(msg.sender, claimable);
        emit ProfitClaimed(msg.sender, claimable);
    }

    // ============================================
    // ADMINISTRATION FUNCTIONS
    // ============================================
    /**
     * @notice Updates the cycle status with state machine validation
     * @param newStatus Next status in the cycle
     */
    function setStatus(CycleStatus newStatus) external onlyOwner {
        require(isValidTransition(currentStatus, newStatus), "Invalid transition");
        
        if (newStatus == CycleStatus.PAYOUT_READY) {
            claimDeadline = block.timestamp + claimPeriodSeconds;
        }
        
        currentStatus = newStatus;
        emit StatusUpdated(newStatus);
    }
    
    /**
     * @notice Validates status transitions according to the cycle state machine
     */
    function isValidTransition(CycleStatus from, CycleStatus to) internal pure returns (bool) {
        if (to == CycleStatus.SALE_CLOSED) return from == CycleStatus.SALE_OPEN;
        if (to == CycleStatus.HARVESTING) return from == CycleStatus.SALE_CLOSED;
        if (to == CycleStatus.PAYOUT_READY) return from == CycleStatus.HARVESTING;
        if (to == CycleStatus.COMPLETED) return from == CycleStatus.PAYOUT_READY;
        return false;
    }

    /**
     * @notice Recovers only rounding residuals (excess beyond owed profits)
     * @dev Prevents withdrawal of investor funds while allowing recovery of dust amounts
     */
    function recoverRoundingResiduals() external onlyOwner nonReentrant {
        uint256 contractBalance = IERC20(paymentToken).balanceOf(address(this));
        uint256 profitsOwed = usdtProfitsReceived - usdtProfitsDistributed;
        
        // Ensure we only recover amounts beyond what investors are owed
        require(contractBalance > profitsOwed, "No residuals beyond owed profits");
        
        uint256 residuals = contractBalance - profitsOwed;
        
        IERC20(paymentToken).safeTransfer(treasuryAddress, residuals);
        emit TreasuryFundsRecovered(residuals, "Rounding residuals only");
    }

    /**
     * @notice Emergency recovery of all funds (30 days after claim deadline)
     * @dev Only for extreme situations where normal operation is impossible
     */
    function emergencyRecovery() external onlyOwner nonReentrant {
        require(currentStatus == CycleStatus.COMPLETED, "Must be completed");
        require(block.timestamp > claimDeadline + 30 days, "Must wait 30 days after deadline");
        
        uint256 contractBalance = IERC20(paymentToken).balanceOf(address(this));
        
        if (contractBalance > 0) {
            IERC20(paymentToken).safeTransfer(treasuryAddress, contractBalance);
            emit TreasuryFundsRecovered(contractBalance, "EMERGENCY RECOVERY - 30 days after deadline");
        }
    }

    /**
     * @notice Recovers ERC20 tokens sent to contract by mistake
     * @param token Address of the token to recover (cannot be paymentToken)
     */
    function recoverForeignTokens(address token) external onlyOwner nonReentrant {
        require(token != paymentToken, "Use recovery functions for payment token");
        
        uint256 balance = IERC20(token).balanceOf(address(this));
        require(balance > 0, "No tokens to recover");
        
        IERC20(token).safeTransfer(treasuryAddress, balance);
        emit ForeignTokensRecovered(token, balance);
    }

    /**
     * @notice Updates the metadata URI for all tokens
     * @param newuri New URI for token metadata
     */
    function setURI(string memory newuri) external onlyOwner {
        _setURI(newuri);
        emit URIUpdated(newuri);
    }

    // ============================================
    // VIEW FUNCTIONS
    // ============================================
    /**
     * @notice Calculates claimable profits for an investor
     * @param investor Address to check
     * @return amount Claimable profit amount
     */
    function calculateClaimable(address investor) external view returns (uint256) {
        if (usdtSharesSold == 0) return 0;
        
        uint256 tokenBalance = balanceOf(investor, PARCELLE_USDT);
        if (tokenBalance == 0) return 0;
        
        uint256 totalDue = (usdtProfitsReceived * tokenBalance) / usdtSharesSold;
        uint256 alreadyClaimed = usdtInvestorClaims[investor];
        
        return totalDue > alreadyClaimed ? totalDue - alreadyClaimed : 0;
    }
    
    /**
     * @notice Returns remaining shares available for sale
     */
    function getRemainingSupply() external view returns (uint256) {
        return MAX_TOTAL_SUPPLY - totalSharesSold;
    }
    
    /**
     * @notice Returns percentage of total shares sold
     */
    function getPercentageSold() external view returns (uint256) {
        if (MAX_TOTAL_SUPPLY == 0) return 0;
        return (totalSharesSold * 100) / MAX_TOTAL_SUPPLY;
    }
    
    /**
     * @notice Returns investor's holdings in both USDT and Fiat shares
     * @param investor Address to check
     * @return usdtBalance Number of USDT shares owned
     * @return fiatBalance Number of Fiat shares owned
     */
    function getInvestorHoldings(address investor) external view returns (uint256 usdtBalance, uint256 fiatBalance) {
        usdtBalance = balanceOf(investor, PARCELLE_USDT);
        fiatBalance = balanceOf(investor, PARCELLE_FIAT);
    }
    
    /**
     * @notice Returns comprehensive cycle information
     */
    function getCycleInfo() external view returns (
        CycleStatus status,
        uint256 startDate,
        uint256 usdtShares,
        uint256 fiatShares,
        uint256 totalProfitsReceived,
        uint256 profitsDistributed,
        uint256 remainingClaimTime,
        uint256 profitsOwed
    ) {
        status = currentStatus;
        startDate = cycleStartDate;
        usdtShares = usdtSharesSold;
        fiatShares = fiatSharesSold;
        totalProfitsReceived = usdtProfitsReceived;
        profitsDistributed = usdtProfitsDistributed;
        profitsOwed = usdtProfitsReceived - usdtProfitsDistributed;
        
        if (currentStatus == CycleStatus.PAYOUT_READY && block.timestamp < claimDeadline) {
            remainingClaimTime = claimDeadline - block.timestamp;
        } else {
            remainingClaimTime = 0;
        }
    }
    
    /**
     * @notice Returns contract balance information
     * @return currentBalance Total token balance in contract
     * @return profitsOwed Profits still owed to investors
     * @return residualsAvailable Residual amounts available for recovery
     */
    function getContractBalanceInfo() external view returns (
        uint256 currentBalance,
        uint256 profitsOwed,
        uint256 residualsAvailable
    ) {
        currentBalance = IERC20(paymentToken).balanceOf(address(this));
        profitsOwed = usdtProfitsReceived - usdtProfitsDistributed;
        
        if (currentBalance > profitsOwed) {
            residualsAvailable = currentBalance - profitsOwed;
        } else {
            residualsAvailable = 0;
        }
    }
}

Architecture Standard ERC1155

Le contrat utilise la norme Multi-Token (ERC1155). Contrairement au NFT classique (ERC721), il permet de gérer efficacement des fractions de parcelles.

  • ID 1 : Parcelles investies en USDT.
  • ID 2 : Parcelles investies en Fiat (Preuve uniquement).

Logique Cycle de Vie (State Machine)

Le contrat suit une progression logique stricte définie par l'énumération CycleStatus.

On ne peut pas distribuer les profits si le statut n'est pas PAYOUT_READY, garantissant la cohérence financière.

Sécurité ReentrancyGuard & SafeERC20

Protection contre les attaques de réentrée via le modificateur nonReentrant d'OpenZeppelin.

Utilisation de SafeERC20 pour les transferts d'USDT, gérant proprement les tokens qui ne renvoient pas de booléen lors d'un transfert.

Finance Distribution des Profits

La fonction depositProfits automatise la ventilation :

  • 10% : Frais de gestion AgriFi.
  • Quote-part USDT : Séquestrée sur le contrat pour réclame manuelle.
  • Quote-part Fiat : Envoyée à la Trésorerie pour paiement hors-chaîne.

🛠️ Note de Transparence

Ce contrat est immuable une fois déployé. L'administrateur (Owner) peut changer le statut du cycle et mettre à jour les métadonnées, mais il ne peut en aucun cas retirer les fonds déposés pour les investisseurs USDT (sécurité garantie par recoverRoundingResiduals qui vérifie le solde dû).