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 {}
}
Submitted on: 2025-09-28 16:56:05
Comments
Log in to comment.
No comments yet.