diff --git a/contracts/contracts/Imports.sol b/contracts/contracts/Imports.sol index 259afa1..b669970 100644 --- a/contracts/contracts/Imports.sol +++ b/contracts/contracts/Imports.sol @@ -8,7 +8,12 @@ import {TestSafeProtocolRegistryUnrestricted} from "@safe-global/safe-core-proto // ExecutableMockContract for testing contract ExecutableMockContract is MockContract { - function executeCallViaMock(address payable to, uint256 value, bytes memory data, uint256 gas) external returns (bool success, bytes memory response) { - (success, response) = to.call{ value: value, gas: gas}(data); + function executeCallViaMock( + address payable to, + uint256 value, + bytes memory data, + uint256 gas + ) external returns (bool success, bytes memory response) { + (success, response) = to.call{value: value, gas: gas}(data); } -} \ No newline at end of file +} diff --git a/contracts/test/RelayPlugin.spec.ts b/contracts/test/RelayPlugin.spec.ts index b4fc9d3..b94d5f2 100644 --- a/contracts/test/RelayPlugin.spec.ts +++ b/contracts/test/RelayPlugin.spec.ts @@ -19,21 +19,23 @@ describe("RelayPlugin", async () => { 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 account = await (await ethers.getContractFactory("ExecutableMockContract")).deploy(); const plugin = await getRelayPlugin(hre); return { account, plugin, - manager + manager, }; }); const addRelayContext = (data: string, fee: string, feeToken: string = ZeroAddress, decimals: number = 18) => { - return data + - relayer.address.slice(2) + - getAddress(feeToken).slice(2) + + return ( + data + + relayer.address.slice(2) + + getAddress(feeToken).slice(2) + abiEncoder.encode(["uint256"], [ethers.parseUnits(fee, decimals)]).slice(2) - } + ); + }; it("should be inititalized correctly", async () => { const { plugin } = await setup(); @@ -56,96 +58,112 @@ describe("RelayPlugin", async () => { it("should revert if invalid method selector is used", async () => { const { account, plugin, manager } = await setup(); await expect(plugin.executeFromPlugin(await manager.getAddress(), await account.getAddress(), "0xbaddad42")) - .to.be.revertedWithCustomError(plugin, "InvalidRelayMethod").withArgs("0xbaddad42"); + .to.be.revertedWithCustomError(plugin, "InvalidRelayMethod") + .withArgs("0xbaddad42"); }); it("should revert if target contract reverts", async () => { const { account, plugin, manager } = await setup(); - await account.givenMethodRevert("0x6a761202") + await account.givenMethodRevert("0x6a761202"); await expect(plugin.executeFromPlugin(await manager.getAddress(), await account.getAddress(), "0x6a761202")) - .to.be.revertedWithCustomError(plugin, "RelayExecutionFailure").withArgs("0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000"); + .to.be.revertedWithCustomError(plugin, "RelayExecutionFailure") + .withArgs( + "0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000", + ); }); it("should revert if fee is too high", async () => { const { account, plugin, manager } = await setup(); - const tx = (await plugin.executeFromPlugin.populateTransaction(await manager.getAddress(), account, "0x6a761202")).data - await expect(relayer.sendTransaction({to: plugin, data: addRelayContext(tx, "0.01")})) - .to.be.revertedWithCustomError(plugin, "FeeTooHigh(address,uint256)").withArgs(ZeroAddress, ethers.parseUnits("0.01", 18)); + const tx = (await plugin.executeFromPlugin.populateTransaction(await manager.getAddress(), account, "0x6a761202")).data; + await expect(relayer.sendTransaction({ to: plugin, data: addRelayContext(tx, "0.01") })) + .to.be.revertedWithCustomError(plugin, "FeeTooHigh(address,uint256)") + .withArgs(ZeroAddress, ethers.parseUnits("0.01", 18)); }); it("should revert if fee payment fails", async () => { const { account, plugin, manager } = await setup(); - const setupTx = await plugin.setMaxFeePerToken.populateTransaction(ZeroAddress, ethers.parseUnits("0.01", 18)) - await account.executeCallViaMock(setupTx.to, setupTx.value || 0, setupTx.data, MaxUint256) - expect(await plugin.maxFeePerToken(account, ZeroAddress)).to.be.eq(ethers.parseUnits("0.01", 18)) - await manager.givenAnyRevert() - const tx = (await plugin.executeFromPlugin.populateTransaction(await manager.getAddress(), account, "0x6a761202")).data - await expect(relayer.sendTransaction({to: plugin, data: addRelayContext(tx, "0.01")})) - .to.be.revertedWithCustomError(plugin, "FeePaymentFailure").withArgs("0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000"); + const setupTx = await plugin.setMaxFeePerToken.populateTransaction(ZeroAddress, ethers.parseUnits("0.01", 18)); + await account.executeCallViaMock(setupTx.to, setupTx.value || 0, setupTx.data, MaxUint256); + expect(await plugin.maxFeePerToken(account, ZeroAddress)).to.be.eq(ethers.parseUnits("0.01", 18)); + await manager.givenAnyRevert(); + const tx = (await plugin.executeFromPlugin.populateTransaction(await manager.getAddress(), account, "0x6a761202")).data; + await expect(relayer.sendTransaction({ to: plugin, data: addRelayContext(tx, "0.01") })) + .to.be.revertedWithCustomError(plugin, "FeePaymentFailure") + .withArgs( + "0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000", + ); }); it("should be a success with native token", async () => { const { account, plugin, manager } = await setup(); - const setupTx = await plugin.setMaxFeePerToken.populateTransaction(ZeroAddress, ethers.parseUnits("0.01", 18)) - await account.executeCallViaMock(setupTx.to, setupTx.value || 0, setupTx.data, MaxUint256) - expect(await plugin.maxFeePerToken(account, ZeroAddress)).to.be.eq(ethers.parseUnits("0.01", 18)) - const tx = (await plugin.executeFromPlugin.populateTransaction(await manager.getAddress(), account, "0x6a761202")).data - await expect(relayer.sendTransaction({to: plugin, data: addRelayContext(tx, "0.01")})).to.not.be.reverted; - expect(await account.invocationCount()).to.be.eq(1) - expect(await account.invocationCountForCalldata("0x6a761202")).to.be.eq(1) + const setupTx = await plugin.setMaxFeePerToken.populateTransaction(ZeroAddress, ethers.parseUnits("0.01", 18)); + await account.executeCallViaMock(setupTx.to, setupTx.value || 0, setupTx.data, MaxUint256); + expect(await plugin.maxFeePerToken(account, ZeroAddress)).to.be.eq(ethers.parseUnits("0.01", 18)); + const tx = (await plugin.executeFromPlugin.populateTransaction(await manager.getAddress(), account, "0x6a761202")).data; + await expect(relayer.sendTransaction({ to: plugin, data: addRelayContext(tx, "0.01") })).to.not.be.reverted; + expect(await account.invocationCount()).to.be.eq(1); + expect(await account.invocationCountForCalldata("0x6a761202")).to.be.eq(1); - const nonce = keccak256(abiEncoder.encode( - ["address", "address", "address", "bytes"], - [await plugin.getAddress(), await manager.getAddress(), await account.getAddress(), "0x6a761202"] - )) - const managerInterface = ISafeProtocolManager__factory.createInterface() + const nonce = keccak256( + abiEncoder.encode( + ["address", "address", "address", "bytes"], + [await plugin.getAddress(), await manager.getAddress(), await account.getAddress(), "0x6a761202"], + ), + ); + const managerInterface = ISafeProtocolManager__factory.createInterface(); const expectedData = managerInterface.encodeFunctionData("executeTransaction", [ await account.getAddress(), { nonce, metadataHash: ZeroHash, - actions: [{ - to: relayer.address, - value: ethers.parseUnits("0.01", 18), - data: "0x" - }] - } - ]) - expect(await manager.invocationCount()).to.be.eq(1) - expect(await manager.invocationCountForMethod(expectedData)).to.be.eq(1) + actions: [ + { + to: relayer.address, + value: ethers.parseUnits("0.01", 18), + data: "0x", + }, + ], + }, + ]); + expect(await manager.invocationCount()).to.be.eq(1); + expect(await manager.invocationCountForMethod(expectedData)).to.be.eq(1); }); it("should be a success with fee token", async () => { const { account, plugin, manager } = await setup(); - const maxFee = ethers.parseUnits("0.02", 18) - const setupTx = await plugin.setMaxFeePerToken.populateTransaction(TOKEN_ADDRESS, maxFee) - await account.executeCallViaMock(setupTx.to, setupTx.value || 0, setupTx.data, MaxUint256) - expect(await plugin.maxFeePerToken(account, TOKEN_ADDRESS)).to.be.eq(maxFee) - const tx = (await plugin.executeFromPlugin.populateTransaction(await manager.getAddress(), account, "0x6a761202")).data - await expect(relayer.sendTransaction({to: plugin, data: addRelayContext(tx, "0.02", TOKEN_ADDRESS)})).to.not.be.reverted; - expect(await account.invocationCount()).to.be.eq(1) - expect(await account.invocationCountForCalldata("0x6a761202")).to.be.eq(1) + const maxFee = ethers.parseUnits("0.02", 18); + const setupTx = await plugin.setMaxFeePerToken.populateTransaction(TOKEN_ADDRESS, maxFee); + await account.executeCallViaMock(setupTx.to, setupTx.value || 0, setupTx.data, MaxUint256); + expect(await plugin.maxFeePerToken(account, TOKEN_ADDRESS)).to.be.eq(maxFee); + const tx = (await plugin.executeFromPlugin.populateTransaction(await manager.getAddress(), account, "0x6a761202")).data; + await expect(relayer.sendTransaction({ to: plugin, data: addRelayContext(tx, "0.02", TOKEN_ADDRESS) })).to.not.be.reverted; + expect(await account.invocationCount()).to.be.eq(1); + expect(await account.invocationCountForCalldata("0x6a761202")).to.be.eq(1); - const tokenInterface = new Interface(["function transfer(address,uint256)"]) - const encodedTransfer = tokenInterface.encodeFunctionData("transfer", [relayer.address, maxFee]) - const managerInterface = ISafeProtocolManager__factory.createInterface() - const nonce = keccak256(abiEncoder.encode( - ["address", "address", "address", "bytes"], - [await plugin.getAddress(), await manager.getAddress(), await account.getAddress(), "0x6a761202"] - )) + const tokenInterface = new Interface(["function transfer(address,uint256)"]); + const encodedTransfer = tokenInterface.encodeFunctionData("transfer", [relayer.address, maxFee]); + const managerInterface = ISafeProtocolManager__factory.createInterface(); + const nonce = keccak256( + abiEncoder.encode( + ["address", "address", "address", "bytes"], + [await plugin.getAddress(), await manager.getAddress(), await account.getAddress(), "0x6a761202"], + ), + ); const expectedData = managerInterface.encodeFunctionData("executeTransaction", [ await account.getAddress(), { nonce, metadataHash: ZeroHash, - actions: [{ - to: TOKEN_ADDRESS, - value: 0, - data: encodedTransfer - }] - } - ]) - expect(await manager.invocationCount()).to.be.eq(1) - expect(await manager.invocationCountForMethod(expectedData)).to.be.eq(1) + actions: [ + { + to: TOKEN_ADDRESS, + value: 0, + data: encodedTransfer, + }, + ], + }, + ]); + expect(await manager.invocationCount()).to.be.eq(1); + expect(await manager.invocationCountForMethod(expectedData)).to.be.eq(1); }); }); diff --git a/web/public/favicon.ico b/web/public/favicon.ico index a11777c..c5978fc 100644 Binary files a/web/public/favicon.ico and b/web/public/favicon.ico differ diff --git a/web/src/routes/plugins/Plugin.tsx b/web/src/routes/plugins/Plugin.tsx index e367af9..90083fc 100644 --- a/web/src/routes/plugins/Plugin.tsx +++ b/web/src/routes/plugins/Plugin.tsx @@ -6,7 +6,7 @@ import "./Plugins.css"; import { PluginMetadata } from "../../logic/metadata"; import { PluginDetails, disablePlugin, enablePlugin, loadPluginDetails } from "../../logic/plugins"; import { openSafeApp } from "../../logic/safeapp"; -import { Button } from '@mui/material'; +import { Button, Card, Tooltip } from '@mui/material'; type PluginMetaProps = { metadata: PluginMetadata; @@ -50,12 +50,12 @@ export const Plugin: FunctionComponent = ({ address }) => { } }, [details]) return ( -
- + +
{!details ? "Loading Metadata" : }
{details?.metadata?.requiresRootAccess == true && } - {(details?.metadata?.appUrl?.length ?? 0) > 0 && openSafeApp(details?.metadata?.appUrl!!)} />} - {details?.enabled != undefined && } -
+ {details?.enabled != undefined && } + {(details?.metadata?.appUrl?.length ?? 0) > 0 && openSafeApp(details?.metadata?.appUrl!!)} />} + ); }; diff --git a/web/src/routes/plugins/PluginList.tsx b/web/src/routes/plugins/PluginList.tsx index 00d650c..0adfd93 100644 --- a/web/src/routes/plugins/PluginList.tsx +++ b/web/src/routes/plugins/PluginList.tsx @@ -1,24 +1,24 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import logo from '../../logo.svg'; import './Plugins.css'; import { loadPlugins } from '../../logic/plugins'; import { Plugin } from './Plugin'; -import { Checkbox, FormControlLabel } from '@mui/material'; +import { Button, Checkbox, FormControlLabel } from '@mui/material'; function PluginList() { const [showFlagged, setFilterFlagged] = useState(false); const [plugins, setPlugins] = useState([]); + const fetchData = useCallback(async () => { + try { + setPlugins([]) + setPlugins(await loadPlugins(!showFlagged)) + } catch(e) { + console.warn(e) + } + }, [showFlagged]) useEffect(() => { - const fetchData = async() => { - try { - setPlugins([]) - setPlugins(await loadPlugins(!showFlagged)) - } catch(e) { - console.warn(e) - } - } fetchData(); - }, [showFlagged]) + }, [fetchData]) return (
@@ -27,9 +27,12 @@ function PluginList() { Safe{Core} Protocol Demo

- setFilterFlagged(checked) } inputProps={{ 'aria-label': 'controlled' }} /> - } label="Show Flagged PlugIns" /> + + setFilterFlagged(checked) } inputProps={{ 'aria-label': 'controlled' }} /> + } label="Show Flagged PlugIns" /> + +
{plugins.map((plugin) => )}
diff --git a/web/src/routes/plugins/Plugins.css b/web/src/routes/plugins/Plugins.css index 75cee31..80a1ee2 100644 --- a/web/src/routes/plugins/Plugins.css +++ b/web/src/routes/plugins/Plugins.css @@ -12,9 +12,11 @@ } .Plugin { - width: 100%; + width: calc(100% - 16px); + margin: 8px; + padding: 8px; text-align: start; - font-size: 20pt; + font-size: 16pt; color: white; display: flex; flex-direction: row; @@ -40,7 +42,7 @@ } .Plugins-list { - width: 600pt; + width: min(calc(100% - 48px), 600px); display: flex; flex-direction: column; align-items: start; @@ -51,4 +53,13 @@ .AddressIcon { border-radius: 50%; margin-right: 10px; +} + +.Plugin-link { + cursor: pointer; + margin-left: 8px; +} + +.Plugin-toggle { + margin-left: 8px; } \ No newline at end of file diff --git a/web/src/routes/samples/relay/NextTxs.tsx b/web/src/routes/samples/relay/NextTxs.tsx index 0588b77..daea0cf 100644 --- a/web/src/routes/samples/relay/NextTxs.tsx +++ b/web/src/routes/samples/relay/NextTxs.tsx @@ -1,6 +1,6 @@ -import { FunctionComponent, useEffect, useState } from "react"; +import { FunctionComponent, useCallback, useEffect, useState } from "react"; import "./Relay.css"; -import { CircularProgress, Button, Typography } from '@mui/material'; +import { CircularProgress, Button, Card, Typography, Tooltip } from '@mui/material'; import { getNextTxs } from "../../../logic/sample"; import { SafeInfo } from '@safe-global/safe-apps-sdk'; import { SafeMultisigTransaction } from "../../../logic/services"; @@ -16,49 +16,56 @@ interface NextTx { ready: boolean } -export const NextTxItem: FunctionComponent<{ next: NextTx, handleRelay: (tx: SafeMultisigTransaction) => void }> = ({ next, handleRelay }) => { - return (
- {next.tx.safeTxHash} +const NextTxItem: FunctionComponent<{ next: NextTx, handleRelay: (tx: SafeMultisigTransaction) => void }> = ({ next, handleRelay }) => { + return ( + {next.tx.safeTxHash.slice(0, 6)}...{next.tx.safeTxHash.slice(-6)} {next.ready && } -
) + {!next.ready && Requires Confirmation} + ) } export const NextTxsList: FunctionComponent<{ safeInfo: SafeInfo, handleRelay: (tx: SafeMultisigTransaction) => void }> = ({ safeInfo, handleRelay }) => { const [ status, setStatus ] = useState(Status.Loading) const [ nextTxs, setNextTxs ] = useState([]) - useEffect(() => { - setStatus(Status.Loading) - const fetchData = async() => { - try { - const txs = await getNextTxs(safeInfo.safeAddress) - setNextTxs(txs.map((tx) => { - return { - tx, - ready: (tx.confirmations?.length ?? 0) >= safeInfo.threshold - } - })) - setStatus(Status.Ready) - } catch (e) { - console.error(e) - setStatus(Status.Error) - } + const fetchData = useCallback(async () => { + try { + setStatus(Status.Loading) + const txs = await getNextTxs(safeInfo.safeAddress) + setNextTxs(txs.map((tx) => { + return { + tx, + ready: (tx.confirmations?.length ?? 0) >= safeInfo.threshold + } + })) + setStatus(Status.Ready) + } catch (e) { + console.error(e) + setStatus(Status.Error) } - fetchData(); }, [setStatus, safeInfo]) + useEffect(() => { + fetchData(); + }, [fetchData]) switch(status) { case Status.Loading: - return ( + return ( - ) + ) case Status.Error: - return ( + return ( Error Loading Data - ) + ) case Status.Ready: + if (nextTxs.length == 0) return ( + + No pending transactions + + ) return (<> + {nextTxs.map((nextTx) => )} ) } diff --git a/web/src/routes/samples/relay/Relay.css b/web/src/routes/samples/relay/Relay.css index 669ce68..0ce70f1 100644 --- a/web/src/routes/samples/relay/Relay.css +++ b/web/src/routes/samples/relay/Relay.css @@ -11,8 +11,23 @@ justify-content: start; } -.NextTx { - width: 100%; +.Settings { + width: calc(min(calc(100% - 48px), 600px) - 16px); + margin: 8px; + padding: 8px; + text-align: start; + font-size: 16pt; + color: white; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; +} + +.Notice { + width: calc(min(calc(100% - 48px), 600px) - 16px); + margin: 8px; + padding: 8px; text-align: center; font-size: calc(10px + 2vmin); color: white; @@ -21,4 +36,18 @@ align-items: center; justify-content: center; display: flex; +} + +.NextTxCard { + width: calc(min(calc(100% - 48px), 600px) - 16px); + margin: 8px; + padding: 8px; + text-align: center; + font-size: calc(10px + 2vmin); + color: white; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + display: flex; } \ No newline at end of file diff --git a/web/src/routes/samples/relay/RelayPlugin.tsx b/web/src/routes/samples/relay/RelayPlugin.tsx index c7271b6..28ecbee 100644 --- a/web/src/routes/samples/relay/RelayPlugin.tsx +++ b/web/src/routes/samples/relay/RelayPlugin.tsx @@ -2,7 +2,7 @@ import { FunctionComponent, useCallback, useEffect, useState } from "react"; import { formatUnits, parseUnits } from "ethers" import { useParams } from "react-router-dom"; import "./Relay.css"; -import { CircularProgress, FormControl, InputLabel, Select, MenuItem, TextField, Button, Typography } from '@mui/material'; +import { CircularProgress, FormControl, InputLabel, Select, MenuItem, TextField, Button, Typography, Card } from '@mui/material'; import { TokenInfo, getAvailableFeeToken, getMaxFeePerToken, getTokenInfo, isKnownSamplePlugin, updateMaxFeePerToken } from "../../../logic/sample"; import { getSafeInfo, isConnectedToSafe } from "../../../logic/safeapp"; import { SafeInfo } from '@safe-global/safe-apps-sdk'; @@ -84,30 +84,32 @@ export const RelayPlugin: FunctionComponent<{}> = () => { return (
- {isLoading && } - {feeTokens.length > 0 && selectedFeeToken !== undefined && <> - - Fee Token - - - } - {safeInfo !== undefined && maxFee !== undefined && selectedFeeTokenInfo !== undefined && <> -

Current max fee set: {formatUnits(maxFee, selectedFeeTokenInfo.decimals)} {selectedFeeTokenInfo.symbol}

- - New max fee ({selectedFeeTokenInfo.symbol}):
- setNewMaxFee(event.target.value)}/> -
- - } + + {isLoading && } + {feeTokens.length > 0 && selectedFeeToken !== undefined && <> + + Fee Token + + + } + {safeInfo !== undefined && maxFee !== undefined && selectedFeeTokenInfo !== undefined && <> +

Current max fee set: {formatUnits(maxFee, selectedFeeTokenInfo.decimals)} {selectedFeeTokenInfo.symbol}

+ + New max fee ({selectedFeeTokenInfo.symbol}):
+ setNewMaxFee(event.target.value)}/> +
+ + } +
{safeInfo && setTxToRelay(tx)}/>} setTxToRelay(undefined)} />