Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/four-eggs-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@venusprotocol/evm": minor
---

feat: support display contract error message for details
2 changes: 1 addition & 1 deletion apps/evm/src/components/Notice/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const Notice = ({
{title && <p className="text-sm font-semibold">{title}</p>}

{!!description && (
<p className={cn('text-xs', size === 'md' && 'md:text-sm')}>{description}</p>
<div className={cn('text-xs', size === 'md' && 'md:text-sm')}>{description}</div>
)}
</div>
</div>
Expand Down
61 changes: 61 additions & 0 deletions apps/evm/src/libs/errors/ContractErrorNotice/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { QuaternaryButton, cn } from '@venusprotocol/ui';
import copyToClipboard from 'copy-to-clipboard';
import { useState } from 'react';

import { useTranslation } from 'libs/translations';

export interface ContractErrorNoticeProps {
friendlyPhrase?: string;
errorName: string;
signature?: string;
rawMessage: string;
}

export const ContractErrorNotice: React.FC<ContractErrorNoticeProps> = ({
friendlyPhrase,
errorName,
signature,
rawMessage,
}) => {
const { t } = useTranslation();
const [isRawVisible, setIsRawVisible] = useState(false);

const hasFriendlyPhrase = !!friendlyPhrase;
const headline = friendlyPhrase ?? t('contractErrors.notice.fallback');

return (
<div className="space-y-2">
<p className="text-xs text-white md:text-sm">{headline}</p>

{!hasFriendlyPhrase && (
<div className="rounded-lg border border-dark-grey-hover bg-background/50 px-3 py-2 font-mono text-xs break-all">
<span className="text-light-grey">{t('contractErrors.notice.errorLabel')}: </span>
<span className="text-white">{errorName}</span>
{signature && <span className="text-light-grey"> ({signature})</span>}
</div>
)}

<div className="flex flex-wrap gap-2">
<QuaternaryButton size="xs" onClick={() => copyToClipboard(rawMessage)}>
{t('contractErrors.notice.copyDetails')}
</QuaternaryButton>
<QuaternaryButton size="xs" onClick={() => setIsRawVisible(prev => !prev)}>
{isRawVisible
? t('contractErrors.notice.hideRawError')
: t('contractErrors.notice.showRawError')}
</QuaternaryButton>
</div>

{isRawVisible && (
<pre
className={cn(
'max-h-60 overflow-auto rounded-lg border border-dark-grey-hover bg-background/50 p-3',
'font-mono text-xs text-white whitespace-pre-wrap break-all',
)}
>
{rawMessage}
</pre>
)}
</div>
);
};
11 changes: 11 additions & 0 deletions apps/evm/src/libs/errors/customErrorPhrases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { t } from 'libs/translations';

export const customErrorPhrases: Record<string, string> = {
ActionPaused: t('contractErrors.actionPaused'),
InsufficientLiquidity: t('contractErrors.insufficientLiquidity'),
InsufficientCollateral: t('contractErrors.insufficientCollateral'),
SupplyCapExceeded: t('contractErrors.supplyCapExceeded'),
BorrowCapExceeded: t('contractErrors.borrowCapExceeded'),
TooMuchRepay: t('contractErrors.tooMuchRepay'),
Comment thread
therealemjy marked this conversation as resolved.
SwapDeadlineExpire: t('contractErrors.swapDeadlineExpire'),
};
Comment thread
therealemjy marked this conversation as resolved.
1 change: 1 addition & 0 deletions apps/evm/src/libs/errors/handleContractError/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const SELECTOR_LENGTH = 10;
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { Abi } from 'viem';

import {
aavePoolAddressesProviderAbi,
aaveUiPoolDataProviderAbi,
aaveV3PoolAbi,
erc20Abi,
governorBravoDelegateAbi,
isolatedPoolComptrollerAbi,
jumpRateModelAbi,
jumpRateModelV2Abi,
legacyPoolComptrollerAbi,
leverageManagerAbi,
maximillionAbi,
multicall3Abi,
nativeTokenGatewayAbi,
nexusAbi,
nexusAccountFactoryAbi,
nexusBoostrapAbi,
omnichainGovernanceExecutorAbi,
pancakePairV2Abi,
pendlePtVaultAbi,
poolLensAbi,
poolRegistryAbi,
primeAbi,
relativePositionManagerAbi,
resilientOracleAbi,
rewardsDistributorAbi,
swapRouterAbi,
swapRouterV2Abi,
vBep20Abi,
vBnbAbi,
vTreasuryAbi,
vTreasuryV8Abi,
vaiAbi,
vaiControllerAbi,
vaiVaultAbi,
venusLensAbi,
vrtAbi,
vrtConverterAbi,
xVSProxyOFTDestAbi,
xVSProxyOFTSrcAbi,
xvsAbi,
xvsStoreAbi,
xvsTokenOmnichainAbi,
xvsVaultAbi,
xvsVestingAbi,
zyFiVaultAbi,
} from 'libs/contracts';

// ABIs scanned to decode raw revert data when viem has not pre-decoded it.
// Includes every contract the frontend may interact with (Venus + third-party).
export const CONTRACT_ERROR_ABIS: Abi[] = [
// Venus — core lending
isolatedPoolComptrollerAbi,
legacyPoolComptrollerAbi,
vBep20Abi,
vBnbAbi,
vaiControllerAbi,
poolLensAbi,
poolRegistryAbi,
venusLensAbi,
// Venus — extras
primeAbi,
nativeTokenGatewayAbi,
rewardsDistributorAbi,
swapRouterAbi,
swapRouterV2Abi as Abi, // generated ABI has a malformed constructor field
leverageManagerAbi,
relativePositionManagerAbi,
pendlePtVaultAbi,
resilientOracleAbi,
maximillionAbi,
// Venus — tokens
vaiAbi,
xvsAbi,
vrtAbi,
vrtConverterAbi,
// Venus — vaults / staking
vaiVaultAbi,
xvsVaultAbi,
xvsStoreAbi,
xvsVestingAbi,
zyFiVaultAbi,
// Venus — treasury
vTreasuryAbi,
vTreasuryV8Abi,
// Venus — interest rate models
jumpRateModelAbi,
jumpRateModelV2Abi,
// Venus — governance
governorBravoDelegateAbi,
omnichainGovernanceExecutorAbi,
// Venus — cross-chain
xvsTokenOmnichainAbi,
xVSProxyOFTDestAbi,
xVSProxyOFTSrcAbi,
// Third-party — smart accounts
nexusAbi,
nexusAccountFactoryAbi,
nexusBoostrapAbi,
// Third-party — DeFi
aaveV3PoolAbi,
aaveUiPoolDataProviderAbi,
aavePoolAddressesProviderAbi,
pancakePairV2Abi,
// Generic
erc20Abi,
multicall3Abi,
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { type Hex, decodeErrorResult } from 'viem';

import type { ParsedContractError } from '../parseContractError';
import { CONTRACT_ERROR_ABIS } from './constants';

export const decodeWithContractErrorAbis = (
rawData: Hex,
signature: Hex,
): ParsedContractError | undefined => {
for (const abi of CONTRACT_ERROR_ABIS) {
try {
const decoded = decodeErrorResult({ abi, data: rawData });
return { errorName: decoded.errorName, args: decoded.args, signature };
} catch {
// selector not in this ABI
}
}
return undefined;
};
34 changes: 34 additions & 0 deletions apps/evm/src/libs/errors/handleContractError/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { BaseError } from 'viem';

import { displayNotification } from 'libs/notifications';

import { ContractErrorNotice } from '../ContractErrorNotice';
import { customErrorPhrases } from '../customErrorPhrases';
import { logError } from '../logError';
import type { ParsedContractError } from './parseContractError';

export interface HandleContractErrorInput {
error: BaseError;
parsed: ParsedContractError;
}

export const handleContractError = ({ error, parsed }: HandleContractErrorInput) => {
const firstArg = parsed.args?.[0];
const friendlyPhrase =
parsed.errorName === 'Error' && typeof firstArg === 'string'
? firstArg
: customErrorPhrases[parsed.errorName];

displayNotification({
variant: 'error',
description: (
<ContractErrorNotice
friendlyPhrase={friendlyPhrase}
errorName={parsed.errorName}
signature={parsed.signature}
rawMessage={error.message}
/>
),
});
logError(error);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { Hex } from 'viem';

import { SELECTOR_LENGTH } from '../constants';

export const isHexSelector = (value: unknown): value is Hex =>
typeof value === 'string' && value.startsWith('0x') && value.length >= SELECTOR_LENGTH;
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { BaseError, ContractFunctionRevertedError, encodeErrorResult } from 'viem';
import { describe, expect, it } from 'vitest';

import { isolatedPoolComptrollerAbi } from 'libs/contracts';

import { parseContractError } from '..';

describe('parseContractError', () => {
it('decodes a viem ContractFunctionRevertedError that already carries decoded data', () => {
const data = encodeErrorResult({
abi: isolatedPoolComptrollerAbi,
errorName: 'ActionPaused',
args: ['0xED827b80Bd838192EA95002C01B5c6dA8354219a', 1],
});

const error = new ContractFunctionRevertedError({
abi: isolatedPoolComptrollerAbi,
data,
functionName: 'preBorrowHook',
});

const parsed = parseContractError(error);
expect(parsed?.errorName).toBe('ActionPaused');
expect(parsed?.args).toEqual(['0xED827b80Bd838192EA95002C01B5c6dA8354219a', 1]);
});

it('decodes raw hex revert data nested in the cause chain (estimateGas path)', () => {
const rawData = encodeErrorResult({
abi: isolatedPoolComptrollerAbi,
errorName: 'BorrowCapExceeded',
args: ['0xED827b80Bd838192EA95002C01B5c6dA8354219a', 1000n],
});

const error = new BaseError('execution reverted', {
cause: { data: rawData } as unknown as Error,
});

const parsed = parseContractError(error);
expect(parsed?.errorName).toBe('BorrowCapExceeded');
expect(parsed?.args?.[1]).toBe(1000n);
});

it('returns UnknownContractError when the selector is not in any Venus ABI', () => {
const error = new BaseError('execution reverted', {
cause: { data: '0xdeadbeef' } as unknown as Error,
});

const parsed = parseContractError(error);
expect(parsed?.errorName).toBe('UnknownContractError');
expect(parsed?.signature).toBe('0xdeadbeef');
});

it('returns undefined for non-viem errors', () => {
expect(parseContractError(new Error('random'))).toBeUndefined();
expect(parseContractError(null)).toBeUndefined();
expect(parseContractError('boom')).toBeUndefined();
});
});
Comment thread
therealemjy marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { BaseError, type Hex } from 'viem';

import { SELECTOR_LENGTH } from '../constants';
import { decodeWithContractErrorAbis } from '../decodeWithContractErrorAbis';
import { readPreDecodedRevert } from '../readPreDecodedRevert';
import { readRawRevertData } from '../readRawRevertData';

export interface ParsedContractError {
errorName: string;
args?: readonly unknown[];
signature?: Hex;
}

export const parseContractError = (error: unknown): ParsedContractError | undefined => {
if (!(error instanceof BaseError)) {
return undefined;
}

// viem already decoded the revert via the ABI used at call time (writeContract / simulateContract path)
const preDecoded = readPreDecodedRevert(error);
if (preDecoded) {
return preDecoded;
}

const rawData = readRawRevertData(error);
if (!rawData) {
return undefined;
}
const signature = rawData.slice(0, SELECTOR_LENGTH) as Hex;
return (
decodeWithContractErrorAbis(rawData, signature) ?? {
errorName: 'UnknownContractError',
signature,
}
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { type BaseError, ContractFunctionRevertedError } from 'viem';

import type { ParsedContractError } from '../parseContractError';

export const readPreDecodedRevert = (error: BaseError): ParsedContractError | undefined => {
const layer = error.walk(e => e instanceof ContractFunctionRevertedError);
if (!(layer instanceof ContractFunctionRevertedError) || !layer.data?.errorName) {
return undefined;
}
return {
errorName: layer.data.errorName,
args: layer.data.args,
signature: layer.signature,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { BaseError, Hex } from 'viem';

import { isHexSelector } from '../isHexSelector';

export const readRawRevertData = (error: BaseError): Hex | undefined => {
const layer = error.walk(e => isHexSelector((e as { data?: unknown } | null)?.data));
const data = (layer as { data?: unknown } | null)?.data;
return isHexSelector(data) ? data : undefined;
};
Loading
Loading