Skip to content

Commit

Permalink
migrate dry run functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
frontendphil committed Jan 21, 2025
1 parent 6957c63 commit ce3045d
Show file tree
Hide file tree
Showing 17 changed files with 491 additions and 17 deletions.
29 changes: 29 additions & 0 deletions deployables/app/app/routes/edit-route.$data.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render } from '@/test-utils'
import { dryRun } from '@/utils'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Chain, CHAIN_NAME } from '@zodiac/chains'
Expand Down Expand Up @@ -66,6 +67,17 @@ const mockQueryRolesV2MultiSend = vi.mocked(queryRolesV2MultiSend)

const mockPostMessage = vi.spyOn(window, 'postMessage')

vi.mock('@/utils', async (importOriginal) => {
const module = await importOriginal<typeof import('@/utils')>()

return {
...module,
dryRun: vi.fn(),
}
})

const mockDryRun = vi.mocked(dryRun)

describe('Edit route', () => {
describe('Label', () => {
it('shows the name of a route', async () => {
Expand Down Expand Up @@ -596,5 +608,22 @@ describe('Edit route', () => {
screen.getByRole('button', { name: 'Test route' }),
).toBeInTheDocument()
})

it('shows errors returned by dry run', async () => {
const route = createMockExecutionRoute()

await render(`/edit-route/${btoa(JSON.stringify(route))}`)

mockDryRun.mockResolvedValue({
error: true,
message: 'Something went wrong',
})

await userEvent.click(screen.getByRole('button', { name: 'Test route' }))

expect(
await screen.findByRole('alert', { name: 'Dry run failed' }),
).toHaveAccessibleDescription('Something went wrong')
})
})
})
40 changes: 26 additions & 14 deletions deployables/app/app/routes/edit-route.$data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
WalletProvider,
ZodiacMod,
} from '@/components'
import { editRoute, jsonRpcProvider, parseRouteData } from '@/utils'
import { dryRun, editRoute, jsonRpcProvider, parseRouteData } from '@/utils'
import { invariant } from '@epic-web/invariant'
import { Chain, getChainId, verifyChainId, ZERO_ADDRESS } from '@zodiac/chains'
import {
formData,
Expand Down Expand Up @@ -38,6 +39,7 @@ import {
type Waypoints,
} from '@zodiac/schema'
import {
Error,
PilotType,
PrimaryButton,
SecondaryButton,
Expand Down Expand Up @@ -97,9 +99,8 @@ export const clientAction = async ({

const intent = getOptionalString(data, 'intent')

console.log({ intent })

switch (intent) {
case Intent.DryRun:
case Intent.Save: {
let route = parseRouteData(params.data)

Expand All @@ -111,12 +112,18 @@ export const clientAction = async ({

route = updateLabel(route, getString(data, 'label'))

window.postMessage(
{ type: CompanionAppMessageType.SAVE_ROUTE, data: route },
'*',
)
if (intent === Intent.Save) {
window.postMessage(
{ type: CompanionAppMessageType.SAVE_ROUTE, data: route },
'*',
)

return editRoute(route)
}

const chainId = getChainId(route.avatar)

return editRoute(route)
return dryRun(jsonRpcProvider(chainId), route)
}
case Intent.UpdateChain: {
const route = parseRouteData(params.data)
Expand Down Expand Up @@ -154,16 +161,15 @@ export const clientAction = async ({

return editRoute(updatePilotAddress(route, ZERO_ADDRESS))
}
case Intent.DryRun: {
return null
}

default:
return serverAction()
}
}

const EditRoute = ({
loaderData: { chainId, label, avatar, providerType, waypoints },
actionData,
}: Route.ComponentProps) => {
const submit = useSubmit()

Expand Down Expand Up @@ -261,8 +267,8 @@ const EditRoute = ({
}}
/>

<div className="mt-8 flex items-center justify-between">
<div className="text-xs opacity-75">
<div className="mt-8 flex items-center justify-between gap-8">
<div className="text-balance text-xs opacity-75">
The Pilot extension must be open to save.
</div>

Expand All @@ -284,6 +290,12 @@ const EditRoute = ({
</PrimaryButton>
</div>
</div>

{actionData != null && actionData.error === true && (
<div className="mt-8">
<Error title="Dry run failed">{actionData.message}</Error>
</div>
)}
</Form>
</main>

Expand Down Expand Up @@ -317,7 +329,7 @@ const getMultisend = (route: ExecutionRoute, module: ZodiacModule) => {
return queryRolesV2MultiSend(chainId, module.moduleAddress)
}

throw new Error(`Cannot get multisend for module type "${module.type}"`)
invariant(false, `Cannot get multisend for module type "${module.type}"`)
}

const verifyProviderType = (value: number): ProviderType =>
Expand Down
28 changes: 28 additions & 0 deletions deployables/app/app/utils/dryRun/JsonRpcError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { z } from 'zod'

export interface JsonRpcError extends Error {
data: {
code: number
message?: string
data?: string
originalError?: JsonRpcError['data']
}
}

const jsonRpcErrorSchema = z.object({
data: z.object({
code: z.number(),
message: z.string().optional(),
data: z.string().optional(),
}),
})

export const isJsonRpcError = (error: unknown): error is JsonRpcError => {
try {
jsonRpcErrorSchema.parse(error)

return true
} catch {
return false
}
}
85 changes: 85 additions & 0 deletions deployables/app/app/utils/dryRun/decodeError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { ContractFactories, KnownContracts } from '@gnosis.pm/zodiac'
import { AbiCoder } from 'ethers'
import type { JsonRpcError } from './JsonRpcError'

const RolesV1Interface =
ContractFactories[KnownContracts.ROLES_V1].createInterface()
const RolesV1PermissionsInterface =
ContractFactories[KnownContracts.PERMISSIONS].createInterface()
const RolesV2Interface =
ContractFactories[KnownContracts.ROLES_V2].createInterface()

export function getRevertData(error: JsonRpcError) {
// The errors thrown when a transaction is reverted use different formats, depending on:
// - wallet (MetaMask vs. WalletConnect)
// - RPC provider (Infura vs. Alchemy vs. Tenderly)
// - client library (ethers vs. directly using the EIP-1193 provider)

// first, drill through potential error wrappings down to the original error
while (typeof error === 'object' && (error as any).error) {
error = (error as any).error
}

// Here we try to extract the revert reason in any of the possible formats
const message =
typeof error.data === 'string'
? error.data
: error.data?.originalError?.data ||
error.data?.data ||
error.data?.originalError?.message ||
error.data?.message ||
error.message

const prefix = 'Reverted 0x'
return message.startsWith(prefix)
? message.substring(prefix.length - 2)
: message
}

export function decodeGenericError(error: JsonRpcError) {
const revertData = getRevertData(error)

// Solidity `revert "reason string"` will revert with the data encoded as selector of `Error(string)` followed by the ABI encoded string param
if (revertData.startsWith('0x08c379a0')) {
try {
const [reason] = AbiCoder.defaultAbiCoder().decode(
['string'],
'0x' + revertData.slice(10), // skip over selector
)
return reason as string
} catch {
return revertData
}
}

return revertData
}

export function decodeRolesV1Error(error: JsonRpcError) {
const revertData = getRevertData(error)
if (revertData.startsWith('0x')) {
try {
return (
RolesV1Interface.parseError(revertData) ||
RolesV1PermissionsInterface.parseError(revertData)
)
} catch {
// ignore
}
}
return null
}

export function decodeRolesV2Error(error: JsonRpcError) {
const revertData = getRevertData(error)

if (revertData.startsWith('0x')) {
try {
return RolesV2Interface.parseError(revertData)
} catch {
// ignore
}
}

return null
}
126 changes: 126 additions & 0 deletions deployables/app/app/utils/dryRun/dryRun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { ZERO_ADDRESS } from '@zodiac/chains'
import { getRolesWaypoint } from '@zodiac/modules'
import type { ExecutionRoute } from '@zodiac/schema'
import type { JsonRpcProvider } from 'ethers'
import { AccountType, parsePrefixedAddress } from 'ser-kit'
import { decodeGenericError } from './decodeError'
import { handleRolesV1Error } from './handleRolesV1Error'
import { handleRolesV2Error } from './handleRolesV2Error'
import { isSmartContractAddress } from './isSmartContractAddress'
import { isJsonRpcError } from './JsonRpcError'
import { maybeGetRoleId } from './maybeGetRoleId'
import { wrapRequest } from './wrapRequest'

type Error = { error: true; message: string }
type Success = { error: false }

type Result = Error | Success

export async function dryRun(
provider: JsonRpcProvider,
route: ExecutionRoute,
): Promise<Result> {
if (
route.initiator == null ||
parsePrefixedAddress(route.initiator) === ZERO_ADDRESS
) {
return { error: true, message: 'This route is not connected to a wallet.' }
}

if (parsePrefixedAddress(route.avatar) === ZERO_ADDRESS) {
return { error: true, message: 'This route has no target.' }
}

const rolesWaypoint = getRolesWaypoint(route)

if (
rolesWaypoint != null &&
!(await isSmartContractAddress(rolesWaypoint.account.address, provider))
) {
return { error: true, message: 'Module address is not a smart contract.' }
}

if (
!(await isSmartContractAddress(
parsePrefixedAddress(route.avatar),
provider,
))
) {
return { error: true, message: 'Avatar is not a smart contract.' }
}

const request = wrapRequest({
request: {
to: ZERO_ADDRESS,
data: '0x00000000',
from: parsePrefixedAddress(route.avatar),
},
route,
revertOnError: false,
})

// TODO enable this once we can query role members from ser
// if (!request.from && connection.roleId) {
// // If pilotAddress is not yet determined, we will use a random member of the specified role
// request.from = await getRoleMember(connection)
// }

try {
await provider.estimateGas(request)

return { error: false }
} catch (error) {
if (!isJsonRpcError(error)) {
return { error: true, message: 'Unknown dry run error.' }
}

if (
rolesWaypoint == null ||
rolesWaypoint.account.type !== AccountType.ROLES
) {
return { error: true, message: decodeGenericError(error) }
}
// For the Roles mod, we actually expect the dry run to fail with TargetAddressNotAllowed()
// In case we see any other error, we try to help the user identify the problem.

const { account } = rolesWaypoint

switch (account.version) {
case 1: {
const message = handleRolesV1Error(
error,
maybeGetRoleId(rolesWaypoint) ?? '0',
)

if (message == null) {
return { error: false }
}

return {
error: true,
message,
}
}
case 2: {
const message = handleRolesV2Error(
error,
maybeGetRoleId(rolesWaypoint) ?? '0',
)

if (message == null) {
return { error: false }
}

return {
error: true,
message,
}
}
default: {
console.warn('Unexpected dry run error', error)

return { error: true, message: 'Unexpected dry run error' }
}
}
}
}
Loading

0 comments on commit ce3045d

Please sign in to comment.