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
This commit is contained in:
tom goriunov
2025-09-24 18:48:28 +02:00
committed by GitHub
parent 08829c6904
commit e18e02c910
24 changed files with 162 additions and 79 deletions
@@ -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 });
});
};
+2 -2
View File
@@ -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: {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

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>
);
};
+1
View File
@@ -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 ]);