= ({ functionFragment, data }) => {
- return (
-
- {functionFragment.inputs.length > 0 && (
-
- )}
-
- )
-}
+const DecodedTransaction: React.FC = ({ functionFragment, data }) => (
+
+ {functionFragment.inputs.length > 0 && (
+
+ )}
+
+)
export default DecodedTransaction
diff --git a/extension/src/browser/Drawer/RolePermissionCheck.tsx b/extension/src/browser/Drawer/RolePermissionCheck.tsx
index 58abc991..6897988f 100644
--- a/extension/src/browser/Drawer/RolePermissionCheck.tsx
+++ b/extension/src/browser/Drawer/RolePermissionCheck.tsx
@@ -4,7 +4,7 @@ import { RiGroupLine } from 'react-icons/ri'
import { Flex, Tag } from '../../components'
import { useApplicableTranslation } from '../../transactionTranslations'
-import { JsonRpcError, Route } from '../../types'
+import { Eip1193Provider, JsonRpcError, Route } from '../../types'
import { decodeRolesV1Error } from '../../utils'
import { decodeGenericError, decodeRolesV2Error } from '../../utils/decodeError'
@@ -12,8 +12,6 @@ import CopyToClipboard from './CopyToClipboard'
import { Translate } from './Translate'
import classes from './style.module.css'
import { useRoute } from '../../routes'
-import { useTenderlyProvider } from '../../providers'
-import { TenderlyProvider } from '../../providers/ProvideTenderly'
import { TransactionState } from '../../state'
import { MetaTransactionData } from '@safe-global/safe-core-sdk-types'
import { toQuantity, ZeroAddress } from 'ethers'
@@ -23,11 +21,12 @@ import {
planExecution,
Route as SerRoute,
} from 'ser-kit'
+import { useProvider } from '../ProvideProvider'
const simulateRolesTransaction = async (
encodedTransaction: MetaTransactionData,
route: Route,
- tenderlyProvider: TenderlyProvider
+ provider: Eip1193Provider
) => {
const routeWithInitiator = (
route.initiator ? route : { ...route, initiator: ZeroAddress }
@@ -50,7 +49,7 @@ const simulateRolesTransaction = async (
}
try {
- await tenderlyProvider.request({
+ await provider.request({
method: 'eth_estimateGas',
params: [tx],
})
@@ -86,7 +85,7 @@ const RolePermissionCheck: React.FC<{
}> = ({ transactionState, index, mini = false }) => {
const [error, setError] = useState(undefined)
const { route } = useRoute()
- const tenderlyProvider = useTenderlyProvider()
+ const provider = useProvider()
const translationAvailable = !!useApplicableTranslation(
transactionState.transaction
@@ -94,11 +93,12 @@ const RolePermissionCheck: React.FC<{
useEffect(() => {
let canceled = false
+ if (!provider) return
simulateRolesTransaction(
transactionState.transaction,
route,
- tenderlyProvider
+ provider
).then((error) => {
if (!canceled) setError(error)
})
@@ -106,7 +106,7 @@ const RolePermissionCheck: React.FC<{
return () => {
canceled = true
}
- }, [transactionState, route, tenderlyProvider])
+ }, [transactionState, route, provider])
if (error === undefined) return null
diff --git a/extension/src/browser/Drawer/SimulationStatus.tsx b/extension/src/browser/Drawer/SimulationStatus.tsx
index f7d39609..c085ccd9 100644
--- a/extension/src/browser/Drawer/SimulationStatus.tsx
+++ b/extension/src/browser/Drawer/SimulationStatus.tsx
@@ -2,17 +2,18 @@ import React from 'react'
import { RiExternalLinkLine, RiGitBranchLine } from 'react-icons/ri'
import { Flex, Spinner, Tag } from '../../components'
-import { useTenderlyProvider } from '../../providers'
import classes from './style.module.css'
import { TransactionState } from '../../state'
import { ExecutionStatus } from '../../state/reducer'
+import { useProvider } from '../ProvideProvider'
const SimulationStatus: React.FC<{
transactionState: TransactionState
mini?: boolean
}> = ({ transactionState, mini = false }) => {
- const tenderlyProvider = useTenderlyProvider()
+ const provider = useProvider()
+
if (mini) {
return (
<>
@@ -22,9 +23,9 @@ const SimulationStatus: React.FC<{
{transactionState.status === ExecutionStatus.SUCCESS && (
} color="success">
)}
- {transactionState.status === ExecutionStatus.REVERTED ||
+ {transactionState.status === ExecutionStatus.FAILED ||
(transactionState.status ===
- ExecutionStatus.MODULE_TRANSACTION_REVERTED && (
+ ExecutionStatus.META_TRANSACTION_REVERTED && (
} color="danger">
))}
>
@@ -56,15 +57,15 @@ const SimulationStatus: React.FC<{
Success
)}
- {transactionState.status === ExecutionStatus.REVERTED && (
+ {transactionState.status === ExecutionStatus.FAILED && (
} color="danger">
- Reverted
+ Failed
)}
{transactionState.status ===
- ExecutionStatus.MODULE_TRANSACTION_REVERTED && (
+ ExecutionStatus.META_TRANSACTION_REVERTED && (
} color="danger">
- Module transaction reverted
+ Reverted
)}
@@ -72,7 +73,7 @@ const SimulationStatus: React.FC<{
{transactionState.transactionHash && (
{
throw new Error('This is only supported when using ForkProvider')
}
- await provider.refork()
+ await provider.deleteFork()
// re-simulate all new transactions (assuming the already submitted ones have already been mined on the fresh fork)
for (const transaction of newTransactions) {
diff --git a/extension/src/browser/ProvideProvider.tsx b/extension/src/browser/ProvideProvider.tsx
index 68a7f3c0..74d81679 100644
--- a/extension/src/browser/ProvideProvider.tsx
+++ b/extension/src/browser/ProvideProvider.tsx
@@ -3,16 +3,17 @@ import React, {
ReactNode,
useCallback,
useContext,
- useMemo,
+ useEffect,
+ useRef,
} from 'react'
-import { ForkProvider, useTenderlyProvider } from '../providers'
+import { ForkProvider } from '../providers'
import { useRoute } from '../routes'
import { Eip1193Provider } from '../types'
import { useDispatch, useNewTransactions } from '../state'
import { fetchContractInfo } from '../utils/abi'
import { ExecutionStatus } from '../state/reducer'
-import { AbiCoder, BrowserProvider, TransactionReceipt } from 'ethers'
+import { AbiCoder, BrowserProvider, id, TransactionReceipt } from 'ethers'
import {
ConnectionType,
execute,
@@ -22,12 +23,16 @@ import {
planExecution,
Route as SerRoute,
} from 'ser-kit'
+import { useBeforeUnload } from '../utils'
+import { MetaTransactionData } from '@safe-global/safe-core-sdk-types'
interface Props {
children: ReactNode
}
-const ProviderContext = createContext(null)
+const ProviderContext = createContext<
+ (Eip1193Provider & { getTransactionLink(txHash: string): string }) | null
+>(null)
export const useProvider = () => useContext(ProviderContext)
const SubmitTransactionsContext = createContext<(() => Promise) | null>(
@@ -37,7 +42,6 @@ export const useSubmitTransactions = () => useContext(SubmitTransactionsContext)
const ProvideProvider: React.FC = ({ children }) => {
const { provider, route, chainId } = useRoute()
- const tenderlyProvider = useTenderlyProvider()
const dispatch = useDispatch()
const transactions = useNewTransactions()
@@ -53,99 +57,116 @@ const ProvideProvider: React.FC = ({ children }) => {
parsePrefixedAddress(avatarWaypoint.connection.from)) ||
[]
- const forkProvider = useMemo(
- () =>
- tenderlyProvider &&
- new ForkProvider(tenderlyProvider, {
- avatarAddress,
- moduleAddress:
- connectionType === ConnectionType.IS_ENABLED
- ? connectedFrom
- : undefined,
- ownerAddress:
- connectionType === ConnectionType.OWNS ? connectedFrom : undefined,
-
- async onBeforeTransactionSend(snapshotId, transaction) {
- // Immediately update the state with the transaction so that the UI can show it as pending.
- dispatch({
- type: 'APPEND_TRANSACTION',
- payload: { transaction, snapshotId },
- })
-
- // Now we can take some time decoding the transaction and we update the state once that's done.
- const contractInfo = await fetchContractInfo(
- transaction.to as `0x${string}`,
- chainId
- )
- dispatch({
- type: 'DECODE_TRANSACTION',
- payload: {
- snapshotId,
- contractInfo,
- },
- })
+ const moduleAddress =
+ connectionType === ConnectionType.IS_ENABLED ? connectedFrom : undefined
+ const ownerAddress =
+ connectionType === ConnectionType.OWNS ? connectedFrom : undefined
+
+ const onBeforeTransactionSend = useCallback(
+ async (snapshotId: string, transaction: MetaTransactionData) => {
+ // Immediately update the state with the transaction so that the UI can show it as pending.
+ dispatch({
+ type: 'APPEND_TRANSACTION',
+ payload: { transaction, snapshotId },
+ })
+
+ // Now we can take some time decoding the transaction and we update the state once that's done.
+ const contractInfo = await fetchContractInfo(
+ transaction.to as `0x${string}`,
+ chainId
+ )
+ dispatch({
+ type: 'DECODE_TRANSACTION',
+ payload: {
+ snapshotId,
+ contractInfo,
},
+ })
+ },
+ [chainId, dispatch]
+ )
- async onTransactionSent(snapshotId, transactionHash) {
- dispatch({
- type: 'CONFIRM_TRANSACTION',
- payload: {
- snapshotId,
- transactionHash,
- },
- })
-
- const receipt = await new BrowserProvider(
- tenderlyProvider
- ).getTransactionReceipt(transactionHash)
- if (!receipt?.status) {
- dispatch({
- type: 'UPDATE_TRANSACTION_STATUS',
- payload: {
- snapshotId,
- status: ExecutionStatus.REVERTED,
- },
- })
- return
- }
-
- if (
- receipt.logs.length === 1 &&
- connectionType === ConnectionType.IS_ENABLED &&
- isExecutionFromModuleFailure(
- receipt.logs[0],
- avatarAddress,
- connectedFrom
- )
- ) {
- dispatch({
- type: 'UPDATE_TRANSACTION_STATUS',
- payload: {
- snapshotId,
- status: ExecutionStatus.MODULE_TRANSACTION_REVERTED,
- },
- })
- } else {
- dispatch({
- type: 'UPDATE_TRANSACTION_STATUS',
- payload: {
- snapshotId,
- status: ExecutionStatus.SUCCESS,
- },
- })
- }
+ const onTransactionSent = useCallback(
+ async (
+ snapshotId: string,
+ transactionHash: string,
+ provider: Eip1193Provider
+ ) => {
+ dispatch({
+ type: 'CONFIRM_TRANSACTION',
+ payload: {
+ snapshotId,
+ transactionHash,
},
- }),
- [
- tenderlyProvider,
- avatarAddress,
- connectionType,
- connectedFrom,
- chainId,
- dispatch,
- ]
+ })
+
+ const receipt = await new BrowserProvider(provider).getTransactionReceipt(
+ transactionHash
+ )
+ if (!receipt?.status) {
+ dispatch({
+ type: 'UPDATE_TRANSACTION_STATUS',
+ payload: {
+ snapshotId,
+ status: ExecutionStatus.FAILED,
+ },
+ })
+ return
+ }
+
+ if (
+ receipt.logs.length === 1 &&
+ isExecutionFailure(receipt.logs[0], avatarAddress, moduleAddress)
+ ) {
+ dispatch({
+ type: 'UPDATE_TRANSACTION_STATUS',
+ payload: {
+ snapshotId,
+ status: ExecutionStatus.META_TRANSACTION_REVERTED,
+ },
+ })
+ } else {
+ dispatch({
+ type: 'UPDATE_TRANSACTION_STATUS',
+ payload: {
+ snapshotId,
+ status: ExecutionStatus.SUCCESS,
+ },
+ })
+ }
+ },
+ [dispatch, avatarAddress, moduleAddress]
)
+ const forkProviderRef = useRef(null)
+
+ // whenever anything changes in the connection settings, we delete the current fork and start afresh
+ useEffect(() => {
+ forkProviderRef.current = new ForkProvider({
+ chainId,
+ avatarAddress,
+ moduleAddress,
+ ownerAddress,
+ onBeforeTransactionSend,
+ onTransactionSent,
+ })
+ return () => {
+ forkProviderRef.current?.deleteFork()
+ }
+ }, [
+ chainId,
+ avatarAddress,
+ moduleAddress,
+ ownerAddress,
+ onBeforeTransactionSend,
+ onTransactionSent,
+ ])
+
+ // delete fork when closing browser tab (the effect teardown won't be executed in that case)
+ useBeforeUnload(() => {
+ forkProviderRef.current?.deleteFork()
+ })
+
const submitTransactions = useCallback(async () => {
const metaTransactions = transactions.map((txState) => txState.transaction)
@@ -188,8 +209,12 @@ const ProvideProvider: React.FC = ({ children }) => {
return batchTransactionHash
}, [transactions, provider, dispatch, route])
+ if (!forkProviderRef.current) {
+ return null
+ }
+
return (
-
+
{children}
@@ -199,17 +224,20 @@ const ProvideProvider: React.FC = ({ children }) => {
export default ProvideProvider
-const isExecutionFromModuleFailure = (
+const isExecutionFailure = (
log: TransactionReceipt['logs'][0],
avatarAddress: string,
moduleAddress?: string
) => {
- return (
- log.address.toLowerCase() === avatarAddress.toLowerCase() &&
- log.topics[0] ===
- '0xacd2c8702804128fdb0db2bb49f6d127dd0181c13fd45dbfe16de0930e2bd375' && // ExecutionFromModuleFailure(address)
- (!moduleAddress ||
+ if (log.address.toLowerCase() !== avatarAddress.toLowerCase()) return false
+
+ if (moduleAddress) {
+ return (
+ log.topics[0] === id('ExecutionFromModuleFailure(address)') &&
log.topics[1] ===
- AbiCoder.defaultAbiCoder().encode(['address'], [moduleAddress]))
- )
+ AbiCoder.defaultAbiCoder().encode(['address'], [moduleAddress])
+ )
+ } else {
+ return log.topics[0] === id('ExecutionFailure(bytes32, uint256)')
+ }
}
diff --git a/extension/src/integrations/safe/interface.ts b/extension/src/integrations/safe/interface.ts
index 2e2a11bc..927d2dad 100644
--- a/extension/src/integrations/safe/interface.ts
+++ b/extension/src/integrations/safe/interface.ts
@@ -1,8 +1,5 @@
import { Interface } from 'ethers'
export const safeInterface = new Interface([
- 'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) returns (bool success)',
- 'function changeThreshold(uint256 _threshold)',
- 'function addOwnerWithThreshold(address owner, uint256 _threshold)',
'function getMessageHashForSafe(address safe, bytes message) view returns (bytes32)',
])
diff --git a/extension/src/integrations/safe/signing.ts b/extension/src/integrations/safe/signing.ts
index 39aba047..eda1980e 100644
--- a/extension/src/integrations/safe/signing.ts
+++ b/extension/src/integrations/safe/signing.ts
@@ -1,6 +1,11 @@
import { MetaTransactionData } from '@safe-global/safe-core-sdk-types'
import { EIP712TypedData } from '@safe-global/safe-gateway-typescript-sdk'
-import { Contract, hashMessage, toUtf8String, TypedDataEncoder } from 'ethers'
+import {
+ Contract,
+ hashMessage as ethersHashMessage,
+ toUtf8String,
+ TypedDataEncoder,
+} from 'ethers'
const SIGN_MESSAGE_LIB_ADDRESS = '0xd53cd0aB83D845Ac265BE939c57F53AD838012c9'
const SIGN_MESSAGE_LIB_ABI = [
@@ -16,7 +21,7 @@ const signMessageLib = new Contract(
export const signMessage = (message: string): MetaTransactionData => ({
to: SIGN_MESSAGE_LIB_ADDRESS,
data: signMessageLib.interface.encodeFunctionData('signMessage', [
- hashMessage(decode(message)),
+ hashMessage(message),
]),
value: '0',
operation: 1,
@@ -40,6 +45,9 @@ export const signTypedData = (data: EIP712TypedData) => {
}
}
+export const hashMessage = (message: string) =>
+ ethersHashMessage(decode(message))
+
const decode = (message: string): string => {
if (!message.startsWith('0x')) {
return message
diff --git a/extension/src/providers/ForkProvider.ts b/extension/src/providers/ForkProvider.ts
index 5d67021e..9f5199fe 100644
--- a/extension/src/providers/ForkProvider.ts
+++ b/extension/src/providers/ForkProvider.ts
@@ -1,18 +1,21 @@
import EventEmitter from 'events'
import { ContractFactories, KnownContracts } from '@gnosis.pm/zodiac'
-import { BrowserProvider, toQuantity } from 'ethers'
-import {
- MetaTransactionData,
- TransactionOptions,
-} from '@safe-global/safe-core-sdk-types'
-import { generatePreValidatedSignature } from '@safe-global/protocol-kit/dist/src/utils'
+import { BrowserProvider, toQuantity, ZeroAddress } from 'ethers'
+import { MetaTransactionData } from '@safe-global/safe-core-sdk-types'
import { Eip1193Provider, TransactionData } from '../types'
-import { TenderlyProvider } from './ProvideTenderly'
-import { safeInterface } from '../integrations/safe'
+import TenderlyProvider from './TenderlyProvider'
+import { initSafeProtocolKit, safeInterface } from '../integrations/safe'
import { translateSignSnapshotVote } from '../transactionTranslations/signSnapshotVote'
-import { typedDataHash } from '../integrations/safe/signing'
+import {
+ hashMessage,
+ signMessage,
+ signTypedData,
+ typedDataHash,
+} from '../integrations/safe/signing'
+import { ChainId } from 'ser-kit'
+import { decodeGenericError } from '../utils'
class UnsupportedMethodError extends Error {
code = 4200
@@ -23,39 +26,47 @@ interface Handlers {
checkpointId: string,
metaTx: MetaTransactionData
): void
- onTransactionSent(checkpointId: string, hash: string): void
+ onTransactionSent(
+ checkpointId: string,
+ hash: string,
+ provider: Eip1193Provider
+ ): void
}
+/** This is separated from TenderlyProvider to provide an abstraction over Tenderly implementation details. That way we will be able to more easily plug in alternative simulation back-ends. */
class ForkProvider extends EventEmitter {
private provider: TenderlyProvider
- private handlers: Handlers
- private avatarAddress: string
+ private chainId: ChainId
+ private avatarAddress: string
private moduleAddress: string | undefined
private ownerAddress: string | undefined
+ private handlers: Handlers
+
private blockGasLimitPromise: Promise
private pendingMetaTransaction: Promise | undefined
-
- constructor(
- provider: TenderlyProvider,
- {
- avatarAddress,
- moduleAddress,
- ownerAddress,
-
- ...handlers
- }: {
- avatarAddress: string
- /** If set, will simulate the transaction though an `execTransactionFromModule` call */
- moduleAddress?: string
- /** If set, will simulate the transaction though an `execTransaction` call */
- ownerAddress?: string
- } & Handlers
- ) {
+ private isInitialized = false
+
+ constructor({
+ chainId,
+ avatarAddress,
+ moduleAddress,
+ ownerAddress,
+
+ ...handlers
+ }: {
+ chainId: ChainId
+ avatarAddress: string
+ /** If set, will simulate transactions using respective `execTransactionFromModule` calls */
+ moduleAddress?: string
+ /** If set, will enable the the ownerAddress as a module and simulate using `execTransactionFromModule` calls. If neither `moduleAddress` nor `ownerAddress` is set, it will enable a dummy module 0xfacade */
+ ownerAddress?: string
+ } & Handlers) {
super()
- this.provider = provider
+ this.chainId = chainId
+ this.provider = new TenderlyProvider(chainId)
this.avatarAddress = avatarAddress
this.moduleAddress = moduleAddress
this.ownerAddress = ownerAddress
@@ -91,38 +102,63 @@ class ForkProvider extends EventEmitter {
}
case 'eth_sign': {
- // TODO support this via Safe's SignMessageLib
throw new UnsupportedMethodError('eth_sign is not supported')
}
+ case 'personal_sign': {
+ const [message, from] = params
+ if (from.toLowerCase() !== this.avatarAddress.toLowerCase()) {
+ throw new Error('personal_sign only supported for the avatar address')
+ }
+ const signTx = signMessage(message)
+ const safeTxHash = await this.sendMetaTransaction(signTx)
+
+ console.log('message signed', {
+ safeTxHash,
+ messageHash: hashMessage(message),
+ })
+
+ return '0x'
+ }
+ case 'eth_signTypedData':
case 'eth_signTypedData_v4': {
- console.log('eth_signTypedData_v4', params)
+ const [from, dataString] = params
+ if (from.toLowerCase() !== this.avatarAddress.toLowerCase()) {
+ throw new Error(
+ 'eth_signTypedData_v4 only supported for the avatar address'
+ )
+ }
+ const data = JSON.parse(dataString)
+
+ const dataHash = typedDataHash(data)
+ const safeMessageHash = await safeInterface.encodeFunctionData(
+ 'getMessageHashForSafe',
+ [this.avatarAddress, dataHash]
+ )
// special handling for Snapshot vote signatures
- const tx = translateSignSnapshotVote(params[0] || {})
- if (tx) {
- const safeTxHash = await this.sendMetaTransaction(tx)
-
- // TODO we don't even need this, but for now we keep it for debugging purposes
- const safeMessageHash = await safeInterface.encodeFunctionData(
- 'getMessageHashForSafe',
- [this.avatarAddress, typedDataHash(params[0])]
- )
- console.log('Snapshot vote signed', {
+ const snapshotVoteTx = translateSignSnapshotVote(data || {})
+ if (snapshotVoteTx) {
+ const safeTxHash = await this.sendMetaTransaction(snapshotVoteTx)
+
+ console.log('Snapshot vote EIP-712 message signed', {
safeTxHash,
safeMessageHash,
- typedDataHash: typedDataHash(params[0]),
+ typedDataHash: dataHash,
})
+ } else {
+ // default EIP-712 signature handling
+ const signTx = signTypedData(data)
+ const safeTxHash = await this.sendMetaTransaction(signTx)
- // The Safe App SDK expects a response in the format of `{ safeTxHash }` for on-chain signatures.
- // So we make the safeTxHash available by returning it as the signature.
- return safeTxHash
+ console.log('EIP-712 message signed', {
+ safeTxHash,
+ safeMessageHash,
+ typedDataHash: dataHash,
+ })
}
- // TODO support this via Safe's SignMessageLib
- throw new UnsupportedMethodError(
- 'eth_signTypedData_v4 is not supported'
- )
+ return '0x'
}
case 'eth_sendTransaction': {
@@ -155,18 +191,23 @@ class ForkProvider extends EventEmitter {
const send = this.pendingMetaTransaction
? async () => {
await this.pendingMetaTransaction
- return await this._sendMetaTransaction(metaTx)
+ return await this.sendMetaTransactionIsSeries(metaTx)
}
- : async () => await this._sendMetaTransaction(metaTx)
+ : async () => await this.sendMetaTransactionIsSeries(metaTx)
// Synchronously update `this.pendingMetaTransaction` so subsequent `sendMetaTransaction()` calls will go to the back of the queue
this.pendingMetaTransaction = send()
return await this.pendingMetaTransaction
}
- private async _sendMetaTransaction(
+ private async sendMetaTransactionIsSeries(
metaTx: MetaTransactionData
): Promise {
+ if (!this.isInitialized) {
+ // we lazily initialize the fork (making the Safe ready for simulating transactions) when the first transaction is sent
+ await this.initFork()
+ }
+
const isDelegateCall = metaTx.operation === 1
if (isDelegateCall && !this.moduleAddress && !this.ownerAddress) {
throw new Error('delegatecall requires moduleAddress or ownerAddress')
@@ -183,48 +224,63 @@ class ForkProvider extends EventEmitter {
this.handlers.onBeforeTransactionSend(checkpointId, metaTx)
+ let from = this.moduleAddress || this.ownerAddress || DUMMY_MODULE_ADDRESS
+ if (from === ZeroAddress) from = DUMMY_MODULE_ADDRESS
+
// correctly route the meta tx through the avatar
- let tx: TransactionData & TransactionOptions
- if (this.moduleAddress) {
- tx = execTransactionFromModule(
- metaTx,
- this.avatarAddress,
- this.moduleAddress,
- await this.blockGasLimitPromise
- )
- } else if (this.ownerAddress) {
- tx = execTransaction(
- metaTx,
- this.avatarAddress,
- this.ownerAddress,
- await this.blockGasLimitPromise
- )
- } else {
- // no module or owner address, simulate with avatar as sender
- // note: this is a theoretical case only atm
- tx = {
- to: metaTx.to,
- data: metaTx.data,
- value: toQuantity(BigInt(metaTx.value)),
- from: this.avatarAddress,
- }
- }
+ const tx = execTransactionFromModule(
+ metaTx,
+ this.avatarAddress,
+ from,
+ await this.blockGasLimitPromise
+ )
// execute transaction in fork
const result = await this.provider.request({
method: 'eth_sendTransaction',
params: [tx],
})
- this.handlers.onTransactionSent(checkpointId, result)
+ this.handlers.onTransactionSent(checkpointId, result, this.provider)
return result
}
- async refork(): Promise {
- await this.provider.refork()
+ async initFork(): Promise {
+ console.log('Initializing fork for simulation...')
+
+ await prepareSafeForSimulation(
+ {
+ chainId: this.chainId,
+ avatarAddress: this.avatarAddress,
+ moduleAddress: this.moduleAddress,
+ ownerAddress: this.ownerAddress,
+ },
+ this.provider
+ )
+
+ // notify the background script to start intercepting JSON RPC requests
+ // we use the public RPC for requests originating from apps
+ window.postMessage(
+ {
+ type: 'startSimulating',
+ toBackground: true,
+ networkId: this.chainId,
+ rpcUrl: this.provider.publicRpc,
+ },
+ '*'
+ )
+
+ this.isInitialized = true
}
async deleteFork(): Promise {
+ // notify the background script to stop intercepting JSON RPC requests
+ window.postMessage({ type: 'stopSimulating', toBackground: true }, '*')
await this.provider.deleteFork()
+ this.isInitialized = false
+ }
+
+ getTransactionLink(txHash: string) {
+ return this.provider.getTransactionLink(txHash)
}
}
@@ -260,43 +316,56 @@ const execTransactionFromModule = (
}
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
-
-/** Encode an execTransaction call by the given owner (address must be an actual owner of the Safe) */
-// for reference: https://github.com/safe-global/safe-wallet-web/blob/dev/src/components/tx/security/tenderly/utils.ts#L213
-export function execTransaction(
- tx: MetaTransactionData & TransactionOptions & { gas?: string | number },
- avatarAddress: string,
- ownerAddress: string,
- blockGasLimit: bigint
-): TransactionData & { gas?: string } {
- const signature = generatePreValidatedSignature(ownerAddress)
- const data = safeInterface.encodeFunctionData('execTransaction', [
- tx.to,
- tx.value,
- tx.data,
- tx.operation,
- tx.gasLimit || tx.gas || 0,
- 0,
- tx.gasPrice || 0,
- ZERO_ADDRESS,
- ZERO_ADDRESS,
- signature.staticPart() + signature.dynamicPart(),
- ])
-
- return {
- to: avatarAddress,
- data,
- value: '0x0',
- from: ownerAddress,
- // We simulate setting the entire block gas limit as the gas limit for the transaction
- gas: toQuantity(blockGasLimit / 2n), // for some reason tenderly errors when passing the full block gas limit
- // With gas price 0 account don't need token for gas
- // gasPrice: '0x0', // doesn't seem to be required
- }
-}
+const DUMMY_MODULE_ADDRESS = '0xfacade0000000000000000000000000000000000'
const readBlockGasLimit = async (provider: Eip1193Provider) => {
const browserProvider = new BrowserProvider(provider)
const block = await browserProvider.getBlock('latest')
return block?.gasLimit || 30_000_000n
}
+
+async function prepareSafeForSimulation(
+ {
+ chainId,
+ avatarAddress,
+ moduleAddress,
+ ownerAddress,
+ }: {
+ chainId: ChainId
+ avatarAddress: string
+ moduleAddress?: string
+ ownerAddress?: string
+ },
+ provider: TenderlyProvider
+) {
+ const safe = await initSafeProtocolKit(chainId, avatarAddress)
+
+ // If we simulate as a Safe owner, we could either use execTransaction and override the threshold to 1.
+ // However, enabling the owner as a module seems like a more simple approach.
+
+ let from = moduleAddress || ownerAddress || DUMMY_MODULE_ADDRESS
+ if (from === ZeroAddress) from = DUMMY_MODULE_ADDRESS
+
+ const iface = safe.getContractManager().safeContract?.contract.interface
+ if (!iface) {
+ throw new Error('Safe contract not found')
+ }
+
+ try {
+ await provider.request({
+ method: 'eth_sendTransaction',
+ params: [
+ {
+ to: avatarAddress,
+ data: iface.encodeFunctionData('enableModule', [from]),
+ from: avatarAddress,
+ },
+ ],
+ })
+ } catch (e) {
+ // ignore revert indicating that the module is already enabled
+ if (decodeGenericError(e as any) !== 'GS102') {
+ throw e
+ }
+ }
+}
diff --git a/extension/src/providers/ProvideTenderly.tsx b/extension/src/providers/TenderlyProvider.tsx
similarity index 52%
rename from extension/src/providers/ProvideTenderly.tsx
rename to extension/src/providers/TenderlyProvider.tsx
index 12385dcc..9c05aac4 100644
--- a/extension/src/providers/ProvideTenderly.tsx
+++ b/extension/src/providers/TenderlyProvider.tsx
@@ -1,138 +1,26 @@
import EventEmitter from 'events'
-import React, { useContext, useEffect, useMemo } from 'react'
import { customAlphabet } from 'nanoid'
-import { useRoute } from '../routes'
import { JsonRpcRequest } from '../types'
-import { useBeforeUnload } from '../utils'
-import { initSafeProtocolKit, safeInterface } from '../integrations/safe'
import { getReadOnlyProvider } from './readOnlyProvider'
import { ChainId } from 'ser-kit'
-import { asLegacyConnection } from '../routes/legacyConnectionMigrations'
import { JsonRpcProvider } from 'ethers'
const slug = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789')
-const TenderlyContext = React.createContext(null)
-
-export const useTenderlyProvider = (): TenderlyProvider => {
- const value = useContext(TenderlyContext)
- if (!value) throw new Error('must be wrapped in ')
- return value
-}
-
-const ProvideTenderly: React.FC<{ children: React.ReactNode }> = ({
- children,
-}) => {
- const { chainId, route } = useRoute()
- const { avatarAddress, moduleAddress, pilotAddress } =
- asLegacyConnection(route)
-
- const tenderlyProvider = useMemo(() => {
- return new TenderlyProvider(chainId)
- }, [chainId])
-
- // whenever anything changes in the connection settings, we delete the current fork and start afresh
- useEffect(() => {
- prepareSafeForSimulation(
- { chainId, avatarAddress, moduleAddress, pilotAddress },
- tenderlyProvider
- )
-
- return () => {
- tenderlyProvider.deleteFork()
- }
- }, [tenderlyProvider, chainId, avatarAddress, moduleAddress, pilotAddress])
-
- // delete fork when closing browser tab (the effect teardown won't be executed in that case)
- useBeforeUnload(() => {
- if (tenderlyProvider) tenderlyProvider.deleteFork()
- })
-
- if (!tenderlyProvider) return null
-
- return (
-
- {children}
-
- )
-}
-
-export default ProvideTenderly
-
-async function prepareSafeForSimulation(
- {
- chainId,
- avatarAddress,
- moduleAddress,
- pilotAddress,
- }: {
- chainId: ChainId
- avatarAddress: string
- moduleAddress?: string
- pilotAddress?: string
- },
- tenderlyProvider: TenderlyProvider
-) {
- const safe = await initSafeProtocolKit(chainId, avatarAddress)
-
- // If we simulate as a Safe owner, we might have to override the owners & threshold of the Safe to allow single signature transactions
- if (!moduleAddress) {
- const [owners, threshold] = await Promise.all([
- safe.getOwners(),
- safe.getThreshold(),
- ])
-
- // default to first owner if no pilot address is provided
- if (!pilotAddress) pilotAddress = owners[0]
-
- const pilotIsOwner = owners.some(
- (owner) => owner.toLowerCase() === pilotAddress!.toLowerCase()
- )
-
- if (!pilotIsOwner) {
- // the pilot account is not an owner, so we need to make it one and set the threshold to 1 at the same time
- await tenderlyProvider.request({
- method: 'eth_sendTransaction',
- params: [
- {
- to: avatarAddress,
- data: safeInterface.encodeFunctionData('addOwnerWithThreshold', [
- pilotAddress,
- 1,
- ]),
- from: avatarAddress,
- },
- ],
- })
- } else if (threshold > 1) {
- // doesn't allow to execute with single signature, so we need to override the threshold
- await tenderlyProvider.request({
- method: 'eth_sendTransaction',
- params: [
- {
- to: avatarAddress,
- data: safeInterface.encodeFunctionData('changeThreshold', [1]),
- from: avatarAddress,
- },
- ],
- })
- }
- }
-}
-
-export class TenderlyProvider extends EventEmitter {
+export default class TenderlyProvider extends EventEmitter {
private chainId: number
private forkProviderPromise: Promise | undefined
private vnetId: string | undefined
- private publicRpcSlug: string | undefined
private blockNumber: number | undefined
private tenderlyVnetApi: string
private throttledIncreaseBlock: () => void
+ publicRpc: string | undefined
+
constructor(chainId: ChainId) {
super()
this.chainId = chainId
@@ -193,21 +81,12 @@ export class TenderlyProvider extends EventEmitter {
return result
}
- async refork() {
- this.deleteFork()
- this.forkProviderPromise = this.createFork(this.chainId)
- return await this.forkProviderPromise
- }
-
async deleteFork() {
- // notify the background script to stop intercepting JSON RPC requests
- window.postMessage({ type: 'stopSimulating', toBackground: true }, '*')
-
await this.forkProviderPromise
if (!this.vnetId) return
this.vnetId = undefined
- this.publicRpcSlug = undefined
+ this.publicRpc = undefined
this.forkProviderPromise = undefined
this.blockNumber = undefined
@@ -219,7 +98,9 @@ export class TenderlyProvider extends EventEmitter {
}
getTransactionLink(txHash: string) {
- return `https://dashboard.tenderly.co/explorer/vnet/${this.publicRpcSlug}/tx/${txHash}`
+ if (!this.publicRpc) return ''
+ const publicRpcSlug = this.publicRpc.split('/').pop()
+ return `https://dashboard.tenderly.co/explorer/vnet/${publicRpcSlug}/tx/${txHash}`
}
private async createFork(
@@ -257,25 +138,12 @@ export class TenderlyProvider extends EventEmitter {
this.blockNumber = json.fork_config.block_number
const adminRpc = json.rpcs.find((rpc: any) => rpc.name === 'Admin RPC').url
- const publicRpc = json.rpcs.find(
- (rpc: any) => rpc.name === 'Public RPC'
- ).url
- this.publicRpcSlug = publicRpc.split('/').pop()
-
- // notify the background script to start intercepting JSON RPC requests
- // we use the public RPC for requests originating from apps
- window.postMessage(
- {
- type: 'startSimulating',
- toBackground: true,
- networkId,
- rpcUrl: publicRpc,
- },
- '*'
- )
+ this.publicRpc = json.rpcs.find((rpc: any) => rpc.name === 'Public RPC').url
// for requests going directly to Tenderly provider we use the admin RPC so Pilot can fully control the fork
- return new JsonRpcProvider(adminRpc)
+ const provider = new JsonRpcProvider(adminRpc, this.chainId)
+
+ return provider
}
private increaseBlock = async () => {
diff --git a/extension/src/providers/index.ts b/extension/src/providers/index.ts
index 8a994749..b9d029a1 100644
--- a/extension/src/providers/index.ts
+++ b/extension/src/providers/index.ts
@@ -2,8 +2,4 @@ export { default as ForkProvider } from './ForkProvider'
export { default as useWalletConnect } from './useWalletConnect'
export { getReadOnlyProvider } from './readOnlyProvider'
-export {
- useTenderlyProvider,
- default as ProvideTenderly,
-} from './ProvideTenderly'
export { ProvideMetaMask, default as useMetaMask } from './useMetaMask'
diff --git a/extension/src/providers/readOnlyProvider.ts b/extension/src/providers/readOnlyProvider.ts
index 066ebefd..e431e98a 100644
--- a/extension/src/providers/readOnlyProvider.ts
+++ b/extension/src/providers/readOnlyProvider.ts
@@ -103,13 +103,14 @@ export class Eip1193JsonRpcProvider extends EventEmitter {
return toQuantity(result)
}
+ case 'eth_requestAccounts':
case 'eth_sendTransaction':
case 'eth_signTypedData':
case 'eth_signTypedData_v3':
case 'eth_signTypedData_v4':
case 'personal_sign':
case 'eth_sign': {
- throw new Error(`${method} requires signing`)
+ throw new Error(`${method} not supported by read-only provider`)
}
default: {
diff --git a/extension/src/state/reducer.ts b/extension/src/state/reducer.ts
index f125c929..a72cc952 100644
--- a/extension/src/state/reducer.ts
+++ b/extension/src/state/reducer.ts
@@ -5,8 +5,10 @@ import { Action } from './actions'
export enum ExecutionStatus {
PENDING,
SUCCESS,
- REVERTED,
- MODULE_TRANSACTION_REVERTED,
+ /** Submitting the transaction failed. This is probably due to an issue in the execution route. */
+ FAILED,
+ /** Submitting the transaction succeeded, but the Safe meta transaction reverted. */
+ META_TRANSACTION_REVERTED,
}
export interface TransactionState {
diff --git a/extension/yarn.lock b/extension/yarn.lock
index 7e0c2953..8c4b92cd 100644
--- a/extension/yarn.lock
+++ b/extension/yarn.lock
@@ -1804,6 +1804,18 @@ __metadata:
languageName: node
linkType: hard
+"@safe-global/api-kit@npm:^2.4.4":
+ version: 2.4.4
+ resolution: "@safe-global/api-kit@npm:2.4.4"
+ dependencies:
+ "@safe-global/protocol-kit": "npm:^4.0.4"
+ "@safe-global/safe-core-sdk-types": "npm:^5.0.3"
+ ethers: "npm:^6.13.1"
+ node-fetch: "npm:^2.7.0"
+ checksum: 10/578d1632bfc2f093af3ab89898e39674ba4cb55b42e69ee77c5f5c8976eef00ebc4730fabd53dec22b40a6bcdb72e622454b8fb2149c9abbaa956d1a0df0c82c
+ languageName: node
+ linkType: hard
+
"@safe-global/protocol-kit@npm:^4.0.2":
version: 4.0.2
resolution: "@safe-global/protocol-kit@npm:4.0.2"
@@ -1819,6 +1831,21 @@ __metadata:
languageName: node
linkType: hard
+"@safe-global/protocol-kit@npm:^4.0.4":
+ version: 4.0.4
+ resolution: "@safe-global/protocol-kit@npm:4.0.4"
+ dependencies:
+ "@noble/hashes": "npm:^1.3.3"
+ "@safe-global/safe-core-sdk-types": "npm:^5.0.3"
+ "@safe-global/safe-deployments": "npm:^1.37.3"
+ abitype: "npm:^1.0.2"
+ ethereumjs-util: "npm:^7.1.5"
+ ethers: "npm:^6.13.1"
+ semver: "npm:^7.6.2"
+ checksum: 10/4acdfa6d2c0ced96084a731d62f96463084ec97a0edd118b455f419e34d9f736e06d8186f56168b7fe4ef9b60eddcf5807710db18db4973ca995ac6e46bdb442
+ languageName: node
+ linkType: hard
+
"@safe-global/safe-apps-sdk@npm:^9.1.0":
version: 9.1.0
resolution: "@safe-global/safe-apps-sdk@npm:9.1.0"
@@ -1838,6 +1865,15 @@ __metadata:
languageName: node
linkType: hard
+"@safe-global/safe-core-sdk-types@npm:^5.0.3":
+ version: 5.0.3
+ resolution: "@safe-global/safe-core-sdk-types@npm:5.0.3"
+ dependencies:
+ abitype: "npm:^1.0.2"
+ checksum: 10/e0646c319a7d774ac583f7c589af8a8c18064b4c9d835a19877c2d2edc40ce84b9af47e2fdadd9dd73eec9a7b26c5979b8ad30a80a3c947fb1c2199086068912
+ languageName: node
+ linkType: hard
+
"@safe-global/safe-deployments@npm:^1.37.0":
version: 1.37.1
resolution: "@safe-global/safe-deployments@npm:1.37.1"
@@ -1847,10 +1883,19 @@ __metadata:
languageName: node
linkType: hard
-"@safe-global/safe-gateway-typescript-sdk@npm:^3.21.10":
- version: 3.21.10
- resolution: "@safe-global/safe-gateway-typescript-sdk@npm:3.21.10"
- checksum: 10/602963f3bb8fb96dc8717ba5bf0f1f685d23edcd2c9d14504f633b85e17e09879b1c6ff192fe8877d81bb75a9627fc778f2b49104ed363d1e18074e2f10224de
+"@safe-global/safe-deployments@npm:^1.37.3":
+ version: 1.37.3
+ resolution: "@safe-global/safe-deployments@npm:1.37.3"
+ dependencies:
+ semver: "npm:^7.6.2"
+ checksum: 10/3d1fcaac850a1d1100eaa5ff753c88c9bb889c678bb09248323c6b0c9d6228225a88be47973a89bb32829fe2d13ed17c21f854b9fdc29cc1b3c734021761f15c
+ languageName: node
+ linkType: hard
+
+"@safe-global/safe-gateway-typescript-sdk@npm:^3.22.2":
+ version: 3.22.2
+ resolution: "@safe-global/safe-gateway-typescript-sdk@npm:3.22.2"
+ checksum: 10/7f2b3cab4a1673647c8f7fd927be280f891dc74dba733f302862dee135fedd9d8e1875b1790c75b84c54164b517727bfe08a6dcaf7411659db13eeaefd1407fd
languageName: node
linkType: hard
@@ -10671,11 +10716,11 @@ __metadata:
"@eslint/js": "npm:^9.7.0"
"@gnosis.pm/zodiac": "npm:^4.0.3"
"@noble/hashes": "npm:^1.4.0"
- "@safe-global/api-kit": "npm:^2.4.2"
- "@safe-global/protocol-kit": "npm:^4.0.2"
+ "@safe-global/api-kit": "npm:^2.4.4"
+ "@safe-global/protocol-kit": "npm:^4.0.4"
"@safe-global/safe-apps-sdk": "npm:^9.1.0"
- "@safe-global/safe-core-sdk-types": "npm:^5.0.2"
- "@safe-global/safe-gateway-typescript-sdk": "npm:^3.21.10"
+ "@safe-global/safe-core-sdk-types": "npm:^5.0.3"
+ "@safe-global/safe-gateway-typescript-sdk": "npm:^3.22.2"
"@shazow/whatsabi": "npm:^0.13.2"
"@testing-library/dom": "npm:^10.3.1"
"@testing-library/jest-dom": "npm:^6.4.6"