Skip to Content

STPv1 Source

// SPDX-License-Identifier: MIT pragma solidity 0.8.17; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin-upgradeable/contracts/utils/StringsUpgradeable.sol"; import "@openzeppelin-upgradeable/contracts/access/Ownable2StepUpgradeable.sol"; import "@openzeppelin-upgradeable/contracts/security/PausableUpgradeable.sol"; import "@openzeppelin-upgradeable/contracts/token/ERC721/ERC721Upgradeable.sol"; import "@openzeppelin-upgradeable/contracts/security/ReentrancyGuardUpgradeable.sol"; import "./Shared.sol"; /** * @title Subscription Token Protocol Version 1 * @author Fabric Inc. * @notice An NFT contract which allows users to mint time and access token gated content while time remains. * @dev The balanceOf function returns the number of seconds remaining in the subscription. Token gated systems leverage * the balanceOf function to determine if a user has the token, and if no time remains, the balance is 0. NFT holders * can mint additional time. The creator/owner of the contract can withdraw the funds at any point. There are * additional functionalities for granting time, refunding accounts, fees, rewards, etc. This contract is designed to be used with * Clones, but is not designed to be upgradeable. Added functionality will come with new versions. */ contract SubscriptionTokenV1 is ERC721Upgradeable, Ownable2StepUpgradeable, ReentrancyGuardUpgradeable, PausableUpgradeable { using SafeERC20 for IERC20; using StringsUpgradeable for uint256; /// @dev The maximum number of reward halvings (limiting this prevents overflow) uint256 private constant _MAX_REWARD_HALVINGS = 32; /// @dev Maximum protocol fee basis points (12.5%) uint16 private constant _MAX_FEE_BIPS = 1250; /// @dev Maximum basis points (100%) uint16 private constant _MAX_BIPS = 10000; /// @dev Guard to ensure the purchase amount is valid modifier validAmount(uint256 amount) { require(amount >= _minimumPurchase, "Amount must be >= minimum purchase"); _; } /// @dev Emitted when the owner withdraws available funds event Withdraw(address indexed account, uint256 tokensTransferred); /// @dev Emitted when a subscriber withdraws their rewards event RewardWithdraw(address indexed account, uint256 tokensTransferred); /// @dev Emitted when a subscriber slashed the rewards of another subscriber event RewardPointsSlashed(address indexed account, address indexed slasher, uint256 rewardPointsSlashed); /// @dev Emitted when tokens are allocated to the reward pool event RewardsAllocated(uint256 tokens); /// @dev Emitted when time is purchased (new nft or renewed) event Purchase( address indexed account, uint256 indexed tokenId, uint256 tokensTransferred, uint256 timePurchased, uint256 rewardPoints, uint256 expiresAt ); /// @dev Emitted when a subscriber is granted time by the creator event Grant(address indexed account, uint256 indexed tokenId, uint256 secondsGranted, uint256 expiresAt); /// @dev Emitted when the creator refunds a subscribers remaining time event Refund(address indexed account, uint256 indexed tokenId, uint256 tokensTransferred, uint256 timeReclaimed); /// @dev Emitted when the creator tops up the contract balance on refund event RefundTopUp(uint256 tokensIn); /// @dev Emitted when the fees are transferred to the collector event FeeTransfer(address indexed from, address indexed to, uint256 tokensTransferred); /// @dev Emitted when the fee collector is updated event FeeCollectorChange(address indexed from, address indexed to); /// @dev Emitted when tokens are allocated to the fee pool event FeeAllocated(uint256 tokens); /// @dev Emitted when a referral fee is paid out event ReferralPayout( uint256 indexed tokenId, address indexed referrer, uint256 indexed referralId, uint256 rewardAmount ); /// @dev Emitted when a new referral code is created event ReferralCreated(uint256 id, uint16 rewardBps); /// @dev Emitted when a referral code is deleted event ReferralDestroyed(uint256 id); /// @dev Emitted when the supply cap is updated event SupplyCapChange(uint256 supplyCap); /// @dev Emitted when the transfer recipient is updated event TransferRecipientChange(address indexed recipient); /// @dev The subscription struct which holds the state of a subscription for an account struct Subscription { /// @dev The tokenId for the subscription uint256 tokenId; /// @dev The number of seconds purchased uint256 secondsPurchased; /// @dev The number of seconds granted by the creator uint256 secondsGranted; /// @dev A time offset used to adjust expiration for grants uint256 grantOffset; /// @dev A time offset used to adjust expiration for purchases uint256 purchaseOffset; /// @dev The number of reward points earned uint256 rewardPoints; /// @dev The number of rewards withdrawn uint256 rewardsWithdrawn; } /// @dev The metadata URI for the contract string private _contractURI; /// @dev The metadata URI for the tokens. Note: if it ends with /, then we append the tokenId string private _tokenURI; /// @dev The cost of one second in denominated token (wei or other base unit) uint256 private _tokensPerSecond; /// @dev Minimum number of seconds to purchase. Also, this is the number of seconds until the reward multiplier is halved. uint256 private _minPurchaseSeconds; /// @dev The minimum number of tokens accepted for a time purchase uint256 private _minimumPurchase; /// @dev The token contract address, or 0x0 for native tokens IERC20 private _token; /// @dev The total number of tokens transferred in (accounting) uint256 private _tokensIn; /// @dev The total number of tokens transferred out (accounting) uint256 private _tokensOut; /// @dev The token counter for mint id generation and enforcing supply caps uint256 private _tokenCounter; /// @dev The total number of tokens allocated for the fee collector (accounting) uint256 private _feeBalance; /// @dev The protocol fee basis points (10000 = 100%, max = _MAX_FEE_BIPS) uint16 private _feeBps; /// @dev The protocol fee collector address (for withdraws or sponsored transfers) address private _feeCollector; /// @dev Flag which determines if the contract is erc20 denominated bool private _erc20; /// @dev The block timestamp of the contract deployment (used for reward halvings) uint256 private _deployBlockTime; /// @dev The reward pool size (used to calculate reward withdraws accurately) uint256 private _totalRewardPoints; /// @dev The reward pool balance (accounting) uint256 private _rewardPoolBalance; /// @dev The reward pool total (used to calculate reward withdraws accurately) uint256 private _rewardPoolTotal; /// @dev The reward pool tokens slashed (used to calculate reward withdraws accurately) uint256 private _rewardPoolSlashed; /// @dev The basis points for reward allocations uint16 private _rewardBps; /// @dev The number of reward halvings. This is used to calculate the reward multiplier for early supporters, if the creator chooses to reward them. uint256 private _numRewardHalvings; /// @dev The maximum number of tokens which can be minted (adjustable over time, but will not allow setting below current count) uint256 private _supplyCap; /// @dev The address of the account which can receive transfers via sponsored calls address private _transferRecipient; /// @dev The subscription state for each account mapping(address => Subscription) private _subscriptions; /// @dev The collection of referral codes for referral rewards mapping(uint256 => uint16) private _referralCodes; //////////////////////////////////// /// @dev Disable initializers on the logic contract constructor() { _disableInitializers(); } /// @dev Fallback function to mint time for native token contracts receive() external payable { mintFor(msg.sender, msg.value); } /** * @dev Initialize acts as the constructor, as this contract is intended to work with proxy contracts. * @param params the init params (See Common.InitParams) */ function initialize(Shared.InitParams memory params) public initializer { require(bytes(params.name).length > 0, "Name cannot be empty"); require(bytes(params.symbol).length > 0, "Symbol cannot be empty"); require(bytes(params.contractUri).length > 0, "Contract URI cannot be empty"); require(bytes(params.tokenUri).length > 0, "Token URI cannot be empty"); require(params.owner != address(0), "Owner address cannot be 0x0"); require(params.tokensPerSecond > 0, "Tokens per second must be > 0"); require(params.minimumPurchaseSeconds > 0, "Min purchase seconds must be > 0"); require(params.feeBps <= _MAX_FEE_BIPS, "Fee bps too high"); require(params.rewardBps <= _MAX_BIPS, "Reward bps too high"); require(params.numRewardHalvings <= _MAX_REWARD_HALVINGS, "Reward halvings too high"); if (params.feeRecipient != address(0)) { require(params.feeBps > 0, "Fees required when fee recipient is present"); } if (params.rewardBps > 0) { require(params.numRewardHalvings > 0, "Reward halvings too low"); } __ERC721_init(params.name, params.symbol); _transferOwnership(params.owner); __Pausable_init_unchained(); __ReentrancyGuard_init(); _contractURI = params.contractUri; _tokenURI = params.tokenUri; _tokensPerSecond = params.tokensPerSecond; _minimumPurchase = params.minimumPurchaseSeconds * params.tokensPerSecond; _minPurchaseSeconds = params.minimumPurchaseSeconds; _rewardBps = params.rewardBps; _numRewardHalvings = params.numRewardHalvings; _feeBps = params.feeBps; _feeCollector = params.feeRecipient; _token = IERC20(params.erc20TokenAddr); _erc20 = params.erc20TokenAddr != address(0); _deployBlockTime = block.timestamp; } ///////////////////////// // Subscriber Calls ///////////////////////// /** * @notice Mint or renew a subscription for sender * @param numTokens the amount of ERC20 tokens or native tokens to transfer */ function mint(uint256 numTokens) external payable { mintFor(msg.sender, numTokens); } /** * @notice Mint or renew a subscription for sender, with referral rewards for a referrer * @param numTokens the amount of ERC20 tokens or native tokens to transfer * @param referralCode the referral code to use * @param referrer the referrer address and reward recipient */ function mintWithReferral(uint256 numTokens, uint256 referralCode, address referrer) external payable { mintWithReferralFor(msg.sender, numTokens, referralCode, referrer); } /** * @notice Withdraw available rewards. This is only possible if the subscription is active. */ function withdrawRewards() external { Subscription memory sub = _subscriptions[msg.sender]; require(_isActive(sub), "Subscription not active"); uint256 rewardAmount = _rewardBalance(sub); require(rewardAmount > 0, "No rewards to withdraw"); sub.rewardsWithdrawn += rewardAmount; _subscriptions[msg.sender] = sub; _rewardPoolBalance -= rewardAmount; _transferOut(msg.sender, rewardAmount); emit RewardWithdraw(msg.sender, rewardAmount); } /** * @notice Slash the reward points for an expired subscription after a grace period which is 50% of the purchased time * Any slashable points are burned, increasing the value of remaining points. * @param account the account of the subscription to slash */ function slashRewards(address account) external { require(_rewardBps > 0, "Rewards disabled"); Subscription memory slasher = _subscriptions[msg.sender]; require(_isActive(slasher), "Subscription not active"); Subscription memory sub = _subscriptions[account]; require(sub.rewardPoints > 0, "No reward points to slash"); // Expiration + grace period (50% of purchased time) uint256 slashPoint = _subscriptionExpiresAt(sub) + (sub.secondsPurchased / 2); require(block.timestamp >= slashPoint, "Not slashable"); // Deflate the reward points pool and account for prior reward withdrawals _totalRewardPoints -= sub.rewardPoints; _rewardPoolSlashed += sub.rewardsWithdrawn; // If all points are slashed, move left-over funds to creator if (_totalRewardPoints == 0) { _rewardPoolBalance = 0; } emit RewardPointsSlashed(account, msg.sender, sub.rewardPoints); sub.rewardPoints = 0; sub.rewardsWithdrawn = 0; _subscriptions[account] = sub; } ///////////////////////// // Creator Calls ///////////////////////// /** * @notice Withdraw available funds as the owner */ function withdraw() external { withdrawTo(msg.sender); } /** * @notice Withdraw available funds and transfer fees as the owner */ function withdrawAndTransferFees() external { withdrawTo(msg.sender); _transferFees(); } /** * @notice Withdraw available funds as the owner to a specific account * @param account the account to transfer funds to */ function withdrawTo(address account) public onlyOwner { require(account != address(0), "Account cannot be 0x0"); uint256 balance = creatorBalance(); require(balance > 0, "No Balance"); _transferToCreator(account, balance); } /** * @notice Refund one or more accounts remaining purchased time and revoke any granted time * @dev This refunds accounts using creator balance, and can also transfer in to top up the fund. Any excess value is withdrawable. * @param numTokensIn an optional amount of tokens to transfer in before refunding * @param accounts the list of accounts to refund and revoke grants for */ function refund(uint256 numTokensIn, address[] memory accounts) external payable onlyOwner { require(accounts.length > 0, "No accounts to refund"); if (numTokensIn > 0) { uint256 finalAmount = _transferIn(msg.sender, numTokensIn); emit RefundTopUp(finalAmount); } else if (msg.value > 0) { revert("Unexpected value transfer"); } require(canRefund(accounts), "Insufficient balance for refund"); for (uint256 i = 0; i < accounts.length; i++) { _refund(accounts[i]); } } /** * @notice Update the contract metadata * @param contractUri the collection metadata URI * @param tokenUri the token metadata URI */ function updateMetadata(string memory contractUri, string memory tokenUri) external onlyOwner { require(bytes(contractUri).length > 0, "Contract URI cannot be empty"); require(bytes(tokenUri).length > 0, "Token URI cannot be empty"); _contractURI = contractUri; _tokenURI = tokenUri; } /** * @notice Grant time to a list of accounts, so they can access content without paying * @param accounts the list of accounts to grant time to * @param secondsToAdd the number of seconds to grant for each account */ function grantTime(address[] memory accounts, uint256 secondsToAdd) external onlyOwner { require(secondsToAdd > 0, "Seconds to add must be > 0"); require(accounts.length > 0, "No accounts to grant time to"); for (uint256 i = 0; i < accounts.length; i++) { _grantTime(accounts[i], secondsToAdd); } } /** * @notice Pause minting to allow for migrations or other actions */ function pause() external onlyOwner { _pause(); } /** * @notice Unpause to resume subscription minting */ function unpause() external onlyOwner { _unpause(); } /** * @notice Update the maximum number of tokens (subscriptions) * @param supplyCap the new supply cap (must be greater than token count or 0 for unlimited) */ function setSupplyCap(uint256 supplyCap) external onlyOwner { require(supplyCap == 0 || supplyCap >= _tokenCounter, "Supply cap must be >= current count or 0"); _supplyCap = supplyCap; emit SupplyCapChange(supplyCap); } /** * @notice Set a transfer recipient for automated/sponsored transfers * @param recipient the recipient address */ function setTransferRecipient(address recipient) external onlyOwner { _transferRecipient = recipient; emit TransferRecipientChange(recipient); } ///////////////////////// // Sponsored Calls ///////////////////////// /** * @notice Mint or renew a subscription for a specific account. Intended for automated renewals. * @param account the account to mint or renew time for * @param numTokens the amount of ERC20 tokens or native tokens to transfer */ function mintFor(address account, uint256 numTokens) public payable whenNotPaused validAmount(numTokens) { uint256 finalAmount = _transferIn(msg.sender, numTokens); _purchaseTime(account, finalAmount); } /** * @notice Mint or renew a subscription for a specific account, with referral details * @param account the account to mint or renew time for * @param numTokens the amount of ERC20 tokens or native tokens to transfer * @param referralCode the referral code to use for rewards * @param referrer the referrer address and reward recipient */ function mintWithReferralFor(address account, uint256 numTokens, uint256 referralCode, address referrer) public payable whenNotPaused validAmount(numTokens) { require(referrer != address(0), "Referrer cannot be 0x0"); uint256 finalAmount = _transferIn(msg.sender, numTokens); uint256 tokenId = _purchaseTime(account, finalAmount); // Calculate rewards and transfer rewards out uint256 payout = _referralAmount(finalAmount, referralCode); if (payout > 0) { _transferOut(referrer, payout); emit ReferralPayout(tokenId, referrer, referralCode, payout); } } /** * @notice Transfer any available fees to the fee collector */ function transferFees() external { require(_feeBalance > 0, "No fees to collect"); _transferFees(); } /** * @notice Transfer all balances to the transfer recipient and fee collector (if applicable) * @dev This is a way for EOAs to pay gas fees on behalf of the creator (automation, etc) */ function transferAllBalances() external { require(_transferRecipient != address(0), "Transfer recipient not set"); _transferAllBalances(_transferRecipient); } ///////////////////////// // Fee Management ///////////////////////// /** * @notice Fetch the current fee schedule * @return feeCollector the feeCollector address * @return feeBps the fee basis points */ function feeSchedule() external view returns (address feeCollector, uint16 feeBps) { return (_feeCollector, _feeBps); } /** * @notice Fetch the accumulated fee balance * @return balance the accumulated fees which have not yet been transferred */ function feeBalance() external view returns (uint256 balance) { return _feeBalance; } /** * @notice Update the fee collector address. Can be set to 0x0 to disable fees permanently. * @param newCollector the new fee collector address */ function updateFeeRecipient(address newCollector) external { require(msg.sender == _feeCollector, "Unauthorized"); // Give tokens back to creator and set fee rate to 0 if (newCollector == address(0)) { _feeBalance = 0; _feeBps = 0; } _feeCollector = newCollector; emit FeeCollectorChange(msg.sender, newCollector); } ///////////////////////// // Referral Rewards ///////////////////////// /** * @notice Create a referral code for giving rewards to referrers on mint * @param code the unique integer code for the referral * @param bps the reward basis points */ function createReferralCode(uint256 code, uint16 bps) external onlyOwner { require(bps <= _MAX_BIPS, "bps too high"); require(bps > 0, "bps must be > 0"); uint16 existing = _referralCodes[code]; require(existing == 0, "Referral code exists"); _referralCodes[code] = bps; emit ReferralCreated(code, bps); } /** * @notice Delete a referral code * @param code the unique integer code for the referral */ function deleteReferralCode(uint256 code) external onlyOwner { delete _referralCodes[code]; emit ReferralDestroyed(code); } /** * @notice Fetch the reward basis points for a given referral code * @param code the unique integer code for the referral * @return bps the reward basis points */ function referralCodeBps(uint256 code) external view returns (uint16 bps) { return _referralCodes[code]; } //////////////////////// // Core Internal Logic //////////////////////// /// @dev Add time to a given account (transfer happens before this is called) function _purchaseTime(address account, uint256 amount) internal returns (uint256) { require(account != address(0), "Account cannot be 0x0"); Subscription memory sub = _fetchSubscription(account); // Adjust offset to account for existing time if (block.timestamp > sub.purchaseOffset + sub.secondsPurchased) { sub.purchaseOffset = block.timestamp - sub.secondsPurchased; } uint256 rp = amount * rewardMultiplier(); uint256 tv = timeValue(amount); sub.secondsPurchased += tv; sub.rewardPoints += rp; _subscriptions[account] = sub; _totalRewardPoints += rp; // If fees or rewards are enabled, allocate a portion of the purchase to those pools _allocateFeesAndRewards(amount); // Mint the NFT if it does not exist before purchase event for indexers _maybeMint(account, sub.tokenId); emit Purchase(account, sub.tokenId, amount, tv, rp, _subscriptionExpiresAt(sub)); return sub.tokenId; } /// @dev Get or build a new subscription function _fetchSubscription(address account) internal returns (Subscription memory) { Subscription memory sub = _subscriptions[account]; if (sub.tokenId == 0) { require(_supplyCap == 0 || _tokenCounter < _supplyCap, "Supply cap reached"); _tokenCounter += 1; sub = Subscription(_tokenCounter, 0, 0, block.timestamp, block.timestamp, 0, 0); } return sub; } /// @dev Mint the NFT if it does not exist. Used after grant/purchase state changes (check effects) function _maybeMint(address account, uint256 tokenId) private { if (_ownerOf(tokenId) == address(0)) { _safeMint(account, tokenId); } } /// @dev If fees or rewards are present, allocate a portion of the amount to the relevant pools function _allocateFeesAndRewards(uint256 amount) private { _allocateRewards(_allocateFees(amount)); } /// @dev Allocate tokens to the fee collector function _allocateFees(uint256 amount) internal returns (uint256) { if (_feeBps == 0) { return amount; } uint256 fee = (amount * _feeBps) / _MAX_BIPS; _feeBalance += fee; emit FeeAllocated(fee); return amount - fee; } /// @dev Allocate tokens to the reward pool function _allocateRewards(uint256 amount) internal returns (uint256) { if (_rewardBps == 0 || _totalRewardPoints == 0) { return amount; } uint256 rewards = (amount * _rewardBps) / _MAX_BIPS; _rewardPoolBalance += rewards; _rewardPoolTotal += rewards; emit RewardsAllocated(rewards); return amount - rewards; } /// @dev Transfer tokens into the contract, either native or ERC20 function _transferIn(address from, uint256 amount) internal nonReentrant returns (uint256) { if (!_erc20) { require(msg.value == amount, "Purchase amount must match value sent"); _tokensIn += amount; return amount; } // Note: We support tokens which take fees, but do not support rebasing tokens require(msg.value == 0, "Native tokens not accepted for ERC20 subscriptions"); uint256 preBalance = _token.balanceOf(from); uint256 allowance = _token.allowance(from, address(this)); require(preBalance >= amount && allowance >= amount, "Insufficient Balance or Allowance"); _token.safeTransferFrom(from, address(this), amount); uint256 postBalance = _token.balanceOf(from); uint256 finalAmount = preBalance - postBalance; _tokensIn += finalAmount; return finalAmount; } /// @dev Transfer tokens to the creator, after allocating protocol fees and rewards function _transferToCreator(address to, uint256 amount) internal { emit Withdraw(to, amount); _transferOut(to, amount); } /// @dev Transfer tokens out of the contract, either native or ERC20 function _transferOut(address to, uint256 amount) internal nonReentrant { _tokensOut += amount; if (_erc20) { _token.safeTransfer(to, amount); } else { (bool sent,) = payable(to).call{value: amount}(""); require(sent, "Failed to transfer Ether"); } } /// @dev Transfer fees to the fee collector function _transferFees() internal { if (_feeBalance == 0) { return; } uint256 balance = _feeBalance; _feeBalance = 0; _transferOut(_feeCollector, balance); emit FeeTransfer(msg.sender, _feeCollector, balance); } /// @dev Transfer all remaining balances to the creator and fee collector (if applicable) function _transferAllBalances(address balanceRecipient) internal { uint256 balance = creatorBalance(); if (balance > 0) { _transferToCreator(balanceRecipient, balance); } // Transfer protocol fees _transferFees(); } /// @dev Grant time to a given account function _grantTime(address account, uint256 numSeconds) internal { Subscription memory sub = _fetchSubscription(account); // Adjust offset to account for existing time if (block.timestamp > sub.grantOffset + sub.secondsGranted) { sub.grantOffset = block.timestamp - sub.secondsGranted; } sub.secondsGranted += numSeconds; _subscriptions[account] = sub; // Mint the NFT if it does not exist before grant event for indexers _maybeMint(account, sub.tokenId); emit Grant(account, sub.tokenId, numSeconds, _subscriptionExpiresAt(sub)); } /// @dev The amount of granted time remaining for a given subscription function _grantTimeRemaining(Subscription memory sub) internal view returns (uint256) { uint256 expiresAt = sub.grantOffset + sub.secondsGranted; if (expiresAt <= block.timestamp) { return 0; } return expiresAt - block.timestamp; } /// @dev The amount of purchased time remaining for a given subscription function _purchaseTimeRemaining(Subscription memory sub) internal view returns (uint256) { uint256 expiresAt = sub.purchaseOffset + sub.secondsPurchased; if (expiresAt <= block.timestamp) { return 0; } return expiresAt - block.timestamp; } /// @dev Refund the remaining time for the given accounts subscription, and clear grants function _refund(address account) internal { Subscription memory sub = _subscriptions[account]; if (sub.secondsPurchased == 0 && sub.secondsGranted == 0) { return; } sub.secondsGranted = 0; uint256 balance = refundableBalanceOf(account); uint256 tokens = balance * _tokensPerSecond; if (balance > 0) { sub.secondsPurchased -= balance; _subscriptions[account] = sub; _transferOut(account, tokens); } else { _subscriptions[account] = sub; } emit Refund(account, sub.tokenId, tokens, balance); } /// @dev Compute the reward amount for a given token amount and referral code function _referralAmount(uint256 tokenAmount, uint256 referralCode) internal view returns (uint256) { uint16 referralBps = _referralCodes[referralCode]; if (referralBps == 0) { return 0; } return (tokenAmount * referralBps) / _MAX_BIPS; } /// @dev The timestamp when the subscription expires function _subscriptionExpiresAt(Subscription memory sub) internal pure returns (uint256) { uint256 purchase = sub.purchaseOffset + sub.secondsPurchased; uint256 grant = sub.grantOffset + sub.secondsGranted; return purchase > grant ? purchase : grant; } /// @dev The reward balance for a given subscription function _rewardBalance(Subscription memory sub) internal view returns (uint256) { uint256 userShare = (_rewardPoolTotal - _rewardPoolSlashed) * sub.rewardPoints / _totalRewardPoints; if (userShare <= sub.rewardsWithdrawn) { return 0; } return userShare - sub.rewardsWithdrawn; } /// @dev Determine if a subscription is active function _isActive(Subscription memory sub) internal view returns (bool) { return _subscriptionExpiresAt(sub) > block.timestamp; } //////////////////////// // Informational //////////////////////// /** * @notice Determine the total cost for refunding the given accounts * @dev The value will change from block to block, so this is only an estimate * @param accounts the list of accounts to refund * @return numTokens total number of tokens for refund */ function refundableTokenBalanceOfAll(address[] memory accounts) public view returns (uint256 numTokens) { uint256 amount; for (uint256 i = 0; i < accounts.length; i++) { amount += refundableBalanceOf(accounts[i]); } return amount * _tokensPerSecond; } /** * @notice Determines if a refund can be processed for the given accounts with the current balance * @param accounts the list of accounts to refund * @return refundable true if the refund can be processed from the current balance */ function canRefund(address[] memory accounts) public view returns (bool refundable) { return creatorBalance() >= refundableTokenBalanceOfAll(accounts); } /** * @notice The current reward multiplier used to calculate reward points on mint. This is halved every _minPurchaseSeconds and goes to 0 after N halvings. * @return multiplier the current value */ function rewardMultiplier() public view returns (uint256 multiplier) { if (_numRewardHalvings == 0) { return 0; } uint256 halvings = (block.timestamp - _deployBlockTime) / _minPurchaseSeconds; if (halvings > _numRewardHalvings) { return 0; } return (2 ** _numRewardHalvings) / (2 ** halvings); } /** * @notice The amount of time exchanged for the given number of tokens * @param numTokens the number of tokens to exchange for time * @return numSeconds the number of seconds purchased */ function timeValue(uint256 numTokens) public view returns (uint256 numSeconds) { return numTokens / _tokensPerSecond; } /** * @notice The creators withdrawable balance * @return balance the number of tokens available for withdraw */ function creatorBalance() public view returns (uint256 balance) { return _tokensIn - _tokensOut - _feeBalance - _rewardPoolBalance; } /** * @notice The sum of all deposited tokens over time. Fees and refunds are not accounted for. * @return total the total number of tokens deposited */ function totalCreatorEarnings() public view returns (uint256 total) { return _tokensIn; } /** * @notice Relevant subscription information for a given account * @return tokenId the tokenId for the account * @return refundableAmount the number of seconds which can be refunded * @return rewardPoints the number of reward points earned * @return expiresAt the timestamp when the subscription expires */ function subscriptionOf(address account) external view returns (uint256 tokenId, uint256 refundableAmount, uint256 rewardPoints, uint256 expiresAt) { Subscription memory sub = _subscriptions[account]; return (sub.tokenId, sub.secondsPurchased, sub.rewardPoints, _subscriptionExpiresAt(sub)); } /** * @notice The percentage (as basis points) of creator earnings which are rewarded to subscribers * @return bps reward basis points */ function rewardBps() external view returns (uint16 bps) { return _rewardBps; } /** * @notice The number of reward points allocated to all subscribers (used to calculate rewards) * @return numPoints total number of reward points */ function totalRewardPoints() external view returns (uint256 numPoints) { return _totalRewardPoints; } /** * @notice The balance of the reward pool (for reward withdraws) * @return numTokens number of tokens in the reward pool */ function rewardPoolBalance() external view returns (uint256 numTokens) { return _rewardPoolBalance; } /** * @notice The number of tokens available to withdraw from the reward pool, for a given account * @param account the account to check * @return numTokens number of tokens available to withdraw */ function rewardBalanceOf(address account) external view returns (uint256 numTokens) { Subscription memory sub = _subscriptions[account]; return _rewardBalance(sub); } /** * @notice The ERC-20 address used for purchases, or 0x0 for native * @return erc20 address or 0x0 for native */ function erc20Address() public view returns (address erc20) { return address(_token); } /** * @notice The refundable time balance for a given account * @param account the account to check * @return numSeconds the number of seconds which can be refunded */ function refundableBalanceOf(address account) public view returns (uint256 numSeconds) { Subscription memory sub = _subscriptions[account]; return _purchaseTimeRemaining(sub); } /** * @notice The contract metadata URI for accessing collection metadata * @return uri the collection URI */ function contractURI() public view returns (string memory uri) { return _contractURI; } /** * @notice The base token URI for accessing token metadata * @return uri the base token URI */ function baseTokenURI() public view returns (string memory uri) { return _tokenURI; } /** * @notice The number of tokens required for a single second of time * @return numTokens per second */ function tps() external view returns (uint256 numTokens) { return _tokensPerSecond; } /** * @notice The minimum number of seconds required for a purchase * @return numSeconds minimum */ function minPurchaseSeconds() external view returns (uint256 numSeconds) { return _minPurchaseSeconds; } /** * @notice Fetch the current supply cap (0 for unlimited) * @return count the current number * @return cap the max number of subscriptions */ function supplyDetail() external view returns (uint256 count, uint256 cap) { return (_tokenCounter, _supplyCap); } /** * @notice Fetch the current transfer recipient address * @return recipient the address or 0x0 address for none */ function transferRecipient() external view returns (address recipient) { return _transferRecipient; } /** * @notice Fetch the metadata URI for a given token * @dev If _tokenURI ends with a / then the tokenId is appended * @param tokenId the tokenId to fetch the metadata URI for * @return uri the URI for the token */ function tokenURI(uint256 tokenId) public view override returns (string memory uri) { _requireMinted(tokenId); bytes memory str = bytes(_tokenURI); uint256 len = str.length; if (str[len - 1] == "/") { return string(abi.encodePacked(_tokenURI, tokenId.toString())); } return _tokenURI; } ////////////////////// // Overrides ////////////////////// /** * @notice Override the default balanceOf behavior to account for time remaining * @param account the account to fetch the balance of * @return numSeconds the number of seconds remaining in the subscription */ function balanceOf(address account) public view override returns (uint256 numSeconds) { Subscription memory sub = _subscriptions[account]; return _purchaseTimeRemaining(sub) + _grantTimeRemaining(sub); } /** * @notice Renounce ownership of the contract, transferring all remaining funds to the creator and fee collector * and pausing the contract to prevent further inflows. */ function renounceOwnership() public override onlyOwner { _transferAllBalances(msg.sender); _pause(); _transferOwnership(address(0)); } /// @dev Transfers may occur if the destination does not have a subscription function _beforeTokenTransfer(address from, address to, uint256, /* tokenId */ uint256 /* batchSize */ ) internal override { if (from == address(0)) { return; } require(_subscriptions[to].tokenId == 0, "Cannot transfer to existing subscribers"); if (to != address(0)) { _subscriptions[to] = _subscriptions[from]; } delete _subscriptions[from]; } ////////////////////// // Recovery Functions ////////////////////// /** * @notice Reconcile the ERC20 balance of the contract with the internal state * @dev The prevents lost funds if ERC20 tokens are transferred to the contract directly */ function reconcileERC20Balance() external onlyOwner { require(_erc20, "Only for ERC20 tokens"); uint256 balance = _token.balanceOf(address(this)); uint256 expectedBalance = _tokensIn - _tokensOut; require(balance > expectedBalance, "Tokens already reconciled"); _tokensIn += balance - expectedBalance; } /** * @notice Recover ERC20 tokens which were accidentally sent to the contract * @param tokenAddress the address of the token to recover * @param recipientAddress the address to send the tokens to * @param tokenAmount the amount of tokens to send */ function recoverERC20(address tokenAddress, address recipientAddress, uint256 tokenAmount) external onlyOwner { require(tokenAddress != erc20Address(), "Cannot recover subscription token"); IERC20(tokenAddress).safeTransfer(recipientAddress, tokenAmount); } /** * @notice Recover native tokens which bypassed receive. Only callable for erc20 denominated contracts. * @param recipient the address to send the tokens to */ function recoverNativeTokens(address recipient) external onlyOwner { require(_erc20, "Not supported, use reconcileNativeBalance"); uint256 balance = address(this).balance; require(balance > 0, "No balance to recover"); (bool sent,) = payable(recipient).call{value: balance}(""); require(sent, "Failed to transfer Ether"); } /** * @notice Reconcile native tokens which bypassed receive/mint. Only callable for native denominated contracts. */ function reconcileNativeBalance() external onlyOwner { require(!_erc20, "Not supported, use recoverNativeTokens"); uint256 balance = address(this).balance; require(balance > 0, "No balance to recover"); uint256 expectedBalance = _tokensIn - _tokensOut; require(balance > expectedBalance, "Balance reconciled"); _tokensIn += balance - expectedBalance; } }
Last updated on