User Operations: add switcher to call data (#1950)
* User Operations: add switcher to call data Fixes #1762 * update screenshots * move toggler above decoded method signature on mobile
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-len */
|
||||
import type { UserOp } from 'types/api/userOps';
|
||||
|
||||
export const userOpData: UserOp = {
|
||||
@@ -47,7 +48,6 @@ export const userOpData: UserOp = {
|
||||
max_fee_per_gas: '1575000898',
|
||||
max_priority_fee_per_gas: '1575000898',
|
||||
nonce: '79',
|
||||
// eslint-disable-next-line max-len
|
||||
paymaster_and_data: '0x7cea357b5ac0639f89f9e378a1f03aa5005c0a250000000000000000000000000000000000000000000000000000000065b3a8800000000000000000000000000000000000000000000000000000000065aa6e0028fa4c57ac1141bc9ecd8c9243f618ade8ea1db10ab6c1d1798a222a824764ff2269a72ae7a3680fa8b03a80d8a00cdc710eaf37afdcc55f8c9c4defa3fdf2471b',
|
||||
pre_verification_gas: '48396',
|
||||
sender: '0xF0C14FF4404b188fAA39a3507B388998c10FE284',
|
||||
@@ -64,8 +64,34 @@ export const userOpData: UserOp = {
|
||||
is_verified: null,
|
||||
name: null,
|
||||
},
|
||||
// eslint-disable-next-line max-len
|
||||
call_data: '0xb61d27f600000000000000000000000059f6aa952df7f048fd076e33e0ea8bb552d5ffd8000000000000000000000000000000000000000000000000003f3d017500800000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000',
|
||||
execute_call_data: '0x3cf80e6c',
|
||||
decoded_call_data: {
|
||||
method_call: 'execute(address dest, uint256 value, bytes func)',
|
||||
method_id: 'b61d27f6',
|
||||
parameters: [
|
||||
{
|
||||
name: 'dest',
|
||||
type: 'address',
|
||||
value: '0xb0ccffd05f5a87c4c3ceffaa217900422a249915',
|
||||
},
|
||||
{
|
||||
name: 'value',
|
||||
type: 'uint256',
|
||||
value: '0',
|
||||
},
|
||||
{
|
||||
name: 'func',
|
||||
type: 'bytes',
|
||||
value: '0x3cf80e6c',
|
||||
},
|
||||
],
|
||||
},
|
||||
decoded_execute_call_data: {
|
||||
method_call: 'advanceEpoch()',
|
||||
method_id: '3cf80e6c',
|
||||
parameters: [],
|
||||
},
|
||||
paymaster: {
|
||||
ens_domain_name: null,
|
||||
hash: '0x7ceA357B5AC0639F89F9e378a1f03Aa5005C0a25',
|
||||
|
||||
@@ -21,6 +21,9 @@ export const USER_OP: UserOp = {
|
||||
sender: ADDRESS_HASH,
|
||||
nonce: '0x00b',
|
||||
call_data: '0x123',
|
||||
execute_call_data: null,
|
||||
decoded_call_data: null,
|
||||
decoded_execute_call_data: null,
|
||||
call_gas_limit: '71316',
|
||||
verification_gas_limit: '91551',
|
||||
pre_verification_gas: '53627',
|
||||
|
||||
@@ -15,6 +15,9 @@ const baseStyleTrack = defineStyle((props) => {
|
||||
bg: mode(`${ c }.600`, `${ c }.400`)(props),
|
||||
},
|
||||
},
|
||||
_focusVisible: {
|
||||
boxShadow: 'none',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AddressParamBasic } from './addressParams';
|
||||
import type { DecodedInput } from './decodedInput';
|
||||
|
||||
export type UserOpsItem = {
|
||||
hash: string;
|
||||
@@ -46,6 +47,9 @@ export type UserOp = {
|
||||
signature: string;
|
||||
nonce: string;
|
||||
call_data: string;
|
||||
decoded_call_data: DecodedInput | null;
|
||||
execute_call_data: string | null;
|
||||
decoded_execute_call_data: DecodedInput | null;
|
||||
user_logs_start_index: number;
|
||||
user_logs_count: number;
|
||||
raw: {
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
@@ -21,6 +21,7 @@ test('base view', async({ render, mockTextAd, mockApiResponse }) => {
|
||||
await mockTextAd();
|
||||
await mockApiResponse('user_op', userOpData, { pathParams: { hash: userOpData.hash } });
|
||||
const component = await render(<UserOp/>, { hooksConfig });
|
||||
await component.getByText('View details').click();
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
@@ -31,6 +32,7 @@ test.describe('mobile', () => {
|
||||
await mockTextAd();
|
||||
await mockApiResponse('user_op', userOpData, { pathParams: { hash: userOpData.hash } });
|
||||
const component = await render(<UserOp/>, { hooksConfig });
|
||||
await component.getByText('View details').click();
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 176 KiB |
@@ -9,25 +9,29 @@ const OPTIONS: Array<DataType> = [ 'Hex', 'UTF-8' ];
|
||||
|
||||
interface Props {
|
||||
hex: string;
|
||||
rightSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
const RawInputData = ({ hex }: Props) => {
|
||||
const RawInputData = ({ hex, rightSlot: rightSlotProp }: Props) => {
|
||||
const [ selectedDataType, setSelectedDataType ] = React.useState<DataType>('Hex');
|
||||
|
||||
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSelectedDataType(event.target.value as DataType);
|
||||
}, []);
|
||||
|
||||
const select = (
|
||||
<Select size="xs" borderRadius="base" value={ selectedDataType } onChange={ handleSelectChange } w="auto" mr="auto">
|
||||
{ OPTIONS.map((option) => <option key={ option } value={ option }>{ option }</option>) }
|
||||
</Select>
|
||||
const rightSlot = (
|
||||
<>
|
||||
<Select size="xs" borderRadius="base" value={ selectedDataType } onChange={ handleSelectChange } w="auto" mr="auto">
|
||||
{ OPTIONS.map((option) => <option key={ option } value={ option }>{ option }</option>) }
|
||||
</Select>
|
||||
{ rightSlotProp }
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<RawDataSnippet
|
||||
data={ selectedDataType === 'Hex' ? hex : hexToUtf8(hex) }
|
||||
rightSlot={ select }
|
||||
rightSlot={ rightSlot }
|
||||
textareaMaxHeight="220px"
|
||||
textareaMinHeight="160px"
|
||||
w="100%"
|
||||
|
||||
@@ -2,17 +2,27 @@ import React from 'react';
|
||||
|
||||
import type { DecodedInput } from 'types/api/decodedInput';
|
||||
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
|
||||
import LogDecodedInputDataHeader from './LogDecodedInputDataHeader';
|
||||
import LogDecodedInputDataTable from './LogDecodedInputDataTable';
|
||||
interface Props {
|
||||
data: DecodedInput;
|
||||
isLoading?: boolean;
|
||||
rightSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
const LogDecodedInputData = ({ data, isLoading }: Props) => {
|
||||
const LogDecodedInputData = ({ data, isLoading, rightSlot }: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<>
|
||||
<LogDecodedInputDataHeader methodId={ data.method_id } methodCall={ data.method_call } isLoading={ isLoading }/>
|
||||
{ isMobile ? rightSlot : null }
|
||||
<LogDecodedInputDataHeader
|
||||
methodId={ data.method_id }
|
||||
methodCall={ data.method_call }
|
||||
isLoading={ isLoading }
|
||||
rightSlot={ isMobile ? null : rightSlot }
|
||||
/>
|
||||
{ data.parameters.length > 0 && <LogDecodedInputDataTable data={ data.parameters } isLoading={ isLoading }/> }
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ interface Props {
|
||||
methodId: string;
|
||||
methodCall: string;
|
||||
isLoading?: boolean;
|
||||
rightSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Item = ({ label, children, isLoading }: { label: string; children: React.ReactNode; isLoading?: boolean}) => {
|
||||
@@ -16,7 +17,7 @@ const Item = ({ label, children, isLoading }: { label: string; children: React.R
|
||||
rowGap={ 2 }
|
||||
px={{ base: 0, lg: 4 }}
|
||||
flexDir={{ base: 'column', lg: 'row' }}
|
||||
alignItems="flex-start"
|
||||
alignItems={{ base: 'flex-start', lg: 'center' }}
|
||||
>
|
||||
<Skeleton fontWeight={ 600 } w={{ base: 'auto', lg: '80px' }} flexShrink={ 0 } isLoaded={ !isLoading }>
|
||||
{ label }
|
||||
@@ -26,17 +27,21 @@ const Item = ({ label, children, isLoading }: { label: string; children: React.R
|
||||
);
|
||||
};
|
||||
|
||||
const LogDecodedInputDataHeader = ({ methodId, methodCall, isLoading }: Props) => {
|
||||
const LogDecodedInputDataHeader = ({ methodId, methodCall, isLoading, rightSlot }: Props) => {
|
||||
return (
|
||||
<VStack
|
||||
align="flex-start"
|
||||
divider={ <Divider/> }
|
||||
fontSize="sm"
|
||||
lineHeight={ 5 }
|
||||
flexGrow={ 1 }
|
||||
>
|
||||
<Item label="Method id" isLoading={ isLoading }>
|
||||
<Tag isLoading={ isLoading }>{ methodId }</Tag>
|
||||
</Item>
|
||||
<Flex columnGap={ 2 } w="100%">
|
||||
<Item label="Method id" isLoading={ isLoading }>
|
||||
<Tag isLoading={ isLoading }>{ methodId }</Tag>
|
||||
</Item>
|
||||
{ rightSlot }
|
||||
</Flex>
|
||||
<Item label="Call" isLoading={ isLoading }>
|
||||
<Skeleton isLoaded={ !isLoading } whiteSpace="pre-wrap">{ methodCall }</Skeleton>
|
||||
</Item>
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { UserOp } from 'types/api/userOps';
|
||||
|
||||
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
|
||||
import RawInputData from 'ui/shared/RawInputData';
|
||||
|
||||
import UserOpCallDataSwitch from './UserOpCallDataSwitch';
|
||||
|
||||
interface Props {
|
||||
data: UserOp;
|
||||
}
|
||||
|
||||
const UserOpDecodedCallData = ({ data }: Props) => {
|
||||
|
||||
const [ callData, setCallData ] = React.useState(data.call_data || data.execute_call_data);
|
||||
|
||||
const handleSwitchChange = React.useCallback((isChecked: boolean) => {
|
||||
setCallData(isChecked ? data.execute_call_data : data.call_data);
|
||||
}, [ data ]);
|
||||
|
||||
if (!callData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggler = data.execute_call_data ? (
|
||||
<UserOpCallDataSwitch
|
||||
onChange={ handleSwitchChange }
|
||||
initialValue={ !data.call_data }
|
||||
isDisabled={ !data.call_data }
|
||||
ml={{ base: 3, lg: 'auto' }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailsInfoItem
|
||||
title="Call data"
|
||||
hint="Data that’s passed to the sender for execution"
|
||||
>
|
||||
<RawInputData hex={ callData } rightSlot={ toggler }/>
|
||||
</DetailsInfoItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(UserOpDecodedCallData);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { chakra, FormLabel, FormControl, Switch } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import Hint from 'ui/shared/Hint';
|
||||
|
||||
interface Props {
|
||||
onChange: (isChecked: boolean) => void;
|
||||
initialValue?: boolean;
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const UserOpCallDataSwitch = ({ className, initialValue, isDisabled, onChange }: Props) => {
|
||||
const [ isChecked, setIsChecked ] = React.useState(initialValue ?? false);
|
||||
|
||||
const handleChange = React.useCallback(() => {
|
||||
setIsChecked((prevValue) => {
|
||||
const nextValue = !prevValue;
|
||||
onChange(nextValue);
|
||||
return nextValue;
|
||||
});
|
||||
}, [ onChange ]);
|
||||
|
||||
return (
|
||||
<FormControl className={ className } display="flex" columnGap={ 2 } ml="auto" w="fit-content">
|
||||
<FormLabel htmlFor="isExternal" fontSize="sm" lineHeight={ 5 } fontWeight={ 600 } m={ 0 }>
|
||||
Show external call data
|
||||
</FormLabel>
|
||||
<Switch id="isExternal" isChecked={ isChecked } isDisabled={ isDisabled } onChange={ handleChange }/>
|
||||
<Hint label="Inner call data is a predicted decoded call from this user operation"/>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(chakra(UserOpCallDataSwitch));
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { UserOp } from 'types/api/userOps';
|
||||
|
||||
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
|
||||
import LogDecodedInputData from 'ui/shared/logs/LogDecodedInputData';
|
||||
|
||||
import UserOpCallDataSwitch from './UserOpCallDataSwitch';
|
||||
|
||||
interface Props {
|
||||
data: UserOp;
|
||||
}
|
||||
|
||||
const UserOpDecodedCallData = ({ data }: Props) => {
|
||||
|
||||
const [ callData, setCallData ] = React.useState(data.decoded_call_data || data.decoded_execute_call_data);
|
||||
|
||||
const handleSwitchChange = React.useCallback((isChecked: boolean) => {
|
||||
setCallData(isChecked ? data.decoded_execute_call_data : data.decoded_call_data);
|
||||
}, [ data ]);
|
||||
|
||||
if (!callData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggler = data.decoded_execute_call_data ? (
|
||||
<UserOpCallDataSwitch
|
||||
onChange={ handleSwitchChange }
|
||||
initialValue={ !data.decoded_call_data }
|
||||
isDisabled={ !data.decoded_call_data }
|
||||
ml={{ base: 0, lg: 'auto' }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<DetailsInfoItem
|
||||
title="Decoded call data"
|
||||
hint="Decoded call data"
|
||||
flexDir={{ base: 'column', lg: 'row' }}
|
||||
alignItems={{ base: 'flex-start', lg: 'center' }}
|
||||
>
|
||||
<LogDecodedInputData data={ callData } rightSlot={ toggler }/>
|
||||
</DetailsInfoItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(UserOpDecodedCallData);
|
||||
@@ -22,11 +22,12 @@ import AddressStringOrParam from 'ui/shared/entities/address/AddressStringOrPara
|
||||
import BlockEntity from 'ui/shared/entities/block/BlockEntity';
|
||||
import TxEntity from 'ui/shared/entities/tx/TxEntity';
|
||||
import UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
|
||||
import RawInputData from 'ui/shared/RawInputData';
|
||||
import UserOpSponsorType from 'ui/shared/userOps/UserOpSponsorType';
|
||||
import UserOpStatus from 'ui/shared/userOps/UserOpStatus';
|
||||
import Utilization from 'ui/shared/Utilization/Utilization';
|
||||
|
||||
import UserOpCallData from './UserOpCallData';
|
||||
import UserOpDecodedCallData from './UserOpDecodedCallData';
|
||||
import UserOpDetailsActions from './UserOpDetailsActions';
|
||||
|
||||
interface Props {
|
||||
@@ -300,12 +301,10 @@ const UserOpDetails = ({ query }: Props) => {
|
||||
>
|
||||
{ data.nonce }
|
||||
</DetailsInfoItem>
|
||||
<DetailsInfoItem
|
||||
title="Call data"
|
||||
hint="Data that’s passed to the sender for execution"
|
||||
>
|
||||
<RawInputData hex={ data.call_data }/>
|
||||
</DetailsInfoItem>
|
||||
|
||||
<UserOpCallData data={ data }/>
|
||||
|
||||
<UserOpDecodedCallData data={ data }/>
|
||||
</>
|
||||
) }
|
||||
</Grid>
|
||||
|
||||