Contract interactions: estimated gas for write methods simulation (#3004)
* Contract interactions: simulate transactions and output gas price Resolves #2910 * prevent form with tuples and arrays from being submitted without required fields * update screenshots * add simulate button to receive method
@@ -8,13 +8,16 @@ interface Params {
|
||||
abiItem: AbiFunction;
|
||||
args?: Array<unknown>;
|
||||
address: string;
|
||||
result: Array<unknown>;
|
||||
result: unknown;
|
||||
noResultEncoding?: boolean;
|
||||
rpcMethod?: string;
|
||||
times?: number;
|
||||
}
|
||||
|
||||
export type MockContractReadResponseFixture = (params: Params) => Promise<void>;
|
||||
|
||||
const fixture: TestFixture<MockContractReadResponseFixture, { page: Page }> = async({ page }, use) => {
|
||||
await use(async({ abiItem, args = [], address, result }) => {
|
||||
await use(async({ abiItem, args = [], address, result, rpcMethod = 'eth_call', times = 1, noResultEncoding = false }) => {
|
||||
const rpcUrl = getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL');
|
||||
|
||||
if (!rpcUrl) {
|
||||
@@ -43,21 +46,21 @@ const fixture: TestFixture<MockContractReadResponseFixture, { page: Page }> = as
|
||||
value: params?.value,
|
||||
};
|
||||
|
||||
if (isEqual(params, callParams) && id) {
|
||||
if (isEqual(params, callParams) && id !== undefined && json?.method === rpcMethod) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
json: {
|
||||
id,
|
||||
jsonrpc: '2.0',
|
||||
result: encodeFunctionResult({
|
||||
result: noResultEncoding ? result : encodeFunctionResult({
|
||||
abi: [ abiItem ],
|
||||
functionName: abiItem.name,
|
||||
result,
|
||||
result: result as never,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}, { times });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ test.describe('ABI functionality', () => {
|
||||
await expect(component.getByLabel('9.').getByRole('button', { name: 'Write' })).toBeEnabled();
|
||||
|
||||
await component.getByText('pause').click();
|
||||
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden();
|
||||
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
|
||||
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ test.describe('ABI functionality', () => {
|
||||
await expect(component.getByLabel('9.').getByRole('button', { name: 'Write' })).toBeDisabled();
|
||||
|
||||
await component.getByText('pause').click();
|
||||
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden();
|
||||
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
|
||||
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import type { Abi } from 'viem';
|
||||
import type { Abi, AbiFunction } from 'viem';
|
||||
|
||||
import * as addressMock from 'mocks/address/address';
|
||||
import * as methodsMock from 'mocks/contract/methods';
|
||||
@@ -11,10 +11,6 @@ import ContractMethodsRegular from './ContractMethodsRegular';
|
||||
const addressHash = addressMock.hash;
|
||||
|
||||
test('can read method', async({ render, mockContractReadResponse }) => {
|
||||
// for some reason it takes a long time for "wagmi" library to parse response result in the test environment
|
||||
// so I had to increase the test timeout
|
||||
test.slow();
|
||||
|
||||
const hooksConfig = {
|
||||
router: {
|
||||
query: { hash: addressHash, tab: 'read_contract' },
|
||||
@@ -33,6 +29,40 @@ test('can read method', async({ render, mockContractReadResponse }) => {
|
||||
await expect(component.getByText('USDC')).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
test('can simulate method', async({ render, mockContractReadResponse }) => {
|
||||
const ADDRESS = '0x0000000000000068F116a894984e2DB1123eB395';
|
||||
const AMOUNT = '123';
|
||||
const abiItem = methodsMock.write[1] as AbiFunction;
|
||||
|
||||
const hooksConfig = {
|
||||
router: {
|
||||
query: { hash: addressHash, tab: 'write_contract' },
|
||||
},
|
||||
};
|
||||
|
||||
await mockContractReadResponse({
|
||||
abiItem,
|
||||
address: addressHash,
|
||||
args: [ ADDRESS, AMOUNT ],
|
||||
rpcMethod: 'eth_estimateGas',
|
||||
result: 42000,
|
||||
noResultEncoding: true,
|
||||
});
|
||||
await mockContractReadResponse({
|
||||
abiItem,
|
||||
address: addressHash,
|
||||
args: [ ADDRESS, AMOUNT ],
|
||||
result: true,
|
||||
});
|
||||
|
||||
const component = await render(<ContractMethodsRegular abi={ [ abiItem ] }/>, { hooksConfig });
|
||||
await component.getByPlaceholder('address').last().fill(ADDRESS);
|
||||
await component.getByPlaceholder('uint256').last().fill(AMOUNT);
|
||||
await component.getByText(/simulate/i).click();
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test('all methods +@dark-mode', async({ render }) => {
|
||||
const hooksConfig = {
|
||||
router: {
|
||||
|
||||
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -20,6 +20,8 @@ const ContractMethodArrayButton = ({ type, index, onClick, ...props }: Props) =>
|
||||
|
||||
return (
|
||||
<Button
|
||||
as="div"
|
||||
role="button"
|
||||
data-index={ index }
|
||||
size="2xs_alt"
|
||||
onClick={ handleClick }
|
||||
|
||||
@@ -20,7 +20,7 @@ const ContractMethodFieldAccordion = ({ label, level, children, onAddClick, onRe
|
||||
const bgColor = { _light: 'whiteAlpha.700', _dark: 'blackAlpha.700' };
|
||||
|
||||
return (
|
||||
<AccordionRoot w="100%" bgColor={ level === 0 ? bgColorLevel0 : bgColor } borderRadius="base" lazyMount>
|
||||
<AccordionRoot w="100%" bgColor={ level === 0 ? bgColorLevel0 : bgColor } borderRadius="base">
|
||||
<AccordionItem value="default" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
|
||||
<AccordionItemTrigger
|
||||
indicatorPlacement="start"
|
||||
@@ -30,7 +30,7 @@ const ContractMethodFieldAccordion = ({ label, level, children, onAddClick, onRe
|
||||
textAlign="left"
|
||||
_hover={{ bgColor: 'inherit' }}
|
||||
>
|
||||
<Box textStyle="sm" fontWeight={ 700 } mr="auto" color={ isInvalid ? 'error' : undefined }>
|
||||
<Box textStyle="sm" fontWeight={ 700 } mr="auto" color={ isInvalid ? 'text.error' : undefined }>
|
||||
{ label }
|
||||
</Box>
|
||||
{ onRemoveClick && index !== undefined && <ContractMethodArrayButton index={ index } onClick={ onRemoveClick } type="remove"/> }
|
||||
|
||||
@@ -177,11 +177,6 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props)
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasOutputs = 'outputs' in data && data.outputs.length > 0;
|
||||
if (!hasOutputs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = 'Simulate';
|
||||
const buttonCallStrategy = 'simulate';
|
||||
|
||||
@@ -295,7 +290,7 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props)
|
||||
return <ContractMethodFieldInput key={ index } { ...props } path={ `${ index }` }/>;
|
||||
}) }
|
||||
</Flex>
|
||||
<Flex flexDir="row" gap={ 3 }>
|
||||
<Flex flexDir="row" gap={ 3 } flexWrap="wrap">
|
||||
{ secondaryButton }
|
||||
{ primaryButton }
|
||||
{ copyCallDataButton }
|
||||
@@ -313,25 +308,29 @@ const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props)
|
||||
</Flex>
|
||||
</chakra.form>
|
||||
</FormProvider>
|
||||
{ result && result.source === 'wallet_client' && (
|
||||
<ContractMethodResultWalletClient
|
||||
data={ result.data }
|
||||
onSettle={ handleResultSettle }
|
||||
/>
|
||||
) }
|
||||
{ result && result.source === 'public_client' && result.data instanceof Error && (
|
||||
<ContractMethodResultPublicClient
|
||||
data={ result.data }
|
||||
/>
|
||||
) }
|
||||
{ 'outputs' in data && data.outputs.length > 0 && (
|
||||
<ContractMethodOutput
|
||||
data={ showOutputResult ? result.data : undefined }
|
||||
onSettle={ handleResultSettle }
|
||||
abiItem={ data }
|
||||
mode={ showOutputResult ? 'result' : 'preview' }
|
||||
/>
|
||||
) }
|
||||
<Flex flexDir="column" rowGap={{ base: 1, lg: 2 }} mt={ 3 } _empty={{ display: 'none' }}>
|
||||
{ result && result.source === 'wallet_client' && (
|
||||
<ContractMethodResultWalletClient
|
||||
data={ result.data }
|
||||
onSettle={ handleResultSettle }
|
||||
/>
|
||||
) }
|
||||
{ result && result.source === 'public_client' && (
|
||||
<ContractMethodResultPublicClient
|
||||
data={ result.data }
|
||||
estimatedGas={ result.estimatedGas }
|
||||
onSettle={ handleResultSettle }
|
||||
/>
|
||||
) }
|
||||
{ 'outputs' in data && data.outputs.length > 0 && (
|
||||
<ContractMethodOutput
|
||||
data={ showOutputResult ? result.data : undefined }
|
||||
onSettle={ handleResultSettle }
|
||||
abiItem={ data }
|
||||
mode={ showOutputResult ? 'result' : 'preview' }
|
||||
/>
|
||||
) }
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import type { AbiFunction } from 'viem';
|
||||
|
||||
import type { AbiFallback, ResultViewMode } from '../types';
|
||||
|
||||
import { Alert } from 'toolkit/chakra/alert';
|
||||
|
||||
import ResultItem from './resultPublicClient/Item';
|
||||
|
||||
export interface Props {
|
||||
@@ -25,19 +26,18 @@ const ContractMethodOutput = ({ data, abiItem, onSettle, mode }: Props) => {
|
||||
})();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
<Alert
|
||||
status="info"
|
||||
flexDir="column"
|
||||
rowGap={ 2 }
|
||||
mt={ 3 }
|
||||
px={ 3 }
|
||||
py={ 2 }
|
||||
borderRadius="md"
|
||||
bgColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }}
|
||||
color={ mode === 'preview' ? 'gray.500' : undefined }
|
||||
fontSize="sm"
|
||||
lineHeight="20px"
|
||||
whiteSpace="break-spaces"
|
||||
wordBreak="break-all"
|
||||
textStyle="sm"
|
||||
descriptionProps={{
|
||||
flexDir: 'column',
|
||||
alignItems: 'flex-start',
|
||||
rowGap: 2,
|
||||
whiteSpace: 'break-spaces',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{ abiItem.outputs.map((output, index) => (
|
||||
<ResultItem
|
||||
@@ -47,7 +47,7 @@ const ContractMethodOutput = ({ data, abiItem, onSettle, mode }: Props) => {
|
||||
mode={ mode }
|
||||
/>
|
||||
)) }
|
||||
</Flex>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,16 +3,34 @@ import React from 'react';
|
||||
import { Alert } from 'toolkit/chakra/alert';
|
||||
|
||||
export interface Props {
|
||||
data: Error;
|
||||
data: unknown;
|
||||
onSettle: () => void;
|
||||
estimatedGas?: bigint;
|
||||
}
|
||||
|
||||
const ContractMethodResultPublicClient = ({ data }: Props) => {
|
||||
const ContractMethodResultPublicClient = ({ data, estimatedGas, onSettle }: Props) => {
|
||||
|
||||
return (
|
||||
<Alert status="error" mt={ 3 } textStyle="sm" wordBreak="break-word" whiteSpace="pre-wrap">
|
||||
{ 'shortMessage' in data && typeof data.shortMessage === 'string' ? data.shortMessage : data.message }
|
||||
</Alert>
|
||||
);
|
||||
React.useEffect(() => {
|
||||
onSettle();
|
||||
}, [ onSettle ]);
|
||||
|
||||
if (data instanceof Error) {
|
||||
return (
|
||||
<Alert status="error" textStyle="sm" wordBreak="break-word" whiteSpace="pre-wrap">
|
||||
{ 'shortMessage' in data && typeof data.shortMessage === 'string' ? data.shortMessage : data.message }
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (estimatedGas) {
|
||||
return (
|
||||
<Alert status="info" textStyle="sm" wordBreak="break-word" whiteSpace="pre-wrap">
|
||||
Success. Estimated gas: { estimatedGas.toLocaleString() }
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default React.memo(ContractMethodResultPublicClient);
|
||||
|
||||
@@ -93,11 +93,9 @@ export const ContractMethodResultWalletClientDumb = ({ data, onSettle, txInfo }:
|
||||
return (
|
||||
<Box
|
||||
textStyle="sm"
|
||||
mt={ 3 }
|
||||
alignItems="center"
|
||||
whiteSpace="pre-wrap"
|
||||
wordBreak="break-all"
|
||||
color={ txInfo.status === 'error' || isErrorResult ? 'error' : undefined }
|
||||
>
|
||||
{ content }
|
||||
</Box>
|
||||
|
||||
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,3 +1,4 @@
|
||||
import { chakra } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import type { AbiParameter } from 'viem';
|
||||
|
||||
@@ -67,15 +68,15 @@ const ItemArray = ({ abiParameter, data, level, arrayMatch, mode }: Props) => {
|
||||
})();
|
||||
|
||||
return (
|
||||
<p>
|
||||
<p>
|
||||
<chakra.span display="block">
|
||||
<chakra.span display="block">
|
||||
<span>{ printRowOffset(level) }</span>
|
||||
<ItemLabel abiParameter={ abiParameter }/>
|
||||
<span>[{ data.length === 0 ? ' ]' : '' }</span>
|
||||
</p>
|
||||
</chakra.span>
|
||||
{ content }
|
||||
{ data.length > 0 && <p>{ printRowOffset(level) }]</p> }
|
||||
</p>
|
||||
</chakra.span>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chakra } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import type { AbiParameter } from 'viem';
|
||||
|
||||
@@ -48,11 +49,11 @@ const ItemPrimitive = ({ abiParameter, data, level, hideLabel }: Props) => {
|
||||
})();
|
||||
|
||||
return (
|
||||
<p>
|
||||
<chakra.span display="block">
|
||||
<span>{ printRowOffset(level) }</span>
|
||||
{ !hideLabel && <ItemLabel abiParameter={ abiParameter }/> }
|
||||
{ value }
|
||||
</p>
|
||||
</chakra.span>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,12 +16,12 @@ interface Props {
|
||||
|
||||
const ItemTuple = ({ abiParameter, data, mode, level }: Props) => {
|
||||
return (
|
||||
<p>
|
||||
<p>
|
||||
<chakra.span display="block">
|
||||
<chakra.span display="block">
|
||||
<span>{ printRowOffset(level) }</span>
|
||||
<chakra.span fontWeight={ 500 }>{ abiParameter.name || abiParameter.internalType }</chakra.span>
|
||||
<span> { '{' }</span>
|
||||
</p>
|
||||
</chakra.span>
|
||||
{ 'components' in abiParameter && abiParameter.components.map((component, index) => {
|
||||
const itemData = (() => {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
@@ -47,8 +47,8 @@ const ItemTuple = ({ abiParameter, data, mode, level }: Props) => {
|
||||
/>
|
||||
);
|
||||
}) }
|
||||
<p>{ printRowOffset(level) }{ '}' }</p>
|
||||
</p>
|
||||
<chakra.span display="block">{ printRowOffset(level) }{ '}' }</chakra.span>
|
||||
</chakra.span>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export type SmartContractMethod = SmartContractMethodRead | SmartContractMethodW
|
||||
export interface FormSubmitResultPublicClient {
|
||||
source: 'public_client';
|
||||
data: unknown | Error;
|
||||
estimatedGas?: bigint;
|
||||
}
|
||||
export interface FormSubmitResultWalletClient {
|
||||
source: 'wallet_client';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { getAddress } from 'viem';
|
||||
import { encodeFunctionData, getAddress } from 'viem';
|
||||
import { usePublicClient } from 'wagmi';
|
||||
|
||||
import type { FormSubmitResult, MethodCallStrategy, SmartContractMethod } from './types';
|
||||
@@ -24,10 +24,6 @@ export default function useCallMethodPublicClient(): (params: Params) => Promise
|
||||
const { address: account } = useAccount();
|
||||
|
||||
return React.useCallback(async({ args, item, addressHash, strategy }) => {
|
||||
if (item.type === 'receive') {
|
||||
throw new Error('Incorrect contract method');
|
||||
}
|
||||
|
||||
if (!publicClient) {
|
||||
throw new Error('Public Client is not defined');
|
||||
}
|
||||
@@ -47,12 +43,33 @@ export default function useCallMethodPublicClient(): (params: Params) => Promise
|
||||
account,
|
||||
to: address,
|
||||
value,
|
||||
...(data ? { data } : {}),
|
||||
data,
|
||||
});
|
||||
const estimatedGas = await publicClient.estimateGas({
|
||||
account,
|
||||
to: address,
|
||||
value,
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
source: 'public_client' as const,
|
||||
data: result.data,
|
||||
estimatedGas,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.type === 'receive') {
|
||||
const estimatedGas = await publicClient.estimateGas({
|
||||
account,
|
||||
to: address,
|
||||
value,
|
||||
});
|
||||
|
||||
return {
|
||||
source: 'public_client' as const,
|
||||
data: undefined,
|
||||
estimatedGas,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,11 +81,24 @@ export default function useCallMethodPublicClient(): (params: Params) => Promise
|
||||
account,
|
||||
value,
|
||||
};
|
||||
const paramsForGasEstimate = {
|
||||
account,
|
||||
value,
|
||||
to: address,
|
||||
data: encodeFunctionData({
|
||||
abi: [ item ],
|
||||
functionName: item.name,
|
||||
args: _args,
|
||||
}),
|
||||
};
|
||||
|
||||
const result = strategy === 'read' ? await publicClient.readContract(params) : await publicClient.simulateContract(params);
|
||||
const estimatedGas = strategy === 'simulate' ? await publicClient.estimateGas(paramsForGasEstimate) : undefined;
|
||||
|
||||
return {
|
||||
source: 'public_client' as const,
|
||||
data: strategy === 'read' ? result : result.result,
|
||||
estimatedGas,
|
||||
};
|
||||
|
||||
}, [ account, publicClient ]);
|
||||
|
||||