diff --git a/contracts/contracts/Imports.sol b/contracts/contracts/Imports.sol index 4604622..4e7b09d 100644 --- a/contracts/contracts/Imports.sol +++ b/contracts/contracts/Imports.sol @@ -2,4 +2,4 @@ pragma solidity ^0.8.18; // Import the contract so hardhat compiles it, and we have the ABI available -import {MockContract} from "@gnosis.pm/mock-contract/contracts/MockContract.sol"; +import {MockContract} from "@safe-global/mock-contract/contracts/MockContract.sol"; diff --git a/contracts/contracts/Plugins.sol b/contracts/contracts/Plugins.sol new file mode 100644 index 0000000..497f88f --- /dev/null +++ b/contracts/contracts/Plugins.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.18; + +import {ISafe} from "./interfaces/Accounts.sol"; +import {ISafeProtocolPlugin} from "./interfaces/Integrations.sol"; +import {ISafeProtocolManager} from "./interfaces/Manager.sol"; +import {SafeTransaction, SafeRootAccess} from "./DataTypes.sol"; + +enum MetaDataProviderType { + IPFS, + URL, + Contract, + Event +} + +interface MetaDataProvider { + function retrieveMetaData(bytes32 metaDataHash) external view returns (bytes memory metaData); +} + +struct PluginMetaData { + string name; + string version; + bool requiresRootAccess; + string iconUrl; + string appUrl; +} + +library PluginMetaDataOps { + function encode(PluginMetaData memory data) internal pure returns (bytes memory) { + return + abi.encodePacked( + uint8(0x00), // Format + uint8(0x00), // Format version + abi.encode(data.name, data.version, data.requiresRootAccess, data.iconUrl, data.appUrl) // Meta Data + ); + } + + function decode(bytes calldata data) internal pure returns (PluginMetaData memory) { + require(bytes16(data[0:2]) == bytes16(0x0000), "Unsupported format or format version"); + (string memory name, string memory version, bool requiresRootAccess, string memory iconUrl, string memory appUrl) = abi.decode( + data[2:], + (string, string, bool, string, string) + ); + return PluginMetaData(name, version, requiresRootAccess, iconUrl, appUrl); + } +} + +abstract contract BasePlugin is ISafeProtocolPlugin, MetaDataProvider { + using PluginMetaDataOps for PluginMetaData; + + string public name; + string public version; + bool public immutable requiresRootAccess; + bytes32 public immutable metaDataHash; + bytes private encodedMetaData; + + constructor(PluginMetaData memory metaData) { + name = metaData.name; + version = metaData.version; + requiresRootAccess = metaData.requiresRootAccess; + // MetaData Format + Format Version + Encoded MetaData + encodedMetaData = metaData.encode(); + metaDataHash = keccak256(encodedMetaData); + } + + function metaProvider() external view override returns (uint256 providerType, bytes memory location) { + providerType = uint256(MetaDataProviderType.Contract); + location = abi.encode(address(this)); + } + + function retrieveMetaData(bytes32 _metaDataHash) external view returns (bytes memory metaData) { + require(metaDataHash == _metaDataHash, "Cannot retrieve meta data"); + return encodedMetaData; + } +} + +contract SamplePlugin is BasePlugin { + constructor() + BasePlugin(PluginMetaData({name: "Sample Plugin", version: "1.0.0", requiresRootAccess: false, iconUrl: "", appUrl: ""})) + {} + + function executeFromPlugin( + ISafeProtocolManager manager, + ISafe safe, + SafeTransaction calldata safetx + ) external returns (bytes[] memory data) { + (data) = manager.executeTransaction(safe, safetx); + } +} diff --git a/contracts/contracts/SamplePlugin.sol b/contracts/contracts/SamplePlugin.sol deleted file mode 100644 index c788cf4..0000000 --- a/contracts/contracts/SamplePlugin.sol +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-only -pragma solidity ^0.8.18; - -import {ISafe} from "./interfaces/Accounts.sol"; -import {ISafeProtocolPlugin} from "./interfaces/Integrations.sol"; -import {ISafeProtocolManager} from "./interfaces/Manager.sol"; -import {SafeTransaction, SafeRootAccess} from "./DataTypes.sol"; - -abstract contract BasePlugin is ISafeProtocolPlugin { - string public name; - string public version; - bool public immutable requiresRootAccess; - - constructor(string memory _name, string memory _version, bool _requiresRootAccess) { - name = _name; - version = _version; - requiresRootAccess = _requiresRootAccess; - } - - function metaProvider() external view override returns (uint256 providerType, bytes memory location) {} -} - -contract SamplePlugin is BasePlugin { - constructor() BasePlugin("Sample Plugin", "1.0.0", false) {} - - function executeFromPlugin( - ISafeProtocolManager manager, - ISafe safe, - SafeTransaction calldata safetx - ) external returns (bytes[] memory data) { - (data) = manager.executeTransaction(safe, safetx); - } -} diff --git a/contracts/package.json b/contracts/package.json index 4662d2c..fdd626c 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -15,14 +15,14 @@ "prepack": "yarn build" }, "devDependencies": { - "@gnosis.pm/mock-contract": "gnosis/mock-contract#b0f735ddc62d5000b50667011d69142a4dee9c71", - "@gnosis.pm/safe-singleton-factory": "^1.0.14", "@nomicfoundation/hardhat-chai-matchers": "^2.0.0", "@nomicfoundation/hardhat-ethers": "^3.0.0", "@nomicfoundation/hardhat-network-helpers": "^1.0.0", "@nomicfoundation/hardhat-toolbox": "^3.0.0", "@nomicfoundation/hardhat-verify": "^1.0.0", "@openzeppelin/contracts": "^4.9.1", + "@safe-global/mock-contract": "^4.0.0", + "@gnosis.pm/safe-singleton-factory": "^1.0.14", "@typechain/ethers-v6": "^0.4.0", "@typechain/hardhat": "^8.0.0", "@types/chai": "^4.2.0", diff --git a/contracts/test/SamplePlugin.spec.ts b/contracts/test/SamplePlugin.spec.ts index 24c681f..f733af2 100644 --- a/contracts/test/SamplePlugin.spec.ts +++ b/contracts/test/SamplePlugin.spec.ts @@ -2,6 +2,7 @@ import hre, { deployments } from "hardhat"; import { expect } from "chai"; import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; import { getSamplePlugin } from "./utils/contracts"; +import { loadPluginMetaData } from "./utils/metadata"; describe("SamplePlugin", async () => { let user1: SignerWithAddress; @@ -24,4 +25,15 @@ describe("SamplePlugin", async () => { expect(await plugin.version()).to.be.eq("1.0.0"); expect(await plugin.requiresRootAccess()).to.be.false; }); + + it("can retrieve meta data for module", async () => { + const { plugin } = await setup() + expect(await loadPluginMetaData(plugin)).to.be.deep.eq({ + name: 'Sample Plugin', + version: '1.0.0', + requiresRootAccess: false, + iconUrl: '', + appUrl: '' + }); + }); }); diff --git a/contracts/test/utils/contracts.ts b/contracts/test/utils/contracts.ts index a41788e..1529156 100644 --- a/contracts/test/utils/contracts.ts +++ b/contracts/test/utils/contracts.ts @@ -1,11 +1,15 @@ -import { BaseContract } from "ethers"; +import { Addressable, BaseContract } from "ethers"; import hre, { deployments } from "hardhat"; import { SamplePlugin } from "../../typechain-types"; -export const getInstance = async(name: string): Promise => { +export const getInstance = async(name: string, address: string): Promise => { + // TODO: this typecasting should be refactored + return (await hre.ethers.getContractAt(name, address) as unknown) as T; +}; + +export const getSingleton = async(name: string): Promise => { const deployment = await deployments.get(name); - const Contract = await hre.ethers.getContractFactory(name); - return Contract.attach(deployment.address) as T; + return getInstance(name, deployment.address) }; -export const getSamplePlugin = () => getInstance("SamplePlugin") \ No newline at end of file +export const getSamplePlugin = () => getSingleton("SamplePlugin") \ No newline at end of file diff --git a/contracts/test/utils/metadata.ts b/contracts/test/utils/metadata.ts new file mode 100644 index 0000000..a0bf437 --- /dev/null +++ b/contracts/test/utils/metadata.ts @@ -0,0 +1,64 @@ +import { AbiCoder, ParamType, isHexString, keccak256 } from "ethers" +import { BasePlugin, MetaDataProvider, MetaDataProvider__factory } from "../../typechain-types" +import { getInstance } from "./contracts"; + +interface PluginMetaData { + name: string, + version: string, + requiresRootAccess: boolean, + iconUrl: string, + appUrl: string +} + +const ProviderType_IPFS = 0n; +const ProviderType_URL = 1n; +const ProviderType_Contract = 2n; +const ProviderType_Event = 3n; + +const PluginMetaDataType: string[] = [ + "string name", + "string version", + "bool requiresRootAccess", + "string iconUrl", + "string appUrl" +] + +const loadPluginMetaDataFromContract = async (provider: string, metaDataHash: string): Promise => { + const providerInstance = await getInstance("MetaDataProvider", provider) + return await providerInstance.retrieveMetaData(metaDataHash) +} + +const loadRawMetaData = async(plugin: BasePlugin, metaDataHash: string): Promise => { + const [type, source] = await plugin.metaProvider() + switch(type) { + case ProviderType_Contract: + return loadPluginMetaDataFromContract(AbiCoder.defaultAbiCoder().decode(["address"], source)[0], metaDataHash) + default: + throw Error("Unsupported MetaDataProviderType") + } +} + +export const loadPluginMetaData = async (plugin: BasePlugin): Promise => { + const metaDataHash = await plugin.metaDataHash() + const metaData = await loadRawMetaData(plugin, metaDataHash) + if (metaDataHash !== keccak256(metaData)) throw Error("Invalid meta data retrieved!") + return decodePluginMetaData(metaData) +} + +export const decodePluginMetaData = (data: string): PluginMetaData => { + if (!isHexString(data)) throw Error("Invalid data format"); + const format = data.slice(2, 6) + if (format !== "0000") throw Error("Unsupported format or format version"); + const metaData = data.slice(6) + const decoded = AbiCoder.defaultAbiCoder().decode( + PluginMetaDataType, + "0x" + metaData + ) + return { + name: decoded[0], + version: decoded[1], + requiresRootAccess: decoded[2], + iconUrl: decoded[3], + appUrl: decoded[4] + } +} \ No newline at end of file diff --git a/contracts/yarn.lock b/contracts/yarn.lock index 9367205..1020edd 100644 --- a/contracts/yarn.lock +++ b/contracts/yarn.lock @@ -450,10 +450,6 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" -"@gnosis.pm/mock-contract@gnosis/mock-contract#b0f735ddc62d5000b50667011d69142a4dee9c71": - version "4.0.0" - resolved "https://codeload.github.com/gnosis/mock-contract/tar.gz/b0f735ddc62d5000b50667011d69142a4dee9c71" - "@gnosis.pm/safe-singleton-factory@^1.0.14": version "1.0.14" resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-singleton-factory/-/safe-singleton-factory-1.0.14.tgz#42dae9a91fda21b605f94bfe310a7fccc6a4d738" @@ -792,6 +788,11 @@ resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.1.tgz#afa804d2c68398704b0175acc94d91a54f203645" integrity sha512-aLDTLu/If1qYIFW5g4ZibuQaUsFGWQPBq1mZKp/txaebUnGHDmmiBhRLY1tDNedN0m+fJtKZ1zAODS9Yk+V6uA== +"@safe-global/mock-contract@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@safe-global/mock-contract/-/mock-contract-4.0.0.tgz#8e1e17e93af5d4b343a6bb6cef8c1f513cb7a92e" + integrity sha512-6ijStTgQI6JzYe8Nsc4j1VW4XQ89qCl7ZkRGxwwlxnaOMDYzVekwPACbg2kDDzhtJ4p8vSvE6ZroxSkvP7610A== + "@scure/base@~1.1.0": version "1.1.1" resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"