diff --git a/contracts/README.md b/contracts/README.md index 9e1640c..e7ac987 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -26,4 +26,16 @@ yarn test ```bash yarn deploy +``` + +### Interact with registry + +- Register the Sample Plugin on the Registry +```bash +yarn register-plugin +``` + +- List all Plugins in the Registry +```bash +yarn list-plugins ``` \ No newline at end of file diff --git a/contracts/package.json b/contracts/package.json index 2c9db53..9ee83d9 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -19,6 +19,8 @@ "typechain": "TS_NODE_TRANSPILE_ONLY=true hardhat typechain", "postinstall": "yarn typechain", "deploy": "hardhat deploy --network", + "register-plugin": "hardhat register-plugin --network", + "list-plugins": "hardhat list-plugins --network", "prepack": "yarn build" }, "devDependencies": { diff --git a/contracts/src/utils/protocol.ts b/contracts/src/utils/protocol.ts index 8eac372..6cd388d 100644 --- a/contracts/src/utils/protocol.ts +++ b/contracts/src/utils/protocol.ts @@ -21,6 +21,8 @@ export const getProtocolManagerAddress = async(hre: HardhatRuntimeEnvironment): // For the tests we deploy a mock for the manager if (chainId === "31337") return deployMock(hre, "ManagerMock") + + if (chainId === "5") return "0xb4Dc1B282706aB473cdD1b9899c57baD2BD3e2f3" if (!(chainId in protocolDeployments)) throw Error("Unsupported Chain") const manager = (protocolDeployments as any)[chainId][0].contracts.SafeProtocolManager.address @@ -33,6 +35,8 @@ export const getProtocolRegistryAddress = async(hre: HardhatRuntimeEnvironment): // For the tests we deploy a mock for the registry if (chainId === "31337") return deployMock(hre, "RegistryMock") + + if (chainId === "5") return "0x9EFbBcAD12034BC310581B9837D545A951761F5A" if (!(chainId in protocolDeployments)) throw Error("Unsupported Chain") // We use the unrestricted registry for the demo diff --git a/web/src/logic/plugins.ts b/web/src/logic/plugins.ts index 4ff7cf7..d2b353e 100644 --- a/web/src/logic/plugins.ts +++ b/web/src/logic/plugins.ts @@ -1,11 +1,23 @@ -import { EventLog } from "ethers"; +import { ZeroAddress, EventLog } from "ethers"; +import { BaseTransaction } from '@safe-global/safe-apps-sdk'; import { PluginMetadata, loadPluginMetadata } from "./metadata"; -import { getPlugin, getRegistry } from "./protocol"; +import { getManager, getPlugin, getRegistry } from "./protocol"; +import { getSafeInfo, isConnectToSafe, submitTxs } from "./safeapp"; +import { isModuleEnabled, buildEnableModule } from "./safe"; -export const loadPluginDetails = async(pluginAddress: string): Promise => { +const SENTINEL_MODULES = "0x0000000000000000000000000000000000000001" + +export interface PluginDetails { + metadata: PluginMetadata, + enabled?: boolean +} + +export const loadPluginDetails = async(pluginAddress: string): Promise => { const plugin = await getPlugin(pluginAddress) - const metadata = loadPluginMetadata(plugin) - return metadata + const metadata = await loadPluginMetadata(plugin) + if (!await isConnectToSafe()) return { metadata } + const enabled = await isPluginEnabled(pluginAddress) + return { metadata, enabled } } export const loadPlugins = async(filterFlagged: boolean = true): Promise => { @@ -16,4 +28,68 @@ export const loadPlugins = async(filterFlagged: boolean = true): Promise event.args.integration) return addedIntegrations.filter((integration) => flaggedIntegrations.indexOf(integration) < 0) +} + +export const isPluginEnabled = async(plugin: string) => { + if (!await isConnectToSafe()) throw Error("Not connected to a Safe") + const manager = await getManager() + const safeInfo = await getSafeInfo() + const pluginInfo = await manager.enabledPlugins(safeInfo.safeAddress, plugin) + return pluginInfo.nextPluginPointer !== ZeroAddress +} + +export const loadEnabledPlugins = async(): Promise => { + if (!await isConnectToSafe()) throw Error("Not connected to a Safe") + const manager = await getManager() + const safeInfo = await getSafeInfo() + const paginatedPlugins = await manager.getPluginsPaginated(SENTINEL_MODULES, 10, safeInfo.safeAddress) + return paginatedPlugins.array +} + +const buildEnablePlugin = async(plugin: string, requiresRootAccess: boolean): Promise => { + const manager = await getManager() + return { + to: await manager.getAddress(), + value: "0", + data: (await manager.enablePlugin.populateTransaction(plugin, requiresRootAccess)).data + } +} + +export const enablePlugin = async(plugin: string, requiresRootAccess: boolean) => { + if (!await isConnectToSafe()) throw Error("Not connected to a Safe") + const manager = await getManager() + const managerAddress = await manager.getAddress() + const info = await getSafeInfo() + const txs: BaseTransaction[] = [] + if (!await isModuleEnabled(info.safeAddress, managerAddress)) { + txs.push(await buildEnableModule(info.safeAddress, managerAddress)) + } + if (!await isPluginEnabled(plugin)) { + txs.push(await buildEnablePlugin(plugin, requiresRootAccess)) + } + if (txs.length == 0) return + await submitTxs(txs) +} + +const buildDisablePlugin = async(pointer: string, plugin: string): Promise => { + const manager = await getManager() + return { + to: await manager.getAddress(), + value: "0", + data: (await manager.disablePlugin.populateTransaction(pointer, plugin)).data + } +} + +export const disablePlugin = async(plugin: string) => { + if (!await isConnectToSafe()) throw Error("Not connected to a Safe") + const manager = await getManager() + const txs: BaseTransaction[] = [] + const enabledPlugins = await loadEnabledPlugins() + const index = enabledPlugins.indexOf(plugin) + // Plugin is not enabled + if (index < 0) return + // If the plugin is not the first element in the linked list use previous element as pointer + // Otherwise use sentinel as pointer + const pointer = index > 0 ? enabledPlugins[index - 1] : SENTINEL_MODULES; + await submitTxs([await buildDisablePlugin(pointer, plugin)]) } \ No newline at end of file diff --git a/web/src/logic/protocol.ts b/web/src/logic/protocol.ts index 5470ff1..e2f6f69 100644 --- a/web/src/logic/protocol.ts +++ b/web/src/logic/protocol.ts @@ -11,11 +11,24 @@ const PLUGIN_ABI = [ "function metadataProvider() external view returns (uint256 providerType, bytes location)" ] +export const getManager = async() => { + const provider = await getProvider() + const registryInfo = { + address: "0xb4Dc1B282706aB473cdD1b9899c57baD2BD3e2f3", + abi: protocolDeployments[5][0].contracts.SafeProtocolManager.abi + }; + return new ethers.Contract( + registryInfo.address, + registryInfo.abi, + provider + ) +} + export const getRegistry = async() => { const provider = await getProvider() const registryInfo = protocolDeployments[5][0].contracts.TestSafeProtocolRegistryUnrestricted; return new ethers.Contract( - registryInfo.address, + "0x9EFbBcAD12034BC310581B9837D545A951761F5A", registryInfo.abi, provider ) diff --git a/web/src/logic/safe.ts b/web/src/logic/safe.ts new file mode 100644 index 0000000..4b0a925 --- /dev/null +++ b/web/src/logic/safe.ts @@ -0,0 +1,32 @@ +import { ethers } from "ethers" +import { getProvider } from "./web3"; +import { BaseTransaction } from '@safe-global/safe-apps-sdk'; + +const SAFE_ABI = [ + "function isModuleEnabled(address module) public view returns (bool)", + "function enableModule(address module) public" +] + +// TODO: use safe-core-sdk here +const getSafe = async(address: string) => { + const provider = await getProvider() + return new ethers.Contract( + address, + SAFE_ABI, + provider + ) +} + +export const isModuleEnabled = async(safeAddress: string, module: string): Promise => { + const safe = await getSafe(safeAddress) + return await safe.isModuleEnabled(module) +} + +export const buildEnableModule = async(safeAddress: string, module: string): Promise => { + const safe = await getSafe(safeAddress) + return { + to: safeAddress, + value: "0", + data: (await safe.enableModule.populateTransaction(module)).data + } +} \ No newline at end of file diff --git a/web/src/logic/safeapp.ts b/web/src/logic/safeapp.ts index 97d7120..f14c9a7 100644 --- a/web/src/logic/safeapp.ts +++ b/web/src/logic/safeapp.ts @@ -1,6 +1,6 @@ -import { AbstractProvider, ethers } from "ethers" -import SafeAppsSDK, { SafeInfo } from '@safe-global/safe-apps-sdk'; +import { ethers } from "ethers" +import SafeAppsSDK, { SafeInfo, BaseTransaction } from '@safe-global/safe-apps-sdk'; import { SafeAppProvider } from '@safe-global/safe-apps-provider'; import { PROTOCOL_CHAIN_ID } from "./constants"; @@ -32,4 +32,24 @@ export const getSafeAppsProvider = async() => { const info = await getSafeInfo() if (info.chainId != Number(PROTOCOL_CHAIN_ID)) throw Error("Unsupported chain") return new ethers.BrowserProvider(new SafeAppProvider(info, safeAppsSDK)) +} + +export const submitTxs = async(txs: BaseTransaction[]): Promise => { + const response = await safeAppsSDK.txs.send({ txs }) + return response.safeTxHash +} + +export const openSafeApp = async(appUrl: string) => { + if (!isConnectToSafe()) return + const safe = await getSafeInfo() + const environmentInfo = await safeAppsSDK.safe.getEnvironmentInfo() + const origin = environmentInfo.origin; + const chainInfo = await safeAppsSDK.safe.getChainInfo() + const networkPrefix = chainInfo.shortName + if (origin?.length) { + window.open( + `${origin}/apps/open?safe=${networkPrefix}:${safe.safeAddress}&appUrl=${appUrl}`, + '_blank', + ) + } } \ No newline at end of file diff --git a/web/src/routes/plugins/Plugin.tsx b/web/src/routes/plugins/Plugin.tsx index c157076..e367af9 100644 --- a/web/src/routes/plugins/Plugin.tsx +++ b/web/src/routes/plugins/Plugin.tsx @@ -1,10 +1,12 @@ -import { FunctionComponent, useEffect, useState } from "react"; +import { FunctionComponent, useCallback, useEffect, useState } from "react"; import WarningIcon from '@mui/icons-material/Warning'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import * as blockies from 'blockies-ts'; import "./Plugins.css"; import { PluginMetadata } from "../../logic/metadata"; -import { loadPluginDetails } from "../../logic/plugins"; +import { PluginDetails, disablePlugin, enablePlugin, loadPluginDetails } from "../../logic/plugins"; +import { openSafeApp } from "../../logic/safeapp"; +import { Button } from '@mui/material'; type PluginMetaProps = { metadata: PluginMetadata; @@ -23,24 +25,37 @@ type PluginProps = { }; export const Plugin: FunctionComponent = ({ address }) => { - const [metadata, setMetadata] = useState(undefined); + const [details, setDetails] = useState(undefined); const blocky = blockies.create({ seed: address }).toDataURL(); useEffect(() => { const fetchData = async() => { try { - setMetadata(await loadPluginDetails(address)) + setDetails(await loadPluginDetails(address)) } catch(e) { console.warn(e) } } fetchData(); }, [address]) + + const handleToggle = useCallback(async () => { + if (details?.enabled === undefined) return + try { + if (details.enabled) + await disablePlugin(address) + else + await enablePlugin(address, details.metadata.requiresRootAccess) + } catch (e) { + console.warn(e) + } + }, [details]) return (
-
{!metadata ? "Loading Metadata" : }
- {metadata?.requiresRootAccess == true && } - {(metadata?.appUrl?.length ?? 0) > 0 && } +
{!details ? "Loading Metadata" : }
+ {details?.metadata?.requiresRootAccess == true && } + {(details?.metadata?.appUrl?.length ?? 0) > 0 && openSafeApp(details?.metadata?.appUrl!!)} />} + {details?.enabled != undefined && }
); };