diff --git a/contracts/GuildCredential.sol b/contracts/GuildCredential.sol index 66018f0..790e752 100644 --- a/contracts/GuildCredential.sol +++ b/contracts/GuildCredential.sol @@ -1,13 +1,15 @@ //SPDX-License-Identifier: MIT pragma solidity 0.8.19; -import { GuildOracle } from "./GuildOracle.sol"; import { IGuildCredential } from "./interfaces/IGuildCredential.sol"; import { SoulboundERC721 } from "./token/SoulboundERC721.sol"; +import { GuildOracle } from "./utils/GuildOracle.sol"; +import { TreasuryManager } from "./utils/TreasuryManager.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import { StringsUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol"; +import { StringsUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; /// @title An NFT representing actions taken by Guild.xyz users. contract GuildCredential is @@ -16,7 +18,8 @@ contract GuildCredential is OwnableUpgradeable, UUPSUpgradeable, GuildOracle, - SoulboundERC721 + SoulboundERC721, + TreasuryManager { using StringsUpgradeable for uint256; @@ -25,7 +28,7 @@ contract GuildCredential is /// @notice The ipfs hash, under which the off-chain metadata is uploaded. string internal cid; - mapping(address => mapping(GuildAction => mapping(uint256 => bool))) public hasClaimed; + mapping(address => mapping(GuildAction => mapping(uint256 => Claim))) internal claims; /// @notice Empty space reserved for future updates. uint256[47] private __gap; @@ -42,25 +45,29 @@ contract GuildCredential is /// @param cid_ The ipfs hash, under which the off-chain metadata is uploaded. /// @param linkToken The address of the Chainlink token. /// @param oracleAddress The address of the oracle processing the requests. + /// @param treasury The address where the collected fees will be sent. function initialize( string memory name, string memory symbol, string memory cid_, address linkToken, - address oracleAddress + address oracleAddress, + address payable treasury ) public initializer { __Ownable_init(); __UUPSUpgradeable_init(); __GuildOracle_init(linkToken, oracleAddress); __SoulboundERC721_init(name, symbol); + __TreasuryManager_init(treasury); cid = cid_; } // solhint-disable-next-line no-empty-blocks function _authorizeUpgrade(address) internal override onlyOwner {} - function claim(GuildAction guildAction, uint256 guildId) external { - if (hasClaimed[msg.sender][guildAction][guildId]) revert AlreadyClaimed(); + function claim(address payToken, GuildAction guildAction, uint256 guildId) external payable { + Claim storage currentClaim = claims[msg.sender][guildAction][guildId]; + if (currentClaim.claimed) revert AlreadyClaimed(); uint256 tokenId = totalSupply; @@ -86,17 +93,34 @@ contract GuildCredential is abi.encode(tokenId, msg.sender, GuildAction.IS_ADMIN, guildId) ); + // Fee collection + // When there is no msg.value, try transfering ERC20 + if (msg.value == 0 && !IERC20Upgradeable(payToken).transferFrom(msg.sender, address(this), fee[payToken])) + revert TransferFailed(msg.sender, address(this)); + // When there is msg.value, ensure it's the correct amount + else if (msg.value != fee[address(0)]) revert IncorrectFee(msg.value, fee[address(0)]); + + currentClaim.payToken = payToken; + currentClaim.claimed = true; + emit ClaimRequested(msg.sender, guildAction, guildId); } /// @dev The actual claim function called by the oracle if the requirements are fulfilled. - function fulfillClaim(bytes32 requestId, uint256 access) public checkResponse(requestId, access) { + function fulfillClaim(bytes32 requestId, uint256 access) public recordChainlinkFulfillment(requestId) { (uint256 tokenId, address receiver, GuildAction guildAction, uint256 id) = abi.decode( requests[requestId].args, (uint256, address, GuildAction, uint256) ); - hasClaimed[receiver][guildAction][id] = true; + if (access != uint256(Access.ACCESS)) { + claims[msg.sender][guildAction][id].claimed = false; + + // TODO: refund fees, minus oracle fee + + if (access == uint256(Access.NO_ACCESS)) revert NoAccess(receiver); + if (access >= uint256(Access.CHECK_FAILED)) revert AccessCheckFailed(receiver); + } _safeMint(receiver, tokenId); emit Claimed(receiver, guildAction, id); @@ -107,6 +131,10 @@ contract GuildCredential is return string.concat("ipfs://", cid, "/", tokenId.toString(), ".json"); } + function hasClaimed(address account, GuildAction guildAction, uint256 id) external view returns (bool claimed) { + claimed = claims[account][guildAction][id].claimed; + } + /// A version of {_safeMint} aware of total supply. function _safeMint(address to, uint256 tokenId) internal override { unchecked { diff --git a/contracts/interfaces/IGuildCredential.sol b/contracts/interfaces/IGuildCredential.sol index 30c9215..47734f1 100644 --- a/contracts/interfaces/IGuildCredential.sol +++ b/contracts/interfaces/IGuildCredential.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; -/// @title todo +/// @title An NFT representing actions taken by Guild.xyz users. interface IGuildCredential { /// @notice Actions that can be checked via the oracle. enum GuildAction { @@ -10,6 +10,12 @@ interface IGuildCredential { IS_ADMIN } + /// @notice The token used for paying for a credential and it's claim status. + struct Claim { + address payToken; + bool claimed; + } + /// @notice Returns true if the address has already claimed their token. /// @param account The user's address. /// @param guildAction The action which has been checked via the oracle. @@ -22,9 +28,10 @@ interface IGuildCredential { function totalSupply() external view returns (uint256 count); /// @notice Claims tokens to the given address. + /// @param payToken The address of the token that's used for paying the minting fees. 0 for ether. /// @param guildAction The action to check via the oracle. /// @param guildId The id to claim the token for. - function claim(GuildAction guildAction, uint256 guildId) external; + function claim(address payToken, GuildAction guildAction, uint256 guildId) external payable; /// @notice Event emitted whenever a claim succeeds (is fulfilled). /// @param receiver The address that received the tokens. @@ -41,7 +48,17 @@ interface IGuildCredential { /// @notice Error thrown when the token is already claimed. error AlreadyClaimed(); + /// @notice Error thrown when an incorrect amount of fee is attempted to be paid. + /// @param paid The amount of funds received. + /// @param requiredAmount The amount of fees required for minting. + error IncorrectFee(uint256 paid, uint256 requiredAmount); + /// @notice Error thrown when trying to query info about a token that's not (yet) minted. /// @param tokenId The queried id. error NonExistentToken(uint256 tokenId); + + /// @notice Error thrown when an ERC20 transfer failed. + /// @param from The sender of the token. + /// @param to The recipient of the token. + error TransferFailed(address from, address to); } diff --git a/contracts/interfaces/ITreasuryManager.sol b/contracts/interfaces/ITreasuryManager.sol new file mode 100644 index 0000000..fc7bac2 --- /dev/null +++ b/contracts/interfaces/ITreasuryManager.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title A contract that manages fee-related functionality. +interface ITreasuryManager { + /// @notice Sets the minting fee for a given token used for paying. + /// @dev Callable only by the owner. + /// @param token The token whose fee is set. + /// @param newFee The new fee in base units. + function setFee(address token, uint256 newFee) external; + + /// @notice Sets the address that receives the fees. + /// @dev Callable only by the owner. + /// @param newTreasury The new address of the treasury. + function setTreasury(address payable newTreasury) external; + + /// @notice The minting fee of a token. + /// @param token The token used for paying. + /// @return fee The amount of the fee in base units. + function fee(address token) external view returns (uint256 fee); + + /// @notice Returns the address that receives the fees. + function treasury() external view returns (address payable); + + /// @notice Event emitted when a token's fee is changed. + /// @param token The address of the token whose fee was changed. 0 for ether. + /// @param newFee The new amount of fee in base units. + event FeeChanged(address token, uint256 newFee); + + /// @notice Event emitted when the treasury address is changed. + /// @param newTreasury The new address of the treasury. + event TreasuryChanged(address newTreasury); +} diff --git a/contracts/lib/LibAddress.sol b/contracts/lib/LibAddress.sol new file mode 100644 index 0000000..14a6c38 --- /dev/null +++ b/contracts/lib/LibAddress.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title Library for functions related to addresses. +library LibAddress { + /// @notice Error thrown when sending ether fails. + /// @param recipient The address that could not receive the ether. + error FailedToSendEther(address recipient); + + /// @notice Send ether to an address, forwarding all available gas and reverting on errors. + /// @param recipient The recipient of the ether. + /// @param amount The amount of ether to send in base units. + function sendEther(address payable recipient, uint256 amount) internal { + // solhint-disable-next-line avoid-low-level-calls + (bool success, ) = recipient.call{ value: amount }(""); + if (!success) revert FailedToSendEther(recipient); + } +} diff --git a/contracts/GuildOracle.sol b/contracts/utils/GuildOracle.sol similarity index 99% rename from contracts/GuildOracle.sol rename to contracts/utils/GuildOracle.sol index 492e86e..3c68f59 100644 --- a/contracts/GuildOracle.sol +++ b/contracts/utils/GuildOracle.sol @@ -1,7 +1,7 @@ //SPDX-License-Identifier: MIT pragma solidity 0.8.19; -import { ChainlinkClient } from "./external/chainlink/ChainlinkClient.sol"; +import { ChainlinkClient } from "../external/chainlink/ChainlinkClient.sol"; import { Chainlink } from "@chainlink/contracts/src/v0.8/Chainlink.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { StringsUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; diff --git a/contracts/utils/TreasuryManager.sol b/contracts/utils/TreasuryManager.sol new file mode 100644 index 0000000..294dc9a --- /dev/null +++ b/contracts/utils/TreasuryManager.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.19; + +import { ITreasuryManager } from "../interfaces/ITreasuryManager.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +/// @title A contract that manages fee-related functionality. +contract TreasuryManager is ITreasuryManager, Initializable, OwnableUpgradeable { + address payable public treasury; + + mapping(address => uint256) public fee; + + /// @param treasury_ The address that will receive the fees. + // solhint-disable-next-line func-name-mixedcase + function __TreasuryManager_init(address payable treasury_) internal onlyInitializing { + treasury = treasury_; + } + + function setFee(address token, uint256 newFee) external onlyOwner { + fee[token] = newFee; + emit FeeChanged(token, newFee); + } + + function setTreasury(address payable newTreasury) external onlyOwner { + treasury = newTreasury; + emit TreasuryChanged(newTreasury); + } +}