diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 91bcba0..0bbac1d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: - name: Run Forge build run: | forge --version - forge build --sizes + forge build id: build - name: Run Forge tests diff --git a/remappings.txt b/remappings.txt deleted file mode 100644 index c36eea0..0000000 --- a/remappings.txt +++ /dev/null @@ -1,4 +0,0 @@ -ds-test/=lib/forge-std/lib/ds-test/src/ -erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/ -forge-std/=lib/forge-std/src/ -@openzeppelin/=lib/openzeppelin-contracts/ diff --git a/src/circles/DiscountedBalances.sol b/src/circles/DiscountedBalances.sol index df689d1..7f71ba8 100644 --- a/src/circles/DiscountedBalances.sol +++ b/src/circles/DiscountedBalances.sol @@ -95,8 +95,9 @@ contract DiscountedBalances { /** * @dev stores the discounted balances of the accounts privately. + * Mapping from Circles identifiers to accounts to the discounted balance. */ - mapping(uint256 id => mapping(address account => DiscountedBalance)) private discountedBalances; + mapping(uint256 => mapping(address => DiscountedBalance)) private discountedBalances; /** * @dev Store a lookup table T(n) for computing issuance. diff --git a/src/graph/Graph.sol b/src/graph/Graph.sol index f656643..580e94f 100644 --- a/src/graph/Graph.sol +++ b/src/graph/Graph.sol @@ -230,7 +230,7 @@ contract Graph is ProxyFactory, IGraph { function registerAvatar() external notOnTrustGraph(msg.sender) { bytes memory avatarCircleNodeSetupData = abi.encodeWithSelector(AVATAR_CIRCLE_SETUP_CALLPREFIX, msg.sender); IAvatarCircleNode avatarCircleNode = - IAvatarCircleNode(address(createProxy(address(masterCopyAvatarCircleNode), avatarCircleNodeSetupData))); + IAvatarCircleNode(address(_createProxy(address(masterCopyAvatarCircleNode), avatarCircleNodeSetupData))); avatarToCircle[msg.sender] = avatarCircleNode; _insertAvatarCircleNode(avatarCircleNode); @@ -246,7 +246,7 @@ contract Graph is ProxyFactory, IGraph { bytes memory groupCircleNodeSetupData = abi.encodeWithSelector(GROUP_CIRCLE_SETUP_CALLPREFIX, msg.sender, _exitFee_64x64); IGroupCircleNode groupCircleNode = - IGroupCircleNode(address(createProxy(address(masterCopyGroupCircleNode), groupCircleNodeSetupData))); + IGroupCircleNode(address(_createProxy(address(masterCopyGroupCircleNode), groupCircleNodeSetupData))); groupToCircle[msg.sender] = groupCircleNode; _insertGroupCircleNode(groupCircleNode); diff --git a/src/groups/BaseMintPolicy.sol b/src/groups/BaseMintPolicy.sol new file mode 100644 index 0000000..060dfc6 --- /dev/null +++ b/src/groups/BaseMintPolicy.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.13; + +import "./IMintPolicy.sol"; +import "./Definitions.sol"; + +abstract contract MintPolicy is IMintPolicy { + // External functions + + /** + * @notice Simple mint policy that always returns true + */ + function beforeMintPolicy( + address, /*_minter*/ + address, /*_group*/ + address[] calldata, /*_collateral*/ + uint256[] calldata, /*_amounts*/ + bytes calldata /*_data*/ + ) external virtual override returns (bool) { + return true; + } + + /** + * @notice Simple burn policy that always returns true + */ + function beforeBurnPolicy(address, address, uint256, bytes calldata) external virtual override returns (bool) { + return true; + } + + /** + * @notice Simple redeem policy that returns the redemption ids and values as requested in the data + * @param _data Optional data bytes passed to redeem policy + */ + function beforeRedeemPolicy( + address, /*_operator*/ + address, /*_redeemer*/ + address, /*_group*/ + uint256, /*_value*/ + bytes calldata _data + ) + external + virtual + override + returns ( + uint256[] memory _ids, + uint256[] memory _values, + uint256[] memory _burnIds, + uint256[] memory _burnValues + ) + { + // simplest policy is to return the collateral as the caller requests it in data + BaseMintPolicyDefinitions.BaseRedemptionPolicy memory redemption = + abi.decode(_data, (BaseMintPolicyDefinitions.BaseRedemptionPolicy)); + + // and no collateral gets burnt upon redemption + _burnIds = new uint256[](0); + _burnValues = new uint256[](0); + + // standard treasury checks whether the total sums add up to the amount of group Circles redeemed + // so we can simply decode and pass the request back to treasury. + // The redemption will fail if it does not contain (sufficient of) these Circles + return (redemption.redemptionIds, redemption.redemptionValues, _burnIds, _burnValues); + } +} diff --git a/src/groups/Definitions.sol b/src/groups/Definitions.sol new file mode 100644 index 0000000..0790db7 --- /dev/null +++ b/src/groups/Definitions.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.13; + +contract BaseMintPolicyDefinitions { + // Type declarations + + /** + * @notice Base redemption policy to user specify desired collateral to redeem + */ + struct BaseRedemptionPolicy { + uint256[] redemptionIds; + uint256[] redemptionValues; + } +} diff --git a/src/groups/IMintPolicy.sol b/src/groups/IMintPolicy.sol new file mode 100644 index 0000000..776120d --- /dev/null +++ b/src/groups/IMintPolicy.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.13; + +interface IMintPolicy { + function beforeMintPolicy( + address minter, + address group, + address[] calldata collateral, + uint256[] calldata amounts, + bytes calldata data + ) external returns (bool); + + function beforeRedeemPolicy(address operator, address redeemer, address group, uint256 value, bytes calldata data) + external + returns ( + uint256[] memory redemptionIds, + uint256[] memory redemptionValues, + uint256[] memory burnIds, + uint256[] memory burnValues + ); + + function beforeBurnPolicy(address burner, address group, uint256 value, bytes calldata data) + external + returns (bool); +} diff --git a/src/hub/Hub.sol b/src/hub/Hub.sol index 70fcc9f..0b2f66f 100644 --- a/src/hub/Hub.sol +++ b/src/hub/Hub.sol @@ -1,12 +1,15 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity >=0.8.13; -import "openzeppelin-contracts/contracts/token/ERC1155/utils/ERC1155Holder.sol"; -import "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; -import "openzeppelin-contracts/contracts/utils/Create2.sol"; +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/utils/Create2.sol"; import "../migration/IHub.sol"; import "../migration/IToken.sol"; import "../circles/Circles.sol"; +import "../groups/IMintPolicy.sol"; +import "./IHub.sol"; +import "./MetadataDefinitions.sol"; /** * @title Hub v2 contract for Circles @@ -19,7 +22,7 @@ import "../circles/Circles.sol"; * It further allows to wrap any token into an inflationary or demurraged * ERC20 Circles contract. */ -contract Hub is Circles { +contract Hub is Circles, IHubV2 { // Type declarations /** @@ -208,34 +211,6 @@ contract Hub is Circles { emit InviteHuman(msg.sender, _human); } - /** - * @notice Invite human as organization allows to register a human avatar as an organization. - * @param _human address of the human to invite - * @param _donationReceiver address of where to send the donation to with 2300 gas (using transfer) - */ - function inviteHumanAsOrganization(address _human, address payable _donationReceiver) external payable { - require(msg.value > MINIMUM_DONATION, "Donation must be at least 0.1 xDai."); - // The donation is understood to be a reputational requirement for the organization. - // It is obvious that one can send to self over a different address, but that is reputationally worthless. - // Nonetheless, we require to not directly send to self, mostly to avoid "plausible denial" arguments. - require(_donationReceiver != msg.sender, "Donation receiver cannot be the caller."); - require(isOrganization(msg.sender), "Only organizations can invite."); - - _registerHuman(_human); - - // set trust for a year, but organization can edit this later - _trust(msg.sender, _human, uint96(block.timestamp + 365 days)); - - // invited receives the welcome bonus in their personal Circles - _mint(_human, toTokenId(_human), WELCOME_BONUS, ""); - - // send the donation to the donation receiver but with minimal gas - // to avoid reentrancy attacks - _donationReceiver.transfer(msg.value); - - emit InviteHuman(msg.sender, _human); - } - /** * @notice Register group allows to register a group avatar. * @param _mint mint address will be called before minting group circles @@ -287,7 +262,7 @@ contract Hub is Circles { * @param _cidV0Digest IPFS CIDv0 digest for the organization metadata */ function registerOrganization(string calldata _name, bytes32 _cidV0Digest) external { - require(_isValidName(_name), "Invalid organization name."); + require(isValidName(_name), "Invalid organization name."); _insertAvatar(msg.sender); // store the name for the organization @@ -338,24 +313,62 @@ contract Hub is Circles { // graph transfers SHOULD allow personal -> group conversion en route - // msg.sender holds collateral, and MUST be accepted by group - // maybe less - function groupMint(address _group, uint256[] calldata _collateral, uint256[] calldata _amounts) external { - // check group and collateral exist - // de-demurrage amounts - // loop over collateral + /** + * @notice Group mint allows to mint group Circles by providing the required collateral. + * @param _group address of the group avatar to mint Circles of + * @param _collateral array of (personal or group) avatar addresses to be used as collateral + * @param _amounts array of amounts of collateral to be used for minting + * @param _data (optional) additional data to be passed to the mint policy, treasury and minter + */ + function groupMint( + address _group, + address[] calldata _collateral, + uint256[] calldata _amounts, + bytes calldata _data + ) external { + require(_collateral.length == _amounts.length, "Collateral and amount arrays must have equal length"); + require(_collateral.length > 0, "At least one collateral must be provided"); + require(isGroup(_group), "Group is not registered as an avatar."); + + // note: we don't need to check whether collateral circle ids are registered, + // because only for registered collateral do non-zero balances exist to transfer, + // so it suffices to check that all amounts are non-zero during summing. + uint256 sumAmounts = 0; + uint256[] memory collateralCirclesIds = new uint256[](_collateral.length); + for (uint256 i = 0; i < _amounts.length; i++) { + require(isTrusted(_group, _collateral[i]), "Collateral must be trusted"); + require(_amounts[i] > 0, "Non-zero collateral must be provided."); + sumAmounts += _amounts[i]; + collateralCirclesIds[i] = toTokenId(_collateral[i]); + } - //require( - //mintPolicies[_group].beforeMintPolicy(msg.sender, _group, _collateral, _amounts), ""); + // Rely on the mint policy to determine whether the collateral is valid for minting + require( + IMintPolicy(mintPolicies[_group]).beforeMintPolicy(msg.sender, _group, _collateral, _amounts, _data), + "Mint policy rejected mint." + ); - safeBatchTransferFrom(msg.sender, treasuries[_group], _collateral, _amounts, ""); // treasury.on1155Received should only implement but nothing protocol related + // abi encode the group address into the data to send onwards to the treasury + bytes memory metadataGroup = abi.encode(MetadataDefinitions.GroupMintMetadata({group: _group})); + bytes memory dataWithGroup = abi.encode( + MetadataDefinitions.Metadata({ + metadataType: MetadataDefinitions.MetadataType.GroupMint, + metadata: metadataGroup, + erc1155UserData: _data + }) + ); + + // note: treasury.on1155Received must implement and unpack the GroupMintMetadata to know the group + safeBatchTransferFrom(msg.sender, treasuries[_group], collateralCirclesIds, _amounts, dataWithGroup); - uint256 sumAmounts; - // TODO sum up amounts - sumAmounts = _amounts[0]; - _mint(msg.sender, toTokenId(_group), sumAmounts, ""); + // mint group Circles to the sender and send the original _data onwards + _mint(msg.sender, toTokenId(_group), sumAmounts, _data); } + /** + * @notice Stop allows to stop future mints of personal Circles for this avatar. + * Must be called by the avatar itself. This action is irreversible. + */ function stop() external { require(isHuman(msg.sender), "Only human can call stop."); MintTime storage mintTime = mintTimes[msg.sender]; @@ -364,12 +377,38 @@ contract Hub is Circles { mintTime.lastMintTime = INDEFINITE_FUTURE; } + /** + * Stopped checks whether the avatar has stopped future mints of personal Circles. + * @param _human address of avatar of the human to check whether it is stopped + */ function stopped(address _human) external view returns (bool) { require(isHuman(_human), "Only personal Circles can stopped or not stopped."); MintTime storage mintTime = mintTimes[msg.sender]; return (mintTime.lastMintTime == INDEFINITE_FUTURE); } + /** + * @notice Burn allows to burn Circles owned by the caller. + * @param _id Circles identifier of the Circles to burn + * @param _amount amount of Circles to burn + * @param _data (optional) additional data to be passed to the burn policy if they are group Circles + */ + function burn(uint256 _id, uint256 _amount, bytes calldata _data) external { + // todo: by construction we can not have an id with non-zero balance, + // that was not converted from a group address. + // for now, do a redundant check that the id is identical to the recovered address + address group = address(uint160(_id)); + require(uint256(uint160(group)) == _id, "Invalid Circles identifier."); + + IMintPolicy policy = IMintPolicy(mintPolicies[group]); + if (address(policy) != address(0) && treasuries[group] != msg.sender) { + // if Circles are a group Circles and if the burner is not the associated treasury, + // then the mint policy must approve the burn + require(policy.beforeBurnPolicy(msg.sender, group, _amount, _data), "Burn policy rejected burn."); + } + _burn(msg.sender, _id, _amount); + } + // check if path transfer can be fully ERC1155 compatible // note: matrix math needs to consider mints, otherwise it won't add up @@ -389,6 +428,8 @@ contract Hub is Circles { //require("nett sources have approved operator"); } + // Public functions + function getDeterministicAddress(uint256 _tokenId, bytes32 _bytecodeHash) public view returns (address) { return Create2.computeAddress(keccak256(abi.encodePacked(_tokenId)), _bytecodeHash); } @@ -469,7 +510,7 @@ contract Hub is Circles { } /** - * Checks if an avatar is registered as an organization. + * @notice Checks if an avatar is registered as an organization. * @param _organization address of the organization to check */ function isOrganization(address _organization) public view returns (bool) { @@ -477,6 +518,16 @@ contract Hub is Circles { && mintTimes[_organization].lastMintTime == uint256(0); } + /** + * @notice Returns true if the truster trusts the trustee. + * @param _truster Address of the trusting account + * @param _trustee Address of the trusted account + */ + function isTrusted(address _truster, address _trustee) public view returns (bool) { + // trust up until expiry timestamp + return uint256(trustMarkers[_truster][_trustee].expiry) > block.timestamp; + } + /** * uri returns the IPFS URI for the ERC1155 token. * If the @@ -500,7 +551,7 @@ contract Hub is Circles { * should provide the full display name with unicode characters. * Names are not checked for uniqueness. */ - function _isValidName(string memory _name) public pure returns (bool) { + function isValidName(string memory _name) public pure returns (bool) { bytes memory nameBytes = bytes(_name); if (nameBytes.length > 32 || nameBytes.length == 0) return false; // Check length @@ -529,7 +580,7 @@ contract Hub is Circles { * the length as max 16 bytes and the allowed characters: 0-9, A-Z, a-z, * hyphen, underscore. */ - function _isValidSymbol(string memory _symbol) public pure returns (bool) { + function isValidSymbol(string memory _symbol) public pure returns (bool) { bytes memory symbolBytes = bytes(_symbol); if (symbolBytes.length == 0 || symbolBytes.length > 16) { return false; // Check length is within range @@ -595,9 +646,9 @@ contract Hub is Circles { // todo: same check treasury is an ERC1155Receiver for receiving collateral require(_treasury != address(0), "Treasury address can not be zero."); // name must be ASCII alphanumeric and some special characters - require(_isValidName(_name), "Invalid group name."); + require(isValidName(_name), "Invalid group name."); // symbol must be ASCII alphanumeric and some special characters - require(_isValidSymbol(_symbol), "Invalid group symbol."); + require(isValidSymbol(_symbol), "Invalid group symbol."); // insert avatar into linked list; reverts if it already exists _insertAvatar(_avatar); diff --git a/src/hub/IHub.sol b/src/hub/IHub.sol index 946b1b5..e2307f4 100644 --- a/src/hub/IHub.sol +++ b/src/hub/IHub.sol @@ -1,6 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity >=0.8.13; -interface IHubV2 { - function avatars(address _avatar) external view returns (address); +import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; + +interface IHubV2 is IERC1155 { + function avatars(address avatar) external view returns (address); + function mintPolicies(address avatar) external view returns (address); + function burn(uint256 id, uint256 amount, bytes calldata data) external; } diff --git a/src/hub/MetadataDefinitions.sol b/src/hub/MetadataDefinitions.sol new file mode 100644 index 0000000..2192cc0 --- /dev/null +++ b/src/hub/MetadataDefinitions.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.13; + +contract MetadataDefinitions { + // Type declarations + + struct Metadata { + MetadataType metadataType; + bytes metadata; + bytes erc1155UserData; + } + + struct GroupMintMetadata { + address group; + } + + // Enums + + enum MetadataType { + NoMetadata, + GroupMint + } +} diff --git a/src/proxy/ProxyFactory.sol b/src/proxy/ProxyFactory.sol index c6e7b94..312ea11 100644 --- a/src/proxy/ProxyFactory.sol +++ b/src/proxy/ProxyFactory.sol @@ -14,7 +14,7 @@ contract ProxyFactory { /// execute a message call to the new proxy within one transaction. /// @param masterCopy Address of master copy. /// @param data Payload for message call sent to new proxy contract. - function createProxy(address masterCopy, bytes memory data) internal returns (Proxy proxy) { + function _createProxy(address masterCopy, bytes memory data) internal returns (Proxy proxy) { proxy = new Proxy(masterCopy); if (data.length > 0) { // solhint-disable-next-line no-inline-assembly diff --git a/src/treasury/IStandardVault.sol b/src/treasury/IStandardVault.sol new file mode 100644 index 0000000..74ab2ac --- /dev/null +++ b/src/treasury/IStandardVault.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.13; + +interface IStandardVault { + function returnCollateral(address receiver, uint256[] calldata ids, uint256[] calldata values, bytes calldata data) + external; + function burnCollateral(uint256[] calldata ids, uint256[] calldata values, bytes calldata data) external; +} diff --git a/src/treasury/standardTreasury.sol b/src/treasury/standardTreasury.sol new file mode 100644 index 0000000..5c4c777 --- /dev/null +++ b/src/treasury/standardTreasury.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.13; + +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import "../proxy/ProxyFactory.sol"; +import "../hub/MetadataDefinitions.sol"; +import "../hub/IHub.sol"; +import "../groups/IMintPolicy.sol"; +import "./IStandardVault.sol"; + +contract standardTreasury is ERC165, IERC1155Receiver, ProxyFactory { + // Constants + + /** + * @dev The call prefix for the setup function on the vault contract + */ + bytes4 public constant STANDARD_VAULT_SETUP_CALLPREFIX = bytes4(keccak256("setup(address)")); + + // State variables + + /** + * @notice Address of the hub contract + */ + IHubV2 public immutable hub; + + /** + * @notice Address of the mastercopy standard vault contract + */ + address public immutable mastercopyStandardVault; + + /** + * @notice Mapping of group address to vault address + * @dev The vault is the contract that holds the group's collateral + * todo: we could use deterministic vault addresses as to not store them + * but then we still need to check whether the correct code has been deployed + * so we might as well deploy and store the addresses? + */ + mapping(address => IStandardVault) public vaults; + + // Modifiers + + /** + * @notice Ensure the caller is the hub + */ + modifier onlyHub() { + require(msg.sender == address(hub), "Treasury: caller is not the hub"); + _; + } + + // Constructor + + /** + * @notice Constructor to create a standard treasury + * @param _hub Address of the hub contract + * @param _mastercopyStandardVault Address of the mastercopy standard vault contract + */ + constructor(IHubV2 _hub, address _mastercopyStandardVault) { + require(address(_hub) != address(0), "Hub address cannot be 0"); + require(_mastercopyStandardVault != address(0), "Mastercopy standard vault address cannot be 0"); + hub = _hub; + mastercopyStandardVault = _mastercopyStandardVault; + } + + // Public functions + + /** + * @dev See {IERC165-supportsInterface}. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return interfaceId == type(IERC1155Receiver).interfaceId || super.supportsInterface(interfaceId); + } + + /** + * @dev Exclusively use single received for receiving group Circles to redeem them + * for collateral Circles according to the group mint policy + */ + function onERC1155Received(address _operator, address _from, uint256 _id, uint256 _value, bytes memory _data) + public + virtual + override + returns (bytes4) + { + address group = _validateCirclesIdToGroup(_id); + IStandardVault vault = vaults[group]; + require(address(vault) != address(0), "Treasury: Group has no vault"); + + // query the hub for the mint policy + IMintPolicy policy = IMintPolicy(hub.mintPolicies(group)); + require(address(policy) != address(0), "Treasury: Invalid group without mint policy"); + + // query the mint policy for the redemption values + uint256[] memory redemptionIds; + uint256[] memory redemptionValues; + uint256[] memory burnIds; + uint256[] memory burnValues; + (redemptionIds, redemptionValues, burnIds, burnValues) = + policy.beforeRedeemPolicy(_operator, _from, group, _value, _data); + + // ensure the redemption values sum up to the correct amount + uint256 sum = 0; + for (uint256 i = 0; i < redemptionValues.length; i++) { + sum += redemptionValues[i]; + } + for (uint256 i = 0; i < burnValues.length; i++) { + sum += burnValues[i]; + } + require(sum == _value, "Treasury: Invalid redemption values from policy"); + + // burn the group Circles + hub.burn(_id, _value, _data); + + // return collateral Circles to the redeemer of group Circles + vault.returnCollateral(_from, redemptionIds, redemptionValues, _data); + + // burn the collateral Circles from the vault + vault.burnCollateral(burnIds, burnValues, _data); + + // return the ERC1155 selector for acceptance of the (redeemed) group Circles + return this.onERC1155Received.selector; + } + + /** + * @dev Exclusively use batch received for receiving collateral Circles + * from the hub contract during group minting + */ + function onERC1155BatchReceived( + address, /*_operator*/ + address, /*_from*/ + uint256[] memory _ids, + uint256[] memory _values, + bytes memory _data + ) public virtual override onlyHub returns (bytes4) { + // decode the data to get the group address + MetadataDefinitions.Metadata memory metadata = abi.decode(_data, (MetadataDefinitions.Metadata)); + require(metadata.metadataType == MetadataDefinitions.MetadataType.GroupMint, "Treasury: Invalid metadata type"); + MetadataDefinitions.GroupMintMetadata memory groupMintMetadata = + abi.decode(metadata.metadata, (MetadataDefinitions.GroupMintMetadata)); + // ensure the vault exists + address vault = address(_ensureVault(groupMintMetadata.group)); + // forward the Circles to the vault + hub.safeBatchTransferFrom(address(this), vault, _ids, _values, metadata.erc1155UserData); + return this.onERC1155BatchReceived.selector; + } + + // Internal functions + + /** + * @dev Validate the Circles id to group address + * @param _id Circles identifier + * @return group Address of the group + */ + function _validateCirclesIdToGroup(uint256 _id) internal pure returns (address) { + address group = address(uint160(_id)); + require(uint256(uint160(group)) == _id, "Treasury: Invalid group Circles id"); + return group; + } + + /** + * @dev Ensure the vault exists for the group, and if not deploy it + * @param _group Address of the group + * @return vault Address of the vault + */ + function _ensureVault(address _group) internal returns (IStandardVault) { + IStandardVault vault = vaults[_group]; + if (address(vault) == address(0)) { + vault = _deployVault(); + vaults[_group] = vault; + } + return vault; + } + + // todo: this could be done with deterministic deployment, but same comment, not worth it + /** + * @dev Deploy the vault + * @return vault Address of the vault + */ + function _deployVault() internal returns (IStandardVault) { + bytes memory vaultSetupData = abi.encodeWithSelector(STANDARD_VAULT_SETUP_CALLPREFIX, hub); + IStandardVault vault = IStandardVault(address(_createProxy(mastercopyStandardVault, vaultSetupData))); + return vault; + } +} diff --git a/src/treasury/standardVault.sol b/src/treasury/standardVault.sol new file mode 100644 index 0000000..3495250 --- /dev/null +++ b/src/treasury/standardVault.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.13; + +import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol"; +import "../hub/IHub.sol"; +import "./IStandardVault.sol"; + +contract standardVault is ERC1155Holder, IStandardVault { + // State variables + + /** + * @notice Address of the standard treasury + */ + address public standardTreasury; + + /** + * @notice Address of the hub contract + */ + IHubV2 public hub; + + // Modifiers + + /** + * @notice Ensure the caller is the standard treasury + */ + modifier onlyTreasury() { + require(msg.sender == standardTreasury, "Vault: caller is not the treasury"); + _; + } + + // Constructor + + /** + * @notice Constructor to create a standard vault master copy. + */ + constructor() { + // set the standard treasury to a blocked address for the master copy deployment + standardTreasury = address(1); + } + + // External functions + + /** + * @notice Setup the vault + * @param _hub Address of the hub contract + */ + function setup(IHubV2 _hub) external { + require(address(hub) == address(0), "Vault: already initialized"); + standardTreasury = msg.sender; + hub = _hub; + } + + /** + * Return the collateral to the receiver can only be called by the treasury + * @param _receiver Receivere address of the collateral + * @param _ids Circles identifiers of the collateral + * @param _values Values of the collateral to be returned + * @param _data Optional data bytes passed to the receiver + */ + function returnCollateral( + address _receiver, + uint256[] calldata _ids, + uint256[] calldata _values, + bytes calldata _data + ) external onlyTreasury { + require(_receiver != address(0), "Vault: receiver cannot be 0 address"); + + // return the collateral to the receiver + hub.safeBatchTransferFrom(address(this), _receiver, _ids, _values, _data); + } + + /** + * @notice Burn collateral from the vault can only ve called by the treasury + * @param _ids Circles identifiers of the collateral + * @param _values Values of the collateral to be burnt + * @param _data Optional data bytes passed to the hub and policy for burning + */ + function burnCollateral(uint256[] calldata _ids, uint256[] calldata _values, bytes calldata _data) + external + onlyTreasury + { + require(_ids.length == _values.length, "Vault: ids and values length mismatch"); + + // burn the collateral from the vault + for (uint256 i = 0; i < _ids.length; i++) { + hub.burn(_ids[i], _values[i], _data); + } + } +}