RealOffering.sol

Overview

RealOffering runs a primary sale for a single fractionalized asset series. It collects funds in an ERC-20 payment token (e.g., USDC/USDT) or native ETH during a fixed window, enforces soft/hard caps and per-wallet limits, and then either:

  • Succeeds: issuer finalizes, funds are forwarded, buyers claim fractions; or

  • Fails (soft cap unmet): refunds are enabled and contributors redeem their funds.

Pricing uses a fixed rate: fractionsOut = amountIn * rate / 1e18.


Responsibilities (Single Responsibility Principle)

  • Track contributions and compute purchased fractions at a fixed price.

  • Enforce caps (soft/hard) and per-wallet min/max in payment units.

  • Lock sale to a start/end window; expose a precise status.

  • Provide finalize → claim flow on success, refund flow on failure.

  • Respect permissioned assets by gating buyers via the asset token’s eligibility hook.

Non-goals:

  • Order-book logic (use P2P/Seaport later).

  • Upgradability/proxy patterns (keep the sale contract simple; proxy the factory if needed).


Key Data and Storage

  • Immutable parameters set at construction:

    • issuer (receives funds), token (fractional ERC-20), payToken (ERC-20 or address(0) for ETH)

    • rate, softCap, hardCap, start, end, minPerWallet, maxPerWallet

  • Mutable:

    • totalRaised, finalized, refundsEnabled

    • contributed[buyer] (payment units)

    • purchasedFractions[buyer], claimedFractions[buyer]


State Machine

status() returns one of:

  • PENDING: block.timestamp < start

  • ACTIVE: start ≤ now ≤ end and totalRaised < hardCap

  • ENDED: window over or hard cap reached (pre-finalize)

  • FAILED: set after finalize() if totalRaised < softCap

Transitions:

PENDING → ACTIVE → ENDED → (finalize)
                               ├─ if totalRaised ≥ softCap → ENDED (success) + claim()
                               └─ else → FAILED + claimRefund()

Control Flow (Happy Path)

  1. Setup

    • Deploy token; grant MINTER_ROLE to the offering.

    • Deploy offering with all params.

  2. Contributions

    • Buyers call buy(amountIn) (or send ETH), contract books contributed and purchasedFractions.

  3. Finalize

    • After end (or earlier if hardCap hit), issuer.finalize().

    • If totalRaised ≥ softCap: funds forwarded to issuer, buyers can claim() to mint fractions.

    • Else: refundsEnabled = true.

  4. Settlement

    • Success: buyers claim() (mint via token’s mint()).

    • Failure: buyers claimRefund().


Compliance Integration

  • The token exposes permissioned() and isEligible(account).

  • On buy(): if permissioned, the offering requires isEligible(buyer) == true.

  • Minting on claim() goes through the token’s compliance hook (in token _update), so recipient eligibility still applies.


Rate and Units

  • rate is fractions per 1 payment unit, scaled by 1e18.

  • For USDC, pass amountIn in 6-dec units; conversion to fractions is always amountIn * rate / 1e18.


Events and Indexing

  • Purchased(buyer, amountIn, fractionsOut)

  • Finalized(totalRaised, success)

  • Claimed(buyer, fractionsOut)

  • Refunded(buyer, amountIn)

Indexers typically derive:

  • Cumulative contributions per buyer

  • Claimed vs unclaimed fractions

  • Sale status timeline


Error Handling (typical guards)

  • NotActive on early/late buys; NotEnded/AlreadyFinalized on finalize.

  • Min/max per wallet; hardCap limit; ETH vs ERC-20 mismatches.


Security Considerations

  • Reentrancy guarded on state-changing endpoints.

  • ETH sends use pull-payments pattern for refunds; issuer transfer on finalize via low-level call with revert check.

  • Ensure the token has granted MINTER_ROLE to the offering before calling claim() (deployment scripts should enforce).

  • The token’s mint() will still invoke compliance; failed mint will revert claim.


Gas/UX Notes

  • Fixed-price arithmetic is O(1).

  • claim() mints on demand—spreads gas vs bulk airdrops.

  • For large cohorts, you can add a bulk claim host (optional) or front-end batchers.


Minimal Integration Snippets

Investor contribution (ERC-20):

IERC20(usdc).approve(offering, 1_000e6);
RealOffering(offering).buy(1_000e6);

Finalize and claim:

RealOffering(offering).finalize();
// later
RealOffering(offering).claim(); // mints fractions to msg.sender

Refund path:

RealOffering(offering).claimRefund();

RealOffering.sol

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

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/// @notice Minimal interface for the Real fractional token used in primary offerings.
interface IRealFractionalToken {
    /// @dev Mints `amount` of fractional tokens to `to`. The Offering must hold MINTER_ROLE.
    function mint(address to, uint256 amount) external;

    /// @dev True if the token enforces permissioned transfers/participation.
    function permissioned() external view returns (bool);

    /// @dev Eligibility check used when `permissioned()` is true.
    function isEligible(address account) external view returns (bool);
}

/// @title RealOffering
/// @notice Primary sale contract for fractionalized real-world assets.
/// @dev Accepts an ERC-20 payment token *or* native ETH. Investors receive claimable
///      allocations of fractions at a fixed rate; actual token minting occurs on `claim()`
///      after a successful `finalize()`. If the sale fails (soft cap unmet), `claimRefund()`
///      returns contributed funds.
///
///      Design notes:
///      - `rate` uses 1e18 fixed point: fractionsOut = amountIn * rate / 1e18
///      - `amountIn` is specified in the *smallest unit* of the payment asset (wei for ETH).
///      - Per-wallet min/max and hard cap are enforced in payment units (not in fractions).
///      - The offering contract must be granted MINTER_ROLE on the fractional token.
contract RealOffering is ReentrancyGuard {
    using SafeERC20 for IERC20;

    // ====== Types / Events / Errors ======

    enum Status { PENDING, ACTIVE, ENDED, FAILED }

    event Purchased(address indexed buyer, uint256 amountIn, uint256 fractionsOut);
    event Finalized(uint256 totalRaised, bool success);
    event Claimed(address indexed buyer, uint256 fractionsOut);
    event Refunded(address indexed buyer, uint256 amountIn);

    error NotIssuer();
    error NotActive();
    error NotEnded();
    error AlreadyFinalized();
    error RefundsDisabled();
    error NothingToClaim();
    error NothingToRefund();

    // ====== Immutable Sale Configuration ======

    /// @notice Issuer address that receives funds on successful finalize.
    address public immutable issuer;

    /// @notice Fractional token representing the asset being sold.
    IRealFractionalToken public immutable token;

    /// @notice ERC-20 payment token (if zero address, the sale accepts native ETH).
    IERC20 public immutable payToken;

    /// @notice Fixed price: fractions per 1 *payment unit* (scaled by 1e18).
    ///         Example: if 1 USDC (6 decimals) should buy 100 fractions, set rate = 100e18.
    uint256 public immutable rate;

    /// @notice Minimum total raised for a successful sale (in payment units).
    uint256 public immutable softCap;

    /// @notice Maximum total raised (in payment units).
    uint256 public immutable hardCap;

    /// @notice Sale start and end timestamps (UTC seconds).
    uint64 public immutable start;
    uint64 public immutable end;

    /// @notice Per-wallet min/max contribution (in payment units).
    uint256 public immutable minPerWallet;
    uint256 public immutable maxPerWallet;

    // ====== Mutable State ======

    /// @notice Total contributed across all buyers (in payment units).
    uint256 public totalRaised;

    /// @notice Finalization flags.
    bool public finalized;
    bool public refundsEnabled; // set true if softCap unmet at finalize

    /// @notice Per-buyer contributed amount (in payment units).
    mapping(address => uint256) public contributed;

    /// @notice Per-buyer purchased fractions (claimable after successful finalize).
    mapping(address => uint256) public purchasedFractions;

    /// @notice Per-buyer amount of fractions already claimed.
    mapping(address => uint256) public claimedFractions;

    // ====== Constructor ======

    /// @param _issuer Receives funds on successful finalize
    /// @param _token Address of the fractional token (Offering needs MINTER_ROLE)
    /// @param _payToken ERC-20 payment token address; use zero address to accept native ETH
    /// @param _rate Fractions per 1 payment unit (scaled by 1e18)
    /// @param _softCap Minimum total raised (payment units) required for success
    /// @param _hardCap Maximum total raised (payment units)
    /// @param _start Sale start timestamp
    /// @param _end Sale end timestamp
    /// @param _minPerWallet Minimum contribution per wallet (payment units)
    /// @param _maxPerWallet Maximum contribution per wallet (payment units)
    constructor(
        address _issuer,
        address _token,
        address _payToken,
        uint256 _rate,
        uint256 _softCap,
        uint256 _hardCap,
        uint64  _start,
        uint64  _end,
        uint256 _minPerWallet,
        uint256 _maxPerWallet
    ) {
        require(_issuer != address(0) && _token != address(0), "Invalid params");
        require(_start < _end, "Invalid window");
        require(_hardCap >= _softCap, "Hard < soft");

        issuer        = _issuer;
        token         = IRealFractionalToken(_token);
        payToken      = IERC20(_payToken);
        rate          = _rate;
        softCap       = _softCap;
        hardCap       = _hardCap;
        start         = _start;
        end           = _end;
        minPerWallet  = _minPerWallet;
        maxPerWallet  = _maxPerWallet;
    }

    // ====== Views ======

    /// @notice Current sale status.
    function status() public view returns (Status s) {
        if (finalized) return refundsEnabled ? Status.FAILED : Status.ENDED;
        if (block.timestamp < start) return Status.PENDING;
        if (block.timestamp <= end && totalRaised < hardCap) return Status.ACTIVE;
        return Status.ENDED;
    }

    /// @notice Fractions claimable by `account` after successful finalize.
    function claimable(address account) public view returns (uint256) {
        return purchasedFractions[account] - claimedFractions[account];
    }

    // ====== Core: Buy / Finalize / Claim / Refund ======

    /// @notice Contribute to the sale. For ETH sales, send ETH and set `amountIn = msg.value`.
    /// @param amountIn Contribution amount in payment units (ignored for ETH if msg.value used).
    function buy(uint256 amountIn) external payable nonReentrant {
        if (status() != Status.ACTIVE) revert NotActive();

        // Permissioned sale check
        if (token.permissioned()) {
            require(token.isEligible(msg.sender), "Buyer ineligible");
        }

        // Collect payment
        uint256 received;
        if (_isETHSale()) {
            require(amountIn == msg.value, "ETH mismatch");
            received = msg.value;
        } else {
            require(msg.value == 0, "No ETH accepted");
            payToken.safeTransferFrom(msg.sender, address(this), amountIn);
            received = amountIn;
        }

        // Enforce per-wallet limits & hard cap
        uint256 newContribution = contributed[msg.sender] + received;
        require(newContribution >= minPerWallet, "Below min");
        require(newContribution <= maxPerWallet, "Above max");
        require(totalRaised + received <= hardCap, "Hard cap reached");

        // Book contribution and fractions purchased at current fixed rate
        contributed[msg.sender] = newContribution;
        totalRaised += received;

        uint256 fractionsOut = (received * rate) / 1e18;
        purchasedFractions[msg.sender] += fractionsOut;

        emit Purchased(msg.sender, received, fractionsOut);
    }

    /// @notice Finalize the sale:
    /// - If soft cap met: send funds to issuer; buyers can call `claim()` to mint their fractions.
    /// - If soft cap unmet: enable refunds (`claimRefund()`).
    function finalize() external nonReentrant {
        if (msg.sender != issuer) revert NotIssuer();
        if (finalized) revert AlreadyFinalized();

        Status st = status();
        if (st == Status.ACTIVE || block.timestamp <= end) revert NotEnded();

        finalized = true;

        if (totalRaised < softCap) {
            refundsEnabled = true;
            emit Finalized(totalRaised, false);
            return;
        }

        // Success: forward funds to issuer
        if (_isETHSale()) {
            (bool ok,) = issuer.call{value: address(this).balance}("");
            require(ok, "ETH transfer failed");
        } else {
            payToken.safeTransfer(issuer, totalRaised);
        }

        emit Finalized(totalRaised, true);
    }

    /// @notice Claim purchased fractions after a successful finalize.
    function claim() external nonReentrant {
        if (!finalized || refundsEnabled) revert NotEnded();
        uint256 amt = claimable(msg.sender);
        if (amt == 0) revert NothingToClaim();

        claimedFractions[msg.sender] += amt;
        // Mint directly to claimer; Offering must have MINTER_ROLE on the token.
        token.mint(msg.sender, amt);

        emit Claimed(msg.sender, amt);
    }

    /// @notice Claim a refund if the offering failed (soft cap unmet).
    function claimRefund() external nonReentrant {
        if (!refundsEnabled) revert RefundsDisabled();
        uint256 amt = contributed[msg.sender];
        if (amt == 0) revert NothingToRefund();

        // Zero out buyer accounting
        contributed[msg.sender] = 0;
        purchasedFractions[msg.sender] = 0;
        claimedFractions[msg.sender] = 0;

        if (_isETHSale()) {
            (bool ok,) = msg.sender.call{value: amt}("");
            require(ok, "ETH refund failed");
        } else {
            payToken.safeTransfer(msg.sender, amt);
        }

        emit Refunded(msg.sender, amt);
    }

    // ====== Internal ======

    function _isETHSale() internal view returns (bool) {
        return address(payToken) == address(0);
    }

    // Accept ETH for ETH-denominated sales
    receive() external payable {}
}

Last updated