// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {IReserveFeed} from "./IReserveFeed.sol"; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; /// @title GoldBackedToken /// @author sigmacode.io (Sanicura d.o.o.) /// @notice Real-world-asset (RWA) demo: 1 token (18 decimals) represents 1 gram of physical gold held /// by a custodian. New supply can only be minted if it stays fully covered by the latest /// proof-of-reserve reported by an oracle. /// @dev Invariant enforced on every mint: `totalSupply() + amount <= reserve()`, where the reserve is read /// from an {IReserveFeed} (Chainlink `AggregatorV3Interface` compatible), normalised to 18 decimals, /// and rejected if it is non-positive, incomplete or older than {maxStaleness}. /// /// Redemption flow: a holder calls {redeem} with an off-chain reference (e.g. a hashed custodian /// ticket id); tokens are burned and {RedemptionRequested} lets the custodian deliver the gold. /// /// Roles: /// - `DEFAULT_ADMIN_ROLE`: manages roles, feed address and staleness threshold. /// - `MINTER_ROLE`: mints new supply against reserves. /// - `PAUSER_ROLE`: pauses/unpauses all transfers, mints and redemptions. /// /// Showcase token only: it has no monetary value, is not backed by real gold and is not offered for sale. contract GoldBackedToken is ERC20, ERC20Permit, AccessControl, Pausable { /// @notice Role allowed to mint. bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); /// @notice Role allowed to pause and unpause. bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); /// @notice Upper bound for {maxStaleness} to prevent disabling the freshness check. uint256 public constant MAX_STALENESS_LIMIT = 7 days; /// @notice Proof-of-reserve oracle. IReserveFeed public reserveFeed; /// @notice Maximum accepted age of reserve data, in seconds. uint256 public maxStaleness; /// @notice Emitted when tokens are minted against reserves. event Minted(address indexed to, uint256 amount, uint256 reserve, uint256 newTotalSupply); /// @notice Emitted when a holder burns tokens to redeem physical gold. event RedemptionRequested(address indexed holder, uint256 amount, bytes32 indexed redemptionRef); /// @notice Emitted when the reserve feed changes. event ReserveFeedUpdated(address indexed oldFeed, address indexed newFeed); /// @notice Emitted when the staleness threshold changes. event MaxStalenessUpdated(uint256 oldValue, uint256 newValue); error ZeroAddress(); error ZeroAmount(); error InvalidStaleness(uint256 value); error InvalidReserve(int256 answer); error StaleReserve(uint256 updatedAt, uint256 maxStaleness); error InsufficientReserve(uint256 newTotalSupply, uint256 reserve); error EmptyRedemptionRef(); /// @param admin Receives `DEFAULT_ADMIN_ROLE` and `PAUSER_ROLE`. /// @param minter Receives `MINTER_ROLE`. /// @param feed Proof-of-reserve oracle. /// @param maxStaleness_ Maximum accepted age of reserve data in seconds. constructor(address admin, address minter, IReserveFeed feed, uint256 maxStaleness_) ERC20("Sigma Gold Gram", "sGOLD") ERC20Permit("Sigma Gold Gram") { if (admin == address(0) || minter == address(0) || address(feed) == address(0)) revert ZeroAddress(); if (maxStaleness_ == 0 || maxStaleness_ > MAX_STALENESS_LIMIT) revert InvalidStaleness(maxStaleness_); _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(PAUSER_ROLE, admin); _grantRole(MINTER_ROLE, minter); reserveFeed = feed; maxStaleness = maxStaleness_; emit ReserveFeedUpdated(address(0), address(feed)); emit MaxStalenessUpdated(0, maxStaleness_); } // --------------------------------------------------------------------- // Mint / redeem // --------------------------------------------------------------------- /// @notice Mints `amount` tokens (grams, 18 decimals) to `to` if fully covered by reserves. /// @param to Receiver. /// @param amount Amount to mint. function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) { if (to == address(0)) revert ZeroAddress(); if (amount == 0) revert ZeroAmount(); uint256 reserve_ = reserve(); uint256 newSupply = totalSupply() + amount; if (newSupply > reserve_) revert InsufficientReserve(newSupply, reserve_); _mint(to, amount); emit Minted(to, amount, reserve_, newSupply); } /// @notice Burns `amount` tokens to request physical delivery / cash settlement off-chain. /// @param amount Amount to redeem. /// @param redemptionRef Off-chain reference linking this burn to a custodian ticket. function redeem(uint256 amount, bytes32 redemptionRef) external { if (amount == 0) revert ZeroAmount(); if (redemptionRef == bytes32(0)) revert EmptyRedemptionRef(); _burn(msg.sender, amount); emit RedemptionRequested(msg.sender, amount, redemptionRef); } // --------------------------------------------------------------------- // Admin // --------------------------------------------------------------------- /// @notice Pauses transfers, mints and redemptions. function pause() external onlyRole(PAUSER_ROLE) { _pause(); } /// @notice Unpauses the token. function unpause() external onlyRole(PAUSER_ROLE) { _unpause(); } /// @notice Replaces the proof-of-reserve feed. function setReserveFeed(IReserveFeed newFeed) external onlyRole(DEFAULT_ADMIN_ROLE) { if (address(newFeed) == address(0)) revert ZeroAddress(); emit ReserveFeedUpdated(address(reserveFeed), address(newFeed)); reserveFeed = newFeed; } /// @notice Sets the maximum accepted age of reserve data. function setMaxStaleness(uint256 newMaxStaleness) external onlyRole(DEFAULT_ADMIN_ROLE) { if (newMaxStaleness == 0 || newMaxStaleness > MAX_STALENESS_LIMIT) revert InvalidStaleness(newMaxStaleness); emit MaxStalenessUpdated(maxStaleness, newMaxStaleness); maxStaleness = newMaxStaleness; } // --------------------------------------------------------------------- // Views // --------------------------------------------------------------------- /// @notice Latest validated reserve in grams, normalised to 18 decimals. /// @dev Reverts if the answer is non-positive, incomplete or stale. function reserve() public view returns (uint256) { (, int256 answer,, uint256 updatedAt,) = reserveFeed.latestRoundData(); if (answer <= 0) revert InvalidReserve(answer); if (updatedAt == 0 || updatedAt > block.timestamp || block.timestamp - updatedAt > maxStaleness) { revert StaleReserve(updatedAt, maxStaleness); } uint8 feedDecimals = reserveFeed.decimals(); // Safe: `answer > 0` was checked above. // forge-lint: disable-next-line(unsafe-typecast) uint256 raw = uint256(answer); if (feedDecimals < 18) return raw * 10 ** (18 - feedDecimals); if (feedDecimals > 18) return raw / 10 ** (feedDecimals - 18); return raw; } /// @notice Collateralisation ratio in basis points (10_000 = 100%). Returns max uint if supply is zero. function collateralRatioBps() external view returns (uint256) { uint256 supply = totalSupply(); if (supply == 0) return type(uint256).max; return (reserve() * 10_000) / supply; } /// @dev All balance changes (mint, burn, transfer) are blocked while paused. function _update(address from, address to, uint256 value) internal override whenNotPaused { super._update(from, to, value); } }