diff --git a/contracts/contracts/StoplossPlugin.sol b/contracts/contracts/StoplossPlugin.sol new file mode 100644 index 0000000..1c753a0 --- /dev/null +++ b/contracts/contracts/StoplossPlugin.sol @@ -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; + } +} diff --git a/contracts/package.json b/contracts/package.json index 4fd6614..facdb6f 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -69,5 +69,9 @@ "scripts", "test", "artifacts" - ] + ], + "dependencies": { + "@uniswap/v3-core": "^1.0.1", + "@uniswap/v3-periphery": "^1.4.4" + } } diff --git a/contracts/src/deploy/deploy_plugin.ts b/contracts/src/deploy/deploy_plugin.ts index e162085..8ebe12b 100644 --- a/contracts/src/deploy/deploy_plugin.ts +++ b/contracts/src/deploy/deploy_plugin.ts @@ -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; \ No newline at end of file +export default deploy; diff --git a/contracts/test/StoplossPlugin.spec.ts b/contracts/test/StoplossPlugin.spec.ts new file mode 100644 index 0000000..43399e1 --- /dev/null +++ b/contracts/test/StoplossPlugin.spec.ts @@ -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. + + }); +}); diff --git a/contracts/test/exampleBot.ts b/contracts/test/exampleBot.ts new file mode 100644 index 0000000..f8e40f1 --- /dev/null +++ b/contracts/test/exampleBot.ts @@ -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"; \ No newline at end of file diff --git a/contracts/test/testBot.ts b/contracts/test/testBot.ts new file mode 100644 index 0000000..4d4e45b --- /dev/null +++ b/contracts/test/testBot.ts @@ -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}` }; +}); \ No newline at end of file diff --git a/contracts/test/utils/contracts.ts b/contracts/test/utils/contracts.ts index 3efa608..36b0783 100644 --- a/contracts/test/utils/contracts.ts +++ b/contracts/test/utils/contracts.ts @@ -5,6 +5,7 @@ import { RelayPlugin, TestSafeProtocolRegistryUnrestricted, WhitelistPlugin, + StoplossPlugin } from "../../typechain-types"; import { HardhatRuntimeEnvironment } from "hardhat/types"; import { getProtocolRegistryAddress } from "../../src/utils/protocol"; @@ -28,5 +29,6 @@ export const getRelayPlugin = (hre: HardhatRuntimeEnvironment) => getSingleton getInstance(hre, "TestSafeProtocolRegistryUnrestricted", await getProtocolRegistryAddress(hre)); export const getWhiteListPlugin = async (hre: HardhatRuntimeEnvironment) => getSingleton(hre, "WhitelistPlugin"); +export const getStopLossPlugin = async (hre: HardhatRuntimeEnvironment) => getSingleton(hre, "StoplossPlugin"); export const getRecoveryWithDelayPlugin = async (hre: HardhatRuntimeEnvironment) => getSingleton(hre, "RecoveryWithDelayPlugin"); diff --git a/contracts/yarn.lock b/contracts/yarn.lock index ff252c9..d8137cf 100644 --- a/contracts/yarn.lock +++ b/contracts/yarn.lock @@ -786,6 +786,11 @@ "@nomicfoundation/solidity-analyzer-win32-ia32-msvc" "0.1.1" "@nomicfoundation/solidity-analyzer-win32-x64-msvc" "0.1.1" +"@openzeppelin/contracts@3.4.2-solc-0.7": + 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/contracts@4.8.0": version "4.8.0" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.8.0.tgz#6854c37df205dd2c056bdfa1b853f5d732109109" @@ -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" @@ -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== +base64-sol@1.0.1: + 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"