Skip to content
This repository has been archived by the owner on Oct 27, 2023. It is now read-only.

Commit

Permalink
Reorganize Examples (5afe#19)
Browse files Browse the repository at this point in the history
* Add event storage for metadata; Extend sample to show relaying

* Add event for max fee update

* Add posibility to relay tx via UI

* Fix uri encoding

* Improve UX slightly

* Fix app url

* Revert link change

* Improve structure and documentation

* Renaming

* Formatting

* Update description and logo for react app
  • Loading branch information
rmeissner authored Jul 17, 2023
1 parent c8cda23 commit 630c5d0
Show file tree
Hide file tree
Showing 33 changed files with 1,443 additions and 169 deletions.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Safe{Core} Protocol Demo

This repository contain a demo for developing and using [Plugins](https://github.com/5afe/safe-core-protocol-specs/tree/main/integrations#plugins) from the Safe{Core} Protocol.

## Structure

The repository is separated into two parts:

- [Contracts](./contracts/) contains the sample contracts and scripts to deploy them
- [Web App](./web/) contains the web app to configure and use the sample contracts

## Make it your own

To get started with your own plugin you can fork/ copy this repository and adjust the existing code.

Follow the instructions in the [Contracts](./contracts/) folder to create a Plugin for your use case. You can then register the plugin on a test registry and it will be visible on [Demo App](https://5afe.github.io/safe-core-protocol-demo).

This [Demo App](https://5afe.github.io/safe-core-protocol-demo) can be used as a [Safe app in the Safe{Wallet} Web](https://app.safe.global/share/safe-app?appUrl=https%3A%2F%2F5afe.github.io%2Fsafe-core-protocol-demo&chain=gor) interface to add the Plugin to a Safe.

To configure your Plugin it is necessary to create a web app. For this follow the instructions in the [Web App](./web/) folder. A simple way to host your web app is to use [GitHub pages](https://pages.github.com/). For this you can use the `yarn deploy` script.

Important don't forget to update your [Plugin Metadata](./contracts/README.md#plugin-metadata).
48 changes: 48 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,52 @@

This project shows the usage of [Safe{Core} protocol](https://github.com/5afe/safe-core-protocol)

## Structure

The project contains [contracts](./contracts), [tests](./test) and [scripts](./src/) to build a Plugin.

- [Contracts](./contracts)
- [Imports.sol](./contracts/Imports.sol) - Import that are only used for testing and by scripts
- [Base.sol](./contracts/Base.sol) - Types and base Plugin contracts that can be extended (i.e. to manage Plugin metadata)
- [Plugins.sol](./contracts/Plugins.sol) - A collection of example Plugins
- [Tests](./test)
- [RelayPlugin.spec.ts](./test/SamplePlugin.spec.ts) - Tests for the Relay example Plugin.
- [Scripts](./src)
- [Deployment](./src/deploy) - Deployment scripts for the example Plugins
- [Tasks](./src/tasks) - Tasks to register plugins
- [Utils](./src/utils) - Utility method to interfact with Plugins

## Plugin Metadata

The metadata of a Plugin is used to provide information to users when enabling the Plugin. Currently the information required is:
- `name` - Name of the Plugin that should be displayed
- `version` - Version of the Plugin that is shown to the user
- `requiresRootAccess` - Indicates if the Plugin require root access (i.e. perform `delegatecall` or change the account config).
- `iconUrl` - Icon that should be displayed
- `appUrl` - App to configure and use the Plugin

Note: The format and type of metadata currently required by Plugins is just for this demo. This will change in the future and a proper format will be proposed in the [specificiations](https://github.com/5afe/safe-core-protocol-specs)

## Contracts

### Base Contracts

The base contracts include two base contracts:

- `BasePluginWithStoredMetadata` - A plugin that stores the metadata onchain
- `BasePluginWithEventMetadata` - A plugin that stores the metadata in an event

Both allow that the web app can retrieve this metadata to display it to the user.

It is also possible to provide other storage means (i.e. `ipfs` or `url`). For this it is necessary to extend the `BasePlugin` contract and add the require utility script.

### Example Plugins

The following example are included int his repository:

- `RelayPlugin` - A plugin that allows to relay Safe transactions and pay a fee for it which is capped by the user.


## Useful commands

### Install
Expand Down Expand Up @@ -30,6 +76,8 @@ yarn deploy <network>

### Interact with registry

The Registry used in this demo is a open test Registry (so no verification of Plugins or any other listing requirements).

- Register the Sample Plugin on the Registry
```bash
yarn register-plugin <network>
Expand Down
94 changes: 94 additions & 0 deletions contracts/contracts/Base.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity ^0.8.18;

import {ISafeProtocolPlugin} from "@safe-global/safe-core-protocol/contracts/interfaces/Integrations.sol";

enum MetadataProviderType {
IPFS,
URL,
Contract,
Event
}

interface IMetadataProvider {
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) // Plugin Metadata
);
}

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 {
using PluginMetadataOps for PluginMetadata;

string public name;
string public version;
bool public immutable requiresRootAccess;
bytes32 public immutable metadataHash;

constructor(PluginMetadata memory metadata) {
name = metadata.name;
version = metadata.version;
requiresRootAccess = metadata.requiresRootAccess;
metadataHash = keccak256(metadata.encode());
}
}

abstract contract BasePluginWithStoredMetadata is BasePlugin, IMetadataProvider {
using PluginMetadataOps for PluginMetadata;

bytes private encodedMetadata;

constructor(PluginMetadata memory metadata) BasePlugin(metadata) {
encodedMetadata = metadata.encode();
}

function retrieveMetadata(bytes32 _metadataHash) external view override returns (bytes memory metadata) {
require(metadataHash == _metadataHash, "Cannot retrieve metadata");
return encodedMetadata;
}

function metadataProvider() public view override returns (uint256 providerType, bytes memory location) {
providerType = uint256(MetadataProviderType.Contract);
location = abi.encode(address(this));
}
}

abstract contract BasePluginWithEventMetadata is BasePlugin {
using PluginMetadataOps for PluginMetadata;

event Metadata(bytes32 indexed metadataHash, bytes data);

constructor(PluginMetadata memory metadata) BasePlugin(metadata) {
emit Metadata(metadataHash, metadata.encode());
}

function metadataProvider() public view override returns (uint256 providerType, bytes memory location) {
providerType = uint256(MetadataProviderType.Event);
location = abi.encode(address(this));
}
}
142 changes: 70 additions & 72 deletions contracts/contracts/Plugins.sol
Original file line number Diff line number Diff line change
@@ -1,94 +1,92 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity ^0.8.18;

import {BasePluginWithEventMetadata, PluginMetadata} from "./Base.sol";
import {ISafe} from "@safe-global/safe-core-protocol/contracts/interfaces/Accounts.sol";
import {ISafeProtocolPlugin} from "@safe-global/safe-core-protocol/contracts/interfaces/Integrations.sol";
import {ISafeProtocolManager} from "@safe-global/safe-core-protocol/contracts/interfaces/Manager.sol";
import {SafeTransaction, SafeRootAccess} from "@safe-global/safe-core-protocol/contracts/DataTypes.sol";
import {SafeTransaction, SafeProtocolAction} from "@safe-global/safe-core-protocol/contracts/DataTypes.sol";
import {_getFeeCollectorRelayContext, _getFeeTokenRelayContext, _getFeeRelayContext} from "@gelatonetwork/relay-context/contracts/GelatoRelayContext.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) // Plugin Metadata
);
}
address constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

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);
}
}
contract RelayPlugin is BasePluginWithEventMetadata {
event MaxFeeUpdated(address indexed account, address indexed feeToken, uint256 maxFee);

abstract contract BasePlugin is ISafeProtocolPlugin, MetadataProvider {
using PluginMetadataOps for PluginMetadata;
error FeeTooHigh(address feeToken, uint256 fee);
error FeePaymentFailure(bytes data);
error UntrustedOrigin(address origin);
error RelayExecutionFailure(bytes data);
error InvalidRelayMethod(bytes4 data);

string public name;
string public version;
bool public immutable requiresRootAccess;
bytes32 public immutable metadataHash;
bytes private encodedMetadata;
address public immutable trustedOrigin;
bytes4 public immutable relayMethod;

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);
}
// Account => token => maxFee
mapping(address => mapping(address => uint256)) public maxFeePerToken;

// TODO: Legacy version that should be removed
function metaProvider() external view override returns (uint256 providerType, bytes memory location) {
return metadataProvider();
constructor(
address _trustedOrigin,
bytes4 _relayMethod
)
BasePluginWithEventMetadata(
PluginMetadata({
name: "Relay Plugin",
version: "1.0.0",
requiresRootAccess: false,
iconUrl: "",
appUrl: "https://5afe.github.io/safe-core-protocol-demo/#/relay/${plugin}"
})
)
{
trustedOrigin = _trustedOrigin;
relayMethod = _relayMethod;
}

function metadataProvider() public view returns (uint256 providerType, bytes memory location) {
providerType = uint256(MetadataProviderType.Contract);
location = abi.encode(address(this));
function setMaxFeePerToken(address token, uint256 maxFee) external {
maxFeePerToken[msg.sender][token] = maxFee;
emit MaxFeeUpdated(msg.sender, token, maxFee);
}

function retrieveMetadata(bytes32 _metadataHash) external view returns (bytes memory metadata) {
require(metadataHash == _metadataHash, "Cannot retrieve metadata");
return encodedMetadata;
function payFee(ISafeProtocolManager manager, ISafe safe, uint256 nonce) internal {
address feeCollector = _getFeeCollectorRelayContext();
address feeToken = _getFeeTokenRelayContext();
uint256 fee = _getFeeRelayContext();
SafeProtocolAction[] memory actions = new SafeProtocolAction[](1);
uint256 maxFee = maxFeePerToken[address(safe)][feeToken];
if (fee > maxFee) revert FeeTooHigh(feeToken, fee);
if (feeToken == NATIVE_TOKEN || feeToken == address(0)) {
// If the native token is used for fee payment, then we directly send the fees to the fee collector
actions[0].to = payable(feeCollector);
actions[0].value = fee;
actions[0].data = "";
} else {
// If a ERC20 token is used for fee payment, then we trigger a token transfer on the token for the fee to the fee collector
actions[0].to = payable(feeToken);
actions[0].value = 0;
actions[0].data = abi.encodeWithSignature("transfer(address,uint256)", feeCollector, fee);
}
// Note: Metadata format has not been proposed
SafeTransaction memory safeTx = SafeTransaction({actions: actions, nonce: nonce, metadataHash: bytes32(0)});
try manager.executeTransaction(safe, safeTx) returns (bytes[] memory) {} catch (bytes memory reason) {
revert FeePaymentFailure(reason);
}
}
}

contract SamplePlugin is BasePlugin {
ISafeProtocolManager public immutable manager;
function relayCall(address relayTarget, bytes calldata relayData) internal {
// Check relay data to avoid that module can be abused for arbitrary interactions
if (bytes4(relayData[:4]) != relayMethod) revert InvalidRelayMethod(bytes4(relayData[:4]));

constructor(
ISafeProtocolManager _manager
) BasePlugin(PluginMetadata({name: "Sample Plugin", version: "1.0.0", requiresRootAccess: false, iconUrl: "", appUrl: ""})) {
manager = _manager;
// Perform relay call and require success to avoid that user paid for failed transaction
(bool success, bytes memory data) = relayTarget.call(relayData);
if (!success) revert RelayExecutionFailure(data);
}

function executeFromPlugin(ISafe safe, SafeTransaction calldata safetx) external returns (bytes[] memory data) {
(data) = manager.executeTransaction(safe, safetx);
function executeFromPlugin(ISafeProtocolManager manager, ISafe safe, bytes calldata data) external {
if (trustedOrigin != address(0) && msg.sender != trustedOrigin) revert UntrustedOrigin(msg.sender);

relayCall(address(safe), data);
// We use the hash of the tx to relay has a nonce as this is unique
uint256 nonce = uint256(keccak256(abi.encode(this, manager, safe, data, block.number)));
payFee(manager, safe, nonce);
}
}
3 changes: 2 additions & 1 deletion contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,16 @@
"prepack": "yarn build"
},
"devDependencies": {
"@gelatonetwork/relay-context": "^2.1.0",
"@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",
"@safe-global/safe-core-protocol": "^0.1.0-alpha.4",
"@safe-global/safe-singleton-factory": "^1.0.14",
"@safe-global/safe-core-protocol": "^0.1.0-alpha.3",
"@typechain/ethers-v6": "^0.4.0",
"@typechain/hardhat": "^8.0.0",
"@types/chai": "^4.2.0",
Expand Down
16 changes: 10 additions & 6 deletions contracts/src/deploy/deploy_plugin.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { DeployFunction } from "hardhat-deploy/types";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { getProtocolManagerAddress } from "../utils/protocol";
import { getGelatoAddress } from "@gelatonetwork/relay-context";
import { ZeroAddress } from "ethers";

const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
const { deployments, getNamedAccounts } = hre;
const { deployer } = await getNamedAccounts();
const { deploy } = deployments;

const manager = await getProtocolManagerAddress(hre)
console.log({manager})

await deploy("SamplePlugin", {
// execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)
// https://www.4byte.directory/signatures/?bytes4_signature=0x6a761202
const relayMethod = "0x6a761202"
// We don't use a trusted origin right now to make it easier to test.
// For production networks it is strongly recommended to set one to avoid potential fee extraction.
const trustedOrigin = ZeroAddress // hre.network.name === "hardhat" ? ZeroAddress : getGelatoAddress(hre.network.name)
await deploy("RelayPlugin", {
from: deployer,
args: [manager],
args: [trustedOrigin, relayMethod],
log: true,
deterministicDeployment: true,
});
Expand Down
Loading

0 comments on commit 630c5d0

Please sign in to comment.