diff --git a/contracts/src/mocks/utils/metadata/MetadataExtensionMock.sol b/contracts/src/mocks/utils/metadata/MetadataExtensionMock.sol new file mode 100644 index 00000000..b9d284fa --- /dev/null +++ b/contracts/src/mocks/utils/metadata/MetadataExtensionMock.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.8; + +import {MetadataExtension} from "../../../utils/metadata/MetadataExtension.sol"; +import {IDAO} from "../../../dao/IDAO.sol"; +import {DaoAuthorizable} from "../../../permission/auth/DaoAuthorizable.sol"; + +/// @notice A mock contract. +/// @dev DO NOT USE IN PRODUCTION! +contract MetadataExtensionMock is MetadataExtension { + constructor(IDAO dao) DaoAuthorizable(dao) {} +} diff --git a/contracts/src/mocks/utils/metadata/MetadataExtensionUpgradeableMock.sol b/contracts/src/mocks/utils/metadata/MetadataExtensionUpgradeableMock.sol new file mode 100644 index 00000000..da4a7e97 --- /dev/null +++ b/contracts/src/mocks/utils/metadata/MetadataExtensionUpgradeableMock.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.8; + +import {MetadataExtensionUpgradeable} from "../../../utils/metadata/MetadataExtensionUpgradeable.sol"; + +import {IDAO} from "../../../dao/IDAO.sol"; + +/// @notice A mock contract. +/// @dev DO NOT USE IN PRODUCTION! +contract MetadataExtensionUpgradeableMock is MetadataExtensionUpgradeable { + function initialize(IDAO _dao) public initializer { + __DaoAuthorizableUpgradeable_init(_dao); + } +} diff --git a/contracts/src/utils/metadata/MetadataExtension.sol b/contracts/src/utils/metadata/MetadataExtension.sol new file mode 100644 index 00000000..2b6beafb --- /dev/null +++ b/contracts/src/utils/metadata/MetadataExtension.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.8; + +import {ERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; + +import {DaoAuthorizable} from "../../permission/auth/DaoAuthorizable.sol"; + +/// @title MetadataExtension +/// @author Aragon X - 2024 +/// @custom:security-contact sirt@aragon.org +abstract contract MetadataExtension is ERC165, DaoAuthorizable { + /// @notice The ID of the permission required to call the `updateMetadata` function. + bytes32 public constant UPDATE_METADATA_PERMISSION_ID = keccak256("UPDATE_METADATA_PERMISSION"); + + /// @notice Emitted when metadata is updated. + event MetadataUpdated(bytes metadata); + + bytes private metadata; + + /// @notice Checks if this or the parent contract supports an interface by its ID. + /// @param _interfaceId The ID of the interface. + /// @return Returns `true` if the interface is supported. + function supportsInterface(bytes4 _interfaceId) public view virtual override returns (bool) { + return + _interfaceId == this.updateMetadata.selector ^ this.getMetadata.selector || + super.supportsInterface(_interfaceId); + } + + /// @notice Allows to update only the metadata. + /// @param _metadata The utf8 bytes of a content addressing cid that stores plugin's information. + function updateMetadata( + bytes memory _metadata + ) public virtual auth(UPDATE_METADATA_PERMISSION_ID) { + _updateMetadata(_metadata); + } + + /// @notice Returns the metadata currently applied. + /// @return The The utf8 bytes of a content addressing cid. + function getMetadata() public view returns (bytes memory) { + return metadata; + } + + /// @notice Internal function to update metadata. + /// @param _metadata The utf8 bytes of a content addressing cid that stores contract's information. + function _updateMetadata(bytes memory _metadata) internal virtual { + metadata = _metadata; + emit MetadataUpdated(_metadata); + } +} diff --git a/contracts/src/utils/metadata/MetadataExtensionUpgradeable.sol b/contracts/src/utils/metadata/MetadataExtensionUpgradeable.sol new file mode 100644 index 00000000..8d62ecd0 --- /dev/null +++ b/contracts/src/utils/metadata/MetadataExtensionUpgradeable.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.8.8; + +import {ERC165Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; + +import {DaoAuthorizableUpgradeable} from "../../permission/auth/DaoAuthorizableUpgradeable.sol"; + +/// @title MetadataExtensionUpgradeable +/// @dev Due to the requirements that already existing upgradeable plugins need to start inheritting from this, +/// we're required to use hardcoded/specific slots for storage instead of sequential slots with gaps. +/// @author Aragon X - 2024 +/// @custom:security-contact sirt@aragon.org +abstract contract MetadataExtensionUpgradeable is ERC165Upgradeable, DaoAuthorizableUpgradeable { + /// @notice The ID of the permission required to call the `updateMetadata` function. + bytes32 public constant UPDATE_METADATA_PERMISSION_ID = keccak256("UPDATE_METADATA_PERMISSION"); + + // keccak256(abi.encode(uint256(keccak256("osx-commons.storage.MetadataExtension")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant MetadataExtensionStorageLocation = + 0x47ff9796f72d439c6e5c30a24b9fad985a00c85a9f2258074c400a94f8746b00; + + /// @notice Emitted when metadata is updated. + event MetadataUpdated(bytes metadata); + + struct MetadataExtensionStorage { + bytes metadata; + } + + function _getMetadataExtensionStorage() + private + pure + returns (MetadataExtensionStorage storage $) + { + assembly { + $.slot := MetadataExtensionStorageLocation + } + } + + /// @notice Checks if this or the parent contract supports an interface by its ID. + /// @param _interfaceId The ID of the interface. + /// @return Returns `true` if the interface is supported. + function supportsInterface(bytes4 _interfaceId) public view virtual override returns (bool) { + return + _interfaceId == this.updateMetadata.selector ^ this.getMetadata.selector || + super.supportsInterface(_interfaceId); + } + + /// @notice Allows to update only the metadata. + /// @param _metadata The utf8 bytes of a content addressing cid that stores plugin's information. + function updateMetadata( + bytes memory _metadata + ) public virtual auth(UPDATE_METADATA_PERMISSION_ID) { + _updateMetadata(_metadata); + } + + /// @notice Returns the metadata currently applied. + /// @return The The utf8 bytes of a content addressing cid. + function getMetadata() public view returns (bytes memory) { + MetadataExtensionStorage storage $ = _getMetadataExtensionStorage(); + return $.metadata; + } + + /// @notice Internal function to update metadata. + /// @param _metadata The utf8 bytes of a content addressing cid that stores contract's information. + function _updateMetadata(bytes memory _metadata) internal virtual { + MetadataExtensionStorage storage $ = _getMetadataExtensionStorage(); + $.metadata = _metadata; + + emit MetadataUpdated(_metadata); + } +} diff --git a/contracts/test/utils/metadata.ts b/contracts/test/utils/metadata.ts new file mode 100644 index 00000000..230656d4 --- /dev/null +++ b/contracts/test/utils/metadata.ts @@ -0,0 +1,121 @@ +import { + DAOMock, + DAOMock__factory, + MetadataExtensionMock__factory, + MetadataExtensionUpgradeableMock__factory, + MetadataExtensionMock, + MetadataExtensionUpgradeableMock, +} from '../../typechain'; +import {erc165ComplianceTests} from '../helpers'; +import {loadFixture} from '@nomicfoundation/hardhat-network-helpers'; +import {expect} from 'chai'; +import {ethers} from 'hardhat'; + +describe('MetadataExtension', async () => { + MetadataExtensionBaseTests(metadataFixture); +}); + +describe('MetadataExtensionUpgradeable', async () => { + MetadataExtensionBaseTests(metadataUpgradeableFixture); +}); + +// Contains tests for functionality common for `MetadataExtensionMock` and `MetadataExtensionMockUpgradeable` to avoid duplication. +function MetadataExtensionBaseTests(fixture: () => Promise) { + describe('ERC-165', async () => { + it('supports the `ERC-165` standard', async () => { + const {metadataMock} = await loadFixture(fixture); + const signers = await ethers.getSigners(); + await erc165ComplianceTests(metadataMock, signers[0]); + }); + + it('supports the `updateMetadata/getMetadata` selector interface', async () => { + const {metadataMock} = await loadFixture(fixture); + const iface = MetadataExtensionMock__factory.createInterface(); + const interfaceId = ethers.BigNumber.from( + iface.getSighash('updateMetadata') + ) + .xor(ethers.BigNumber.from(iface.getSighash('getMetadata'))) + .toHexString(); + + expect(await metadataMock.supportsInterface(interfaceId)).to.be.true; + }); + }); + + describe('updateMetadata/getMetadata', async () => { + let data: FixtureResult; + beforeEach(async () => { + data = await loadFixture(fixture); + const {metadataMock, daoMock} = data; + await daoMock.setHasPermissionReturnValueMock(true); + }); + + it("reverts if caller doesn't have a permission", async () => { + const {metadataMock, daoMock} = data; + await daoMock.setHasPermissionReturnValueMock(false); + + await expect( + metadataMock.updateMetadata('0x11') + ).to.be.revertedWithCustomError(metadataMock, 'DaoUnauthorized'); + }); + + it('sets the metadata and emits the event', async () => { + const {metadataMock} = data; + const metadata = '0x11'; + await expect(metadataMock.updateMetadata(metadata)) + .to.emit(metadataMock, 'MetadataUpdated') + .withArgs(metadata); + }); + + it('retrieves the metadata', async () => { + const {metadataMock} = data; + let metadata = '0x11'; + await metadataMock.updateMetadata(metadata); + expect(await metadataMock.getMetadata()).to.equal(metadata); + + // Check that it correctly retrieves the metadata if the length is > 32 + // This ensures that our `sstore/sload` operations behave correctly. + metadata = '0x' + '11'.repeat(50); + await metadataMock.updateMetadata(metadata); + expect(await metadataMock.getMetadata()).to.equal(metadata); + }); + }); +} + +type BaseFixtureResult = { + daoMock: DAOMock; +}; + +async function baseFixture(): Promise { + const signers = await ethers.getSigners(); + const daoMock = await new DAOMock__factory(signers[0]).deploy(); + + return {daoMock}; +} + +type FixtureResult = { + metadataMock: MetadataExtensionMock | MetadataExtensionUpgradeableMock; + daoMock: DAOMock; +}; + +async function metadataFixture(): Promise { + const {daoMock} = await baseFixture(); + const signers = await ethers.getSigners(); + const metadataMock = await new MetadataExtensionMock__factory( + signers[0] + ).deploy(daoMock.address); + + return {metadataMock, daoMock}; +} + +async function metadataUpgradeableFixture(): Promise { + const {daoMock} = await baseFixture(); + const signers = await ethers.getSigners(); + + const metadataMock = await new MetadataExtensionUpgradeableMock__factory( + signers[0] + ).deploy(); + + await metadataMock.initialize(daoMock.address); + + return {metadataMock, daoMock}; +}