Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions contracts/contracts/StoplossPlugin.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity ^0.8.18;
import {ISafe} from "@safe-global/safe-core-protocol/contracts/interfaces/Accounts.sol";
import {ISafeProtocolManager} from "@safe-global/safe-core-protocol/contracts/interfaces/Manager.sol";
import {SafeTransaction, SafeProtocolAction} from "@safe-global/safe-core-protocol/contracts/DataTypes.sol";
import {BasePluginWithEventMetadata, PluginMetadata} from "./Base.sol";
import "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol";
import "@uniswap/v3-periphery/contracts/libraries/TransferHelper.sol";

/// @title A safe plugin to implement stopLoss on a certain token in safe
/// @author https://github.com/kalrashivam
/// @notice Creates an event which can be used to create
/// a bot to track price and then trigger safe transaction through plugin.
/// @dev The plugin is made based on the safe-core-demo-template
contract StoplossPlugin is BasePluginWithEventMetadata {

struct StopLoss {
uint256 stopLossLimit;
address tokenAddress;
address contractAddress;
}

// safe account => stopLoss
mapping(address => StopLoss) public Bots;

/// @notice Listen for this event to create your stop loss bot
/// @param safeAccount safe account address.
/// @param tokenAddress token address to apply stoploss on.
/// @param contractAddress address of the uniswap/cowswap pair to perform transaction on.
/// @param stopLossLimit the limit after which the swap should be triggered.
event AddStopLoss(address indexed safeAccount, address indexed tokenAddress, address contractAddress, uint256 stopLossLimit);
// Listen for this event to remove the bot
event RemoveStopLoss(address indexed safeAccount, address indexed tokenAddress);

// raised when the swap on uniswap fails, check for this in the bot.
error SwapFailure(bytes data);

constructor()
BasePluginWithEventMetadata(
PluginMetadata({name: "Stoploss Plugin", version: "1.0.0", requiresRootAccess: false, iconUrl: "", appUrl: ""})
)
{}

function addStopLoss(uint256 _stopLossLimit, address _tokenAddress, address _contractAddress) external {
Bots[msg.sender] = StopLoss(_stopLossLimit, _tokenAddress, _contractAddress);
emit AddStopLoss(msg.sender, _tokenAddress, _contractAddress, _stopLossLimit);
}

/// @notice executes the transaction from the bot to swap or unstake,
/// checks if the bot is valid by checking the signature
/// @dev Can further be extened and add role access modifier by
/// zodiac (https://github.com/gnosis/zodiac-modifier-roles)
/// to check the functions that can be called from this on a given contract address
/// @param manager manager address
/// @param safe account
/// @param _hashedMessage hassed message to check the validity of the bot.
/// @param _safeSwapTx safe transaction to swap the token for a stable coin or
/// unstake the tokens from a platform.
function executeFromPlugin(
ISafeProtocolManager manager,
ISafe safe,
SafeTransaction calldata _safeSwapTx,
bytes32 _hashedMessage,
bytes32 _r,
bytes32 _s,
uint8 _v
) external {
address safeAddress = address(safe);
address signer = verifyMessage(_hashedMessage, _v, _r, _s);
require(signer == safeAddress, "ERROR_UNVERIFIED_BOT");

StopLoss memory stopLossBot = Bots[safeAddress];

try manager.executeTransaction(safe, _safeSwapTx) returns (bytes[] memory) {
delete Bots[safeAddress];
emit RemoveStopLoss(safeAddress, stopLossBot.tokenAddress);
} catch (bytes memory reason) {
revert SwapFailure(reason);
}
}

function verifyMessage(bytes32 _hashedMessage, uint8 _v, bytes32 _r, bytes32 _s) public pure returns (address) {
bytes memory prefix = "\x19Ethereum Signed Message:\n32";
bytes32 prefixedHashMessage = keccak256(abi.encodePacked(prefix, _hashedMessage));
address signer = ecrecover(prefixedHashMessage, _v, _r, _s);
return signer;
}
}
6 changes: 5 additions & 1 deletion contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,9 @@
"scripts",
"test",
"artifacts"
]
],
"dependencies": {
"@uniswap/v3-core": "^1.0.1",
"@uniswap/v3-periphery": "^1.4.4"
}
}
38 changes: 22 additions & 16 deletions contracts/src/deploy/deploy_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,34 @@ const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
// 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: [trustedOrigin, relayMethod],
log: true,
deterministicDeployment: true,
});
// await deploy("RelayPlugin", {
// from: deployer,
// args: [trustedOrigin, relayMethod],
// log: true,
// deterministicDeployment: true,
// });

await deploy("WhitelistPlugin", {
from: deployer,
args: [],
log: true,
deterministicDeployment: true,
});
// await deploy("WhitelistPlugin", {
// from: deployer,
// args: [],
// log: true,
// deterministicDeployment: true,
// });

await deploy("RecoveryWithDelayPlugin", {
// await deploy("RecoveryWithDelayPlugin", {
// from: deployer,
// args: [recoverer],
// log: true,
// deterministicDeployment: true,
// });

await deploy("StoplossPlugin", {
from: deployer,
args: [recoverer],
args: [],
log: true,
deterministicDeployment: true,
});

};

deploy.tags = ["plugins"];
export default deploy;
export default deploy;
69 changes: 69 additions & 0 deletions contracts/test/StoplossPlugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import hre, { deployments, ethers } from "hardhat";
import { expect } from "chai";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
import { getStopLossPlugin, getInstance } from "./utils/contracts";
import { loadPluginMetadata } from "../src/utils/metadata";
import { buildSingleTx } from "../src/utils/builder";
import { ISafeProtocolManager__factory, MockContract } from "../typechain-types";
import { MaxUint256, ZeroHash } from "ethers";
import { getProtocolManagerAddress } from "../src/utils/protocol";

describe("StopLossPlugin", async () => {
// let user1: SignerWithAddress, user2: SignerWithAddress;

before(async () => {
// [user1, user2] = await hre.ethers.getSigners();
});

const setup = deployments.createFixture(async ({ deployments }) => {
await deployments.fixture();
const manager = await ethers.getContractAt("MockContract", await getProtocolManagerAddress(hre));

const account = await (await ethers.getContractFactory("ExecutableMockContract")).deploy();
const plugin = await getStopLossPlugin(hre);
return {
account,
plugin,
manager,
};
});

it("should be initialized correctly", async () => {
const { plugin } = await setup();
expect(await plugin.name()).to.be.eq("Stoploss Plugin");
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(hre, plugin)).to.be.deep.eq({
name: "Stoploss Plugin",
version: "1.0.0",
requiresRootAccess: false,
iconUrl: "",
appUrl: "",
});
});

it("should emit AddStopLoss when stoploss is added", async () => {
const { plugin, account } = await setup();
// const swapRouter2Uniswap = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45";
const data = plugin.interface.encodeFunctionData("addStopLoss", [ethers.parseUnits("99", 6), "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"]);
expect(await account.executeCallViaMock(await plugin.getAddress(), 0, data, MaxUint256))
.to.emit(plugin, "AddStopLoss")
});

it("Should allow to execute transaction to for verified bot", async () => {
const { plugin, account, manager } = await setup();
const safeAddress = await account.getAddress();
// AAVE ADDRESS = "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9"
// UNISWAP ROUTER ADDRESS = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"
const data = plugin.interface.encodeFunctionData("addStopLoss", [ethers.parseUnits("99", 6), "0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9", "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"]);
await account.executeCallViaMock(await plugin.getAddress(), 0, data, MaxUint256);
// Required for isOwner(address) to return true
account.givenMethodReturnBool("0x2f54bf6e", true);
// TODO: test if a normal transaction works on safe.

});
});
8 changes: 8 additions & 0 deletions contracts/test/exampleBot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import hre, { deployments, ethers } from "hardhat";
import { expect } from "chai";
import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
import { getRelayPlugin } from "./utils/contracts";
import { loadPluginMetadata } from "../src/utils/metadata";
import { getProtocolManagerAddress } from "../src/utils/protocol";
import { Interface, MaxUint256, ZeroAddress, ZeroHash, getAddress, keccak256 } from "ethers";
import { ISafeProtocolManager__factory } from "../typechain-types";
21 changes: 21 additions & 0 deletions contracts/test/testBot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Interface } from "@ethersproject/abi";
import { Web3Function, Web3FunctionEventContext } from "@gelatonetwork/web3-functions-sdk";

const NFT_ABI = [
"event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)",
];

Web3Function.onRun(async (context: Web3FunctionEventContext) => {
// Get event log from Web3FunctionEventContext
const { log } = context;

// Parse your event from ABI
const nft = new Interface(NFT_ABI);
const event = nft.parseLog(log);

// Handle event data
const { from, to, tokenId } = event.args;
console.log(`Transfer of NFT #${tokenId} from ${from} to ${to} detected`);

return { canExec: false, message: `Event processed ${log.transactionHash}` };
});
2 changes: 2 additions & 0 deletions contracts/test/utils/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
RelayPlugin,
TestSafeProtocolRegistryUnrestricted,
WhitelistPlugin,
StoplossPlugin
} from "../../typechain-types";
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { getProtocolRegistryAddress } from "../../src/utils/protocol";
Expand All @@ -28,5 +29,6 @@ export const getRelayPlugin = (hre: HardhatRuntimeEnvironment) => getSingleton<R
export const getRegistry = async (hre: HardhatRuntimeEnvironment) =>
getInstance<TestSafeProtocolRegistryUnrestricted>(hre, "TestSafeProtocolRegistryUnrestricted", await getProtocolRegistryAddress(hre));
export const getWhiteListPlugin = async (hre: HardhatRuntimeEnvironment) => getSingleton<WhitelistPlugin>(hre, "WhitelistPlugin");
export const getStopLossPlugin = async (hre: HardhatRuntimeEnvironment) => getSingleton<StoplossPlugin>(hre, "StoplossPlugin");
export const getRecoveryWithDelayPlugin = async (hre: HardhatRuntimeEnvironment) =>
getSingleton<RecoveryWithDelayPlugin>(hre, "RecoveryWithDelayPlugin");
36 changes: 36 additions & 0 deletions contracts/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,11 @@
"@nomicfoundation/solidity-analyzer-win32-ia32-msvc" "0.1.1"
"@nomicfoundation/solidity-analyzer-win32-x64-msvc" "0.1.1"

"@openzeppelin/[email protected]":
version "3.4.2-solc-0.7"
resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-3.4.2-solc-0.7.tgz#38f4dbab672631034076ccdf2f3201fab1726635"
integrity sha512-W6QmqgkADuFcTLzHL8vVoNBtkwjvQRpYIAom7KiUNoLKghyx3FgH0GBjt8NRvigV1ZmMOBllvE1By1C+bi8WpA==

"@openzeppelin/[email protected]":
version "4.8.0"
resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.8.0.tgz#6854c37df205dd2c056bdfa1b853f5d732109109"
Expand Down Expand Up @@ -1194,6 +1199,32 @@
"@typescript-eslint/types" "6.6.0"
eslint-visitor-keys "^3.4.1"

"@uniswap/lib@^4.0.1-alpha":
version "4.0.1-alpha"
resolved "https://registry.yarnpkg.com/@uniswap/lib/-/lib-4.0.1-alpha.tgz#2881008e55f075344675b3bca93f020b028fbd02"
integrity sha512-f6UIliwBbRsgVLxIaBANF6w09tYqc6Y/qXdsrbEmXHyFA7ILiKrIwRFXe1yOg8M3cksgVsO9N7yuL2DdCGQKBA==

"@uniswap/v2-core@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@uniswap/v2-core/-/v2-core-1.0.1.tgz#af8f508bf183204779938969e2e54043e147d425"
integrity sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q==

"@uniswap/v3-core@^1.0.0", "@uniswap/v3-core@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@uniswap/v3-core/-/v3-core-1.0.1.tgz#b6d2bdc6ba3c3fbd610bdc502395d86cd35264a0"
integrity sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ==

"@uniswap/v3-periphery@^1.4.4":
version "1.4.4"
resolved "https://registry.yarnpkg.com/@uniswap/v3-periphery/-/v3-periphery-1.4.4.tgz#d2756c23b69718173c5874f37fd4ad57d2f021b7"
integrity sha512-S4+m+wh8HbWSO3DKk4LwUCPZJTpCugIsHrWR86m/OrUyvSqGDTXKFfc2sMuGXCZrD1ZqO3rhQsKgdWg3Hbb2Kw==
dependencies:
"@openzeppelin/contracts" "3.4.2-solc-0.7"
"@uniswap/lib" "^4.0.1-alpha"
"@uniswap/v2-core" "^1.0.1"
"@uniswap/v3-core" "^1.0.0"
base64-sol "1.0.1"

abbrev@1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
Expand Down Expand Up @@ -1508,6 +1539,11 @@ base64-js@^1.3.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==

[email protected]:
version "1.0.1"
resolved "https://registry.yarnpkg.com/base64-sol/-/base64-sol-1.0.1.tgz#91317aa341f0bc763811783c5729f1c2574600f6"
integrity sha512-ld3cCNMeXt4uJXmLZBHFGMvVpK9KsLVEhPpFRXnvSVAqABKbuNZg/+dsq3NuM+wxFLb/UrVkz7m1ciWmkMfTbg==

bcrypt-pbkdf@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
Expand Down