CosigoRedeem

Description:

Smart contract deployed on Ethereum.

Blockchain: Ethereum

Source Code: View Code On The Blockchain

Solidity Source Code:

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

/// @title Cosigo Redeem (limits + allow/deny + cooldown + optional shipping fee)
/// @notice
/// - In-person:  use redeemPhysicalSilver(...).
/// - Shipped:    use redeemAndShip(...), which first pulls a flat shipping fee
///               (in a configured stable ERC20) to a shipping sink, then burns COSIGO by
///               transferring to your sink (burn/accounting vault).
/// - Token-side transfer fees (e.g., 0.5%) still apply if your COSIGO token taxes transfers.
///   Consider exempting this redeemer or the sink on the token if you need exact mg burned.

interface IERC20 {
    function transfer(address to, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);

    function allowance(address owner, address spender) external view returns (uint256);
    function balanceOf(address who) external view returns (uint256);
}

interface IERC20Metadata is IERC20 {
    function decimals() external view returns (uint8);
}

contract CosigoRedeem {
    // ---------------- token / config ----------------
    address public immutable token;          // COSIGO token
    uint8   public immutable tokenDecimals;
    uint256 public immutable decMultiplier;  // 10 ** tokenDecimals

    // ---------------- ownership / sinks ----------------
    address public owner;                    // simple ownable
    address public pendingOwner;             // two-step ownership
    address public sink;                     // burn/accounting vault for COSIGO

    // ---------------- pausing ----------------
    bool public paused;                      // pause all redemptions
    bool public shipPaused;                  // pause only shipping path

    // ---------------- limits (mg) ----------------
    uint256 public minRedemptionMg;          // 0 = disabled
    uint256 public maxRedemptionMg;          // 0 = disabled
    uint256 public dailyUserLimitMg;         // 0 = disabled
    uint256 public dailyGlobalLimitMg;       // 0 = disabled

    // ---------------- shipping (stable coin) ----------------
    address public shippingStable;           // e.g., TUSD/USDC (ERC20); 0x0 disables shipping
    address public shippingSink;             // receiver of shipping fee
    uint256 public shippingFee;              // in shippingStable smallest units

    // ---------------- allow/deny + cooldown ----------------
    mapping(address => bool) public deny;    // blocked users
    mapping(address => bool) public allow;   // allowlist
    bool public allowlistEnabled;            // if true, only addresses in `allow` may redeem
    uint256 public cooldownSecs;             // min seconds between redemptions per user
    mapping(address => uint256) public lastRedeemAt;

    // ---------------- day accounting (mg) ----------------
    mapping(uint256 => uint256) private _globalDayMg;
    mapping(uint256 => mapping(address => uint256)) private _userDayMg;

    // ---------------- reentrancy guard ----------------
    uint256 private _lock = 1;
    modifier nonReentrant() {
        require(_lock == 1, "REENTRANCY");
        _lock = 2;
        _;
        _lock = 1;
    }

    // ---------------- events ----------------
    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
    event OwnershipPending(address indexed previousOwner, address indexed pendingOwner);
    event Paused(bool ok);
    event ShipPaused(bool ok);
    event SinkUpdated(address sink);
    event MinRedemptionUpdated(uint256 mg);
    event MaxRedemptionUpdated(uint256 mg);
    event DailyUserLimitUpdated(uint256 mg);
    event DailyGlobalLimitUpdated(uint256 mg);
    event CooldownUpdated(uint256 secs);
    event AllowlistToggled(bool enabled);
    event AllowUpdated(address indexed user, bool allowed);
    event DenyUpdated(address indexed user, bool denied);
    event ShippingConfigUpdated(address stable, address sink, uint256 fee);
    event ShippingFeeTaken(address indexed user, address indexed stable, address indexed sink, uint256 fee);
    event Redeemed(address indexed user, uint256 mg, bytes32 refHash, string refText);
    event Burned(address indexed user, uint256 mg);

    // ---------------- errors ----------------
    error NotOwner();
    error BadAddress();
    error PausedError();
    error ShipPausedError();
    error BelowMinimum();
    error AboveMaximum();
    error DailyUserCapExceeded();
    error DailyGlobalCapExceeded();
    error Denied();
    error NotAllowed();
    error Cooldown();
    error TransferFailed();
    error ShippingNotConfigured();

    // ---------------- modifiers ----------------
    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }
    modifier notPaused() {
        if (paused) revert PausedError();
        _;
    }

    /// @param _token COSIGO token address
    /// @param _sink  burn/accounting vault (your address for burn accounting)
    /// @param _minMg minimum redemption in mg (0 to disable)
    constructor(address _token, address _sink, uint256 _minMg) {
        if (_token == address(0) || _sink == address(0)) revert BadAddress();

        token = _token;
        owner = msg.sender;
        emit OwnershipTransferred(address(0), msg.sender);

        uint8 dec = IERC20Metadata(_token).decimals();
        tokenDecimals = dec;
        decMultiplier = 10 ** uint256(dec);

        sink = _sink;
        emit SinkUpdated(_sink);

        minRedemptionMg = _minMg;
        emit MinRedemptionUpdated(_minMg);
    }

    // ---------------- owner admin ----------------

    // two-step ownership
    function transferOwnership(address newOwner) external onlyOwner {
        if (newOwner == address(0)) revert BadAddress();
        pendingOwner = newOwner;
        emit OwnershipPending(owner, newOwner);
    }
    function acceptOwnership() external {
        if (msg.sender != pendingOwner) revert NotOwner();
        emit OwnershipTransferred(owner, pendingOwner);
        owner = pendingOwner;
        pendingOwner = address(0);
    }

    function setPaused(bool _p) external onlyOwner {
        paused = _p;
        emit Paused(_p);
    }
    function setShipPaused(bool _p) external onlyOwner {
        shipPaused = _p;
        emit ShipPaused(_p);
    }

    function setSink(address _sink) external onlyOwner {
        if (_sink == address(0)) revert BadAddress();
        sink = _sink;
        emit SinkUpdated(_sink);
    }

    // limits (all mg; 0 disables that limit)
    function setMinRedemptionMg(uint256 mg) external onlyOwner {
        minRedemptionMg = mg;
        emit MinRedemptionUpdated(mg);
    }
    function setMaxRedemptionMg(uint256 mg) external onlyOwner {
        maxRedemptionMg = mg;
        emit MaxRedemptionUpdated(mg);
    }
    function setDailyUserLimitMg(uint256 mg) external onlyOwner {
        dailyUserLimitMg = mg;
        emit DailyUserLimitUpdated(mg);
    }
    function setDailyGlobalLimitMg(uint256 mg) external onlyOwner {
        dailyGlobalLimitMg = mg;
        emit DailyGlobalLimitUpdated(mg);
    }

    // cooldown
    function setCooldown(uint256 secs) external onlyOwner {
        cooldownSecs = secs;
        emit CooldownUpdated(secs);
    }

    // allow/deny
    function setAllowlistEnabled(bool b) external onlyOwner {
        allowlistEnabled = b;
        emit AllowlistToggled(b);
    }
    function setAllowed(address u, bool b) external onlyOwner {
        allow[u] = b;
        emit AllowUpdated(u, b);
    }
    function setDenied(address u, bool b) external onlyOwner {
        deny[u] = b;
        emit DenyUpdated(u, b);
    }

    // shipping config: set (stable token, fee receiver, flat fee in smallest units)
    // set shippingStable = address(0) to disable shipping entirely.
    function setShippingConfig(address _stable, address _feeSink, uint256 _fee) external onlyOwner {
        if (_stable == address(0)) {
            shippingStable = address(0);
            shippingSink   = address(0);
            shippingFee    = 0;
            emit ShippingConfigUpdated(address(0), address(0), 0);
            return;
        }
        if (_feeSink == address(0)) revert BadAddress();
        shippingStable = _stable;
        shippingSink   = _feeSink;
        shippingFee    = _fee;
        emit ShippingConfigUpdated(_stable, _feeSink, _fee);
    }

    // ---------------- core (IN-PERSON) ----------------

    /// @notice Redeem (in-person) using a human-readable string reference
    function redeemPhysicalSilver(uint256 amountMg, string calldata shippingRef)
        external
        notPaused
        nonReentrant
    {
        _enforceAndBook(msg.sender, amountMg);
        bytes32 h = keccak256(bytes(shippingRef));
        _consumeAndSink(msg.sender, amountMg);
        emit Redeemed(msg.sender, amountMg, h, shippingRef);
    }

    /// @notice Redeem (in-person) using a bytes32 reference
    function redeemPhysicalSilver(uint256 amountMg, bytes32 shippingRef32)
        external
        notPaused
        nonReentrant
    {
        _enforceAndBook(msg.sender, amountMg);
        _consumeAndSink(msg.sender, amountMg);
        emit Redeemed(msg.sender, amountMg, shippingRef32, "");
    }

    // ---------------- core (SHIPPED: takes stablecoin fee) ----------------

    /// @notice Redeem (shipped) with string ref; pulls `shippingFee` in `shippingStable` to `shippingSink` first
    function redeemAndShip(uint256 amountMg, string calldata shippingRef)
        external
        notPaused
        nonReentrant
    {
        if (shipPaused) revert ShipPausedError();
        _enforceAndBook(msg.sender, amountMg);
        _takeShippingFee(msg.sender);
        bytes32 h = keccak256(bytes(shippingRef));
        _consumeAndSink(msg.sender, amountMg);
        emit Redeemed(msg.sender, amountMg, h, shippingRef);
    }

    /// @notice Redeem (shipped) with bytes32 ref; pulls `shippingFee` first
    function redeemAndShip(uint256 amountMg, bytes32 shippingRef32)
        external
        notPaused
        nonReentrant
    {
        if (shipPaused) revert ShipPausedError();
        _enforceAndBook(msg.sender, amountMg);
        _takeShippingFee(msg.sender);
        _consumeAndSink(msg.sender, amountMg);
        emit Redeemed(msg.sender, amountMg, shippingRef32, "");
    }

    /// @notice Burn-only path (no shipping reference, no shipping fee)
    function burnTokens(uint256 amountMg) external notPaused nonReentrant {
        _enforceAndBook(msg.sender, amountMg);
        _consumeAndSink(msg.sender, amountMg);
        emit Burned(msg.sender, amountMg);
    }

    // ---------------- views (for UI) ----------------

    function epochDay() public view returns (uint256) {
        return block.timestamp / 1 days; // UTC-day
    }

    function userRedeemedTodayMg(address user_) public view returns (uint256) {
        return _userDayMg[epochDay()][user_];
    }

    function globalRedeemedTodayMg() public view returns (uint256) {
        return _globalDayMg[epochDay()];
    }

    function remainingTodayForUserMg(address user_) external view returns (uint256) {
        if (dailyUserLimitMg == 0) return type(uint256).max;
        uint256 used = _userDayMg[epochDay()][user_];
        return used >= dailyUserLimitMg ? 0 : (dailyUserLimitMg - used);
    }

    function remainingTodayGlobalMg() external view returns (uint256) {
        if (dailyGlobalLimitMg == 0) return type(uint256).max;
        uint256 used = _globalDayMg[epochDay()];
        return used >= dailyGlobalLimitMg ? 0 : (dailyGlobalLimitMg - used);
    }

    function shippingInfo() external view returns (address stable, address feeSink, uint256 fee) {
        return (shippingStable, shippingSink, shippingFee);
    }

    // ---------------- internal ----------------

    function _enforceAndBook(address u, uint256 mg) internal {
        if (deny[u]) revert Denied();
        if (allowlistEnabled && !allow[u]) revert NotAllowed();

        if (cooldownSecs != 0) {
            uint256 t = lastRedeemAt[u];
            if (block.timestamp < t + cooldownSecs) revert Cooldown();
            lastRedeemAt[u] = block.timestamp;
        }

        if (mg == 0 || (minRedemptionMg > 0 && mg < minRedemptionMg)) revert BelowMinimum();
        if (maxRedemptionMg > 0 && mg > maxRedemptionMg) revert AboveMaximum();

        uint256 day = epochDay();

        if (dailyUserLimitMg > 0) {
            uint256 usedU = _userDayMg[day][u];
            if (usedU + mg > dailyUserLimitMg) revert DailyUserCapExceeded();
            _userDayMg[day][u] = usedU + mg;
        }

        if (dailyGlobalLimitMg > 0) {
            uint256 usedG = _globalDayMg[day];
            if (usedG + mg > dailyGlobalLimitMg) revert DailyGlobalCapExceeded();
            _globalDayMg[day] = usedG + mg;
        }
    }

    function _consumeAndSink(address from, uint256 mg) internal {
        // convert mg -> token base units safely
        unchecked {
            if (mg > (type(uint256).max / decMultiplier)) revert TransferFailed();
        }
        uint256 units = mg * decMultiplier;

        // pull from user and forward to sink
        (bool ok1, bytes memory d1) = token.call(
            abi.encodeWithSelector(IERC20.transferFrom.selector, from, sink, units)
        );
        if (!ok1 || (d1.length != 0 && !abi.decode(d1, (bool)))) revert TransferFailed();
        // NOTE: if the token is fee-on-transfer, the sink will receive less than `units`.
    }

    function _takeShippingFee(address from) internal {
        if (shippingStable == address(0) || shippingFee == 0) revert ShippingNotConfigured();
        if (shippingSink == address(0)) revert BadAddress();

        (bool ok, bytes memory ret) = shippingStable.call(
            abi.encodeWithSelector(IERC20.transferFrom.selector, from, shippingSink, shippingFee)
        );
        if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TransferFailed();

        emit ShippingFeeTaken(from, shippingStable, shippingSink, shippingFee);
    }

    // ---------------- rescue / sweep ----------------

    /// @notice Sweep any ERC20 mistakenly sent to this contract.
    function sweepERC20(address erc20, address to, uint256 amt) external onlyOwner nonReentrant {
        if (erc20 == address(0) || to == address(0)) revert BadAddress();
        (bool ok, bytes memory ret) = erc20.call(abi.encodeWithSelector(IERC20.transfer.selector, to, amt));
        if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert TransferFailed();
    }

    /// @notice Sweep ETH (if any) sent to this contract.
    function sweepETH(address payable to, uint256 amt) external onlyOwner nonReentrant {
        if (to == address(0)) revert BadAddress();
        (bool ok, ) = to.call{value: amt}("");
        if (!ok) revert TransferFailed();
    }

    // allow receiving ETH (not used but safe)
    receive() external payable {}
    fallback() external payable {}
}

Tags:
addr:0x3d4f1f2d7e55c9454a4a86aa05f49c704fff9642|verified:true|block:23461924|tx:0x2553b6c466f3f89c8a6d8e3df315c048bb694fe330ee41cf9d63fc0a85a2fdac|first_check:1759071365

Submitted on: 2025-09-28 16:56:05

Comments

Log in to comment.

No comments yet.