// SPDX-License-Identifier: MPL-2.0 pragma solidity ^0.8.17; import "../lib/EIP712.sol"; import {AbstractConditionOracle} from "./AbstractConditionOracle.sol"; /** * @dev Use binary claims/tickets signed off-chain by validator wallet, to distribute one-time rewards. */ contract OneTimeOffchainTickets is AbstractConditionOracle { using EIP712 for address; struct Rewarded { uint256 claimedAmount; uint32 currentNonce; } struct DecodedTicket { address user; uint256 amount; uint256 claimedAmount; uint32 nonce; bytes callData; bytes rawTicket; } /// @dev Value returned by a call to `isValidSignature` if the check /// was successful. The value is defined as: /// bytes4(keccak256("isAllowed(address,uint256,uint256,uint32,bytes)")) bytes4 private constant MAGICVALUE = 0x834943e8; /// @dev The EIP-712 domain type hash used for computing the domain /// separator. bytes32 internal constant DOMAIN_TYPE_HASH = keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" ); /// @dev The EIP-712 domain name used for computing the domain separator. bytes32 internal constant DOMAIN_NAME = keccak256("ValidTicket"); /// @dev The EIP-712 domain version used for computing the domain separator. bytes32 internal constant DOMAIN_VERSION = keccak256("v1"); /// @dev The domain separator used for signing orders that gets mixed in /// making signatures for different domains incompatible. This domain /// separator is computed following the EIP-712 standard and has replay /// protection mixed in so that signed orders are only valid for specific /// contracts. bytes32 public immutable DOMAIN_SEPARATOR; mapping(address => Rewarded) internal claimedRewards; address public validator; event NewValidator(address owner, address indexed newValidator); event UsedTicket( address indexed account, bytes32 indexed ticket, uint32 indexed nonce, uint256 totalClaimedAmount, uint256 amount ); /** * @dev Constructor allows setting an initial configuration and consumer contract. * @param _claimInterface interface used to distribute reward tokens, usually mint or transfer. * @param _rewardToken ERC20 or ERC1155 reward token contract address. * @param _rewardTokenId ERC1155 token id used for reward, pass 0 for ERC20 reward token. * @param _defaultConsumer address of contract which is authorized to call `consumeClaim`, pass address(0) to configure this later. */ constructor(bytes4 _claimInterface, address _rewardToken, uint256 _rewardTokenId, address _defaultConsumer) { _changeSettings(_claimInterface, _rewardToken, _rewardTokenId); if (_defaultConsumer != address(0)) { canConsumeClaims[_defaultConsumer] = true; emit ConsumerAdded(_defaultConsumer); } // NOTE: Currently, the only way to get the chain ID in solidity is // using assembly. uint256 chainId; // solhint-disable-next-line no-inline-assembly assembly { chainId := chainid() } DOMAIN_SEPARATOR = keccak256( abi.encode( DOMAIN_TYPE_HASH, DOMAIN_NAME, DOMAIN_VERSION, chainId, address(this) ) ); validator = owner(); emit NewValidator(msg.sender, msg.sender); } /** * @dev Expose configuration API for reward engine for ticket rewards to owner address. * @param _rewardToken ERC20 or ERC1155 reward token contract address. * @param _rewardTokenId ERC1155 token id used for reward, pass 0 for ERC20 reward token. * @param _claimInterface interface used to distribute reward tokens, usually mint or transfer. */ function adminChangeSettings( bytes4 _claimInterface, address _rewardToken, uint256 _rewardTokenId ) external onlyOwner { _changeSettings(_claimInterface, _rewardToken, _rewardTokenId); } /** * @dev Owner can change validator wallet which signs tickets. * @param _newValidator Address of a new validator wallet. */ function adminChangeValidator(address _newValidator) external onlyOwner { validator = _newValidator; emit NewValidator(msg.sender, _newValidator); } /** * @dev Check if specific account has a valid claim. * @param _account Address which owns the ticket. * @param _claim Encoded claim with integrity hash and ticket data. * @return true if claim is valid. */ function hasClaim(address _account, bytes calldata _claim) public view returns (bool) { DecodedTicket memory _ticketData = _decodeClaim(_claim); if (_account != _ticketData.user) return false; if (_ticketData.nonce <= claimedRewards[_account].currentNonce) return false; if (!_isTicketValid(_ticketData)) return false; if (_ticketData.amount > 0 && _ticketData.claimedAmount == claimedRewards[_account].claimedAmount) return true; return false; } /** * @dev Consume claim by authorized consumer to get reward amount for the claim. * @param _account Address which owns the ticket. * @param _claim Encoded claim with integrity hash and ticket data. * @return reward amount. */ function consumeClaim(address _account, bytes calldata _claim) external returns (uint256) { require(canConsumeClaims[msg.sender], "not a consumer"); DecodedTicket memory _ticketData = _decodeClaim(_claim); require(_account == _ticketData.user, "invalid account"); require(_ticketData.nonce > claimedRewards[_account].currentNonce, "expired ticket"); uint256 _currentClaimedAmount = claimedRewards[_account].claimedAmount; require(_ticketData.amount > 0 && _ticketData.claimedAmount == _currentClaimedAmount, "invalid amount"); require(_isTicketValid(_ticketData), "invalid ticket"); bytes32 _ticket = keccak256(_ticketData.rawTicket); claimedRewards[_account].currentNonce = _ticketData.nonce; uint256 _newClaimedAmount = _currentClaimedAmount + _ticketData.amount; claimedRewards[_account].claimedAmount = _newClaimedAmount; emit ConsumedClaim(_account, _claim); emit UsedTicket(_account, _ticket, _ticketData.nonce, _newClaimedAmount, _ticketData.amount); return _ticketData.amount; } /** * @dev Get domain separator in scope of EIP-712. * @return EIP-712 domain. */ function getDomainSeparator() public virtual view returns(bytes32) { return DOMAIN_SEPARATOR; } /** * @dev Get next valid nonce for the user. * @param _account address which will get the next reward. * @return next valid nonce. */ function nextNonce(address _account) public view returns(uint32) { return claimedRewards[_account].currentNonce + 1; } /** * @dev Get all currently claimed rewards for the address. * @param _account rewarded address. * @return all claimed rewards by the address. */ function claimedAmount(address _account) public view returns(uint256) { return claimedRewards[_account].claimedAmount; } /** * @dev Checks if ticket has a valid issuer. * @param _user Address to check for verification. * @param _amount Reward for ticket. * @param _claimedAmount Total amount already claimed by user. * @param _nonce Ticket index. * @param _callData Verification signature. * @return MAGICVALUE for success 0x00000000 for failure. */ function isAllowed( address _user, uint256 _amount, uint256 _claimedAmount, uint32 _nonce, bytes memory _callData ) public view returns (bytes4) { return EIP712._isValidEIP712Signature( validator, MAGICVALUE, abi.encode(DOMAIN_SEPARATOR, _user, _amount, _claimedAmount, _nonce), _callData ); } /** * @dev Wrapper for ticket validation with DecodedTicket structure. * @param _ticketData DecodedTicket structure with ticket data to validate. * @return true if ticket is valid. */ function _isTicketValid(DecodedTicket memory _ticketData) internal view returns (bool) { return isAllowed( _ticketData.user, _ticketData.amount, _ticketData.claimedAmount, _ticketData.nonce, _ticketData.callData ) == MAGICVALUE; } /** * @dev Helper to easily decode claim with integrity hash and ticket data into DecodedTicket structure. * @param _claim DecodedTicket structure with ticket data to validate. * @return DecodedTicket with decoded ticket data. */ function _decodeClaim(bytes calldata _claim) internal view returns (DecodedTicket memory) { (bytes32 _integrity, bytes memory _encodedTicket) = abi.decode(_claim, (bytes32, bytes)); require(_integrity == integrityHash, "invalid claim"); ( address _user, uint256 _amount, uint256 _claimedAmount, uint32 _nonce, bytes memory _callData ) = abi.decode(_encodedTicket, (address, uint256, uint256, uint32, bytes)); return DecodedTicket(_user, _amount, _claimedAmount, _nonce, _callData, _encodedTicket); } }