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)
Setup
Deploy token; grant MINTER_ROLE to the offering.
Deploy offering with all params.
Contributions
Buyers call buy(amountIn) (or send ETH), contract books contributed and purchasedFractions.
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.
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.senderRefund 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