From b2803e704a4e0492b6a4ffb6333032d6cdc837b9 Mon Sep 17 00:00:00 2001 From: MJ Kiwi Date: Thu, 21 May 2026 18:37:11 +1200 Subject: [PATCH 1/6] Implement human-readable revert reasons for transaction and user operation errors --- .../actions/erc7710RedeemDelegationAction.ts | 27 +- .../src/actions/revertReason.ts | 232 ++++++++++++++++++ .../erc7710RedeemDelegationAction.test.ts | 113 +++++++++ 3 files changed, 364 insertions(+), 8 deletions(-) create mode 100644 packages/smart-accounts-kit/src/actions/revertReason.ts diff --git a/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts b/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts index f0c4b552..a37a2a09 100644 --- a/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts +++ b/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts @@ -26,6 +26,7 @@ import { } from '../executions'; import { getSmartAccountsEnvironment } from '../smartAccountsEnvironment'; import type { Call, PermissionContext } from '../types'; +import { surfaceRevertReason } from './revertReason'; export type DelegatedCall = Call & OneOf< @@ -106,11 +107,17 @@ export async function sendTransactionWithDelegationAction< ...rest } = args; - const hash = await client.sendTransaction({ - ...rest, - to: args.delegationManager, - data: calldata, - } as unknown as SendTransactionParameters); + let hash: Hex; + + try { + hash = await client.sendTransaction({ + ...rest, + to: args.delegationManager, + data: calldata, + } as unknown as SendTransactionParameters); + } catch (error) { + throw surfaceRevertReason(error, 'Transaction'); + } return hash; } @@ -245,7 +252,11 @@ export async function sendUserOperationWithDelegationAction< }; }); - return client.sendUserOperation( - parameters as unknown as SendUserOperationParameters, - ); + try { + return await client.sendUserOperation( + parameters as unknown as SendUserOperationParameters, + ); + } catch (error) { + throw surfaceRevertReason(error, 'User Operation'); + } } diff --git a/packages/smart-accounts-kit/src/actions/revertReason.ts b/packages/smart-accounts-kit/src/actions/revertReason.ts new file mode 100644 index 00000000..6465579c --- /dev/null +++ b/packages/smart-accounts-kit/src/actions/revertReason.ts @@ -0,0 +1,232 @@ +import * as delegationAbis from '@metamask/delegation-abis'; +import { isHex } from 'viem'; +import type { Abi, AbiItem, Hex } from 'viem'; +import { decodeErrorResult, formatAbiItemWithArgs } from 'viem/utils'; + +const knownRevertAbis = Object.values(delegationAbis) as readonly Abi[]; + +const panicReasons: Record = { + '1': 'An `assert` condition failed.', + '17': 'Arithmetic operation resulted in underflow or overflow.', + '18': 'Division or modulo by zero.', + '33': 'Attempted to convert to an invalid type.', + '34': 'Attempted to access a storage byte array that is incorrectly encoded.', + '49': 'Performed `.pop()` on an empty array.', + '50': 'Array index is out of bounds.', + '65': 'Allocated too much memory or created an array which is too large.', + '81': 'Attempted to call a zero-initialized variable of internal function type.', +}; + +type DecodedRevertReason = { + errorName: string; + message: string; + rawData: Hex; +}; + +class RevertReasonError extends Error { + decodedErrorName: string; + + rawData: Hex; + + constructor( + action: string, + decodedReason: DecodedRevertReason, + cause: Error, + ) { + super(`${action} reverted: ${decodedReason.message}`, { cause }); + this.name = 'RevertReasonError'; + this.decodedErrorName = decodedReason.errorName; + this.rawData = decodedReason.rawData; + } +} + +/** + * Re-wraps errors with decoded revert data while keeping the message concise. + * + * @param error - The original error thrown by viem or an RPC provider. + * @param action - The action name to include in the generated error. + * @returns The original error when no human-readable revert reason is found. + */ +export function surfaceRevertReason(error: unknown, action: string): unknown { + const decodedReason = getDecodedRevertReason(error); + + if (!decodedReason) { + return error; + } + + const cause = error instanceof Error ? error : new Error(String(error)); + + return new RevertReasonError(action, decodedReason, cause); +} + +/** + * Decodes the first recognized revert data candidate in an error chain. + * + * @param error - The error object to inspect. + * @returns A decoded revert reason, if one can be recognized. + */ +function getDecodedRevertReason( + error: unknown, +): DecodedRevertReason | undefined { + for (const rawData of getRevertDataCandidates(error)) { + const decoded = decodeRevertData(rawData); + + if (decoded) { + return decoded; + } + } + + return undefined; +} + +/** + * Decodes raw revert data against standard Solidity errors and known SDK ABIs. + * + * @param rawData - ABI-encoded revert data. + * @returns A decoded revert reason, if the data matches a known error. + */ +function decodeRevertData(rawData: Hex): DecodedRevertReason | undefined { + const abis = [[] as const, ...knownRevertAbis]; + + for (const abi of abis) { + try { + const { abiItem, args, errorName } = decodeErrorResult({ + abi, + data: rawData, + }); + + return { + errorName, + message: formatDecodedError(errorName, args, abiItem), + rawData, + }; + } catch { + // Try the next ABI until one can decode the revert data. + } + } + + return undefined; +} + +/** + * Formats a decoded error into compact user-facing text. + * + * @param errorName - The decoded Solidity error name. + * @param args - The decoded Solidity error arguments. + * @param abiItem - The ABI item used to decode the error. + * @returns Human-readable revert text. + */ +function formatDecodedError( + errorName: string, + args: readonly unknown[] | undefined, + abiItem: AbiItem, +): string { + if (errorName === 'Error') { + const [reason] = args ?? []; + return typeof reason === 'string' ? reason : errorName; + } + + if (errorName === 'Panic') { + const [code] = args ?? []; + const panicCode = String(code); + return panicReasons[panicCode] ?? `Panic(${panicCode})`; + } + + const formattedArgs = formatAbiItemWithArgs({ + abiItem, + args: args ?? [], + includeFunctionName: false, + includeName: false, + }); + + return `${errorName}${formattedArgs}`; +} + +/** + * Extracts hex revert data candidates from common viem and JSON-RPC error shapes. + * + * @param error - The error object to inspect. + * @returns Candidate revert data values. + */ +function getRevertDataCandidates(error: unknown): Hex[] { + const candidates: Hex[] = []; + const seen = new Set(); + const seenCandidates = new Set(); + + const addHexCandidate = (candidate: string): void => { + if ( + candidate.length < 10 || + candidate.length % 2 !== 0 || + !isHex(candidate) + ) { + return; + } + + if (!seenCandidates.has(candidate)) { + seenCandidates.add(candidate); + candidates.push(candidate); + } + }; + + const addLabeledHexCandidates = (value: string): void => { + for (const match of value.matchAll( + /(?:reason|revertData|raw|data):\s*(0x[0-9a-fA-F]+)/giu, + )) { + const [, candidate] = match; + + if (candidate) { + addHexCandidate(candidate); + } + } + }; + + const addHexCandidates = (value: unknown): void => { + if (typeof value !== 'string') { + return; + } + + addLabeledHexCandidates(value); + + for (const [candidate] of value.matchAll(/0x[0-9a-fA-F]+/gu)) { + addHexCandidate(candidate); + } + }; + + const visit = (value: unknown, depth = 0): void => { + if (value === null || value === undefined || seen.has(value) || depth > 8) { + return; + } + + addHexCandidates(value); + + if (Array.isArray(value)) { + seen.add(value); + value.forEach((item) => visit(item, depth + 1)); + return; + } + + if (typeof value !== 'object') { + return; + } + + seen.add(value); + + const record = value as Record; + + addHexCandidates(record.revertData); + addHexCandidates(record.raw); + addHexCandidates(record.data); + addHexCandidates(record.details); + addHexCandidates(record.shortMessage); + addHexCandidates(record.message); + + visit(record.data, depth + 1); + visit(record.error, depth + 1); + visit(record.originalError, depth + 1); + visit(record.cause, depth + 1); + }; + + visit(error); + + return candidates; +} diff --git a/packages/smart-accounts-kit/test/actions/erc7710RedeemDelegationAction.test.ts b/packages/smart-accounts-kit/test/actions/erc7710RedeemDelegationAction.test.ts index 3a4e3856..f5df14f8 100644 --- a/packages/smart-accounts-kit/test/actions/erc7710RedeemDelegationAction.test.ts +++ b/packages/smart-accounts-kit/test/actions/erc7710RedeemDelegationAction.test.ts @@ -12,6 +12,7 @@ import { createPublicClient, createWalletClient, custom, + encodeErrorResult, encodeFunctionData, } from 'viem'; import { createBundlerClient } from 'viem/account-abstraction'; @@ -266,6 +267,84 @@ describe('erc7710RedeemDelegationAction', () => { }); }); + it('should surface human-readable revert reasons from bundler error messages', async () => { + const bundlerClient = createBundlerClient({ + transport: custom({ request: mockBundlerRequest }), + chain, + }); + const extendedBundlerClient = bundlerClient.extend( + erc7710BundlerActions(), + ); + + const revertData = encodeErrorResult({ + abi: [ + { + type: 'error', + name: 'Error', + inputs: [{ name: 'message', type: 'string' }], + }, + ], + errorName: 'Error', + args: ['AllowedTargetsEnforcer:target-address-not-allowed'], + }); + + const bundlerError = new Error( + [ + `Execution reverted with reason: UserOperation reverted during simulation with reason: ${revertData}.`, + `Request Arguments: callData: ${randomBytes(128)}`, + `Details: UserOperation reverted during simulation with reason: ${revertData}`, + 'Version: viem@2.31.7', + ].join('\n'), + ); + stub(bundlerClient, 'sendUserOperation').rejects(bundlerError); + + const sendUserOperationWithDelegationArgs: SendUserOperationWithDelegationParameters = + { + publicClient, + calls: [ + { + to: randomAddress(), + value: 0n, + permissionContext: [createDelegation()], + delegationManager: expectedDelegationManager, + }, + ], + }; + + await expect( + extendedBundlerClient.sendUserOperationWithDelegation( + sendUserOperationWithDelegationArgs, + ), + ).rejects.toThrow( + 'User Operation reverted: AllowedTargetsEnforcer:target-address-not-allowed', + ); + }); + + it('should preserve bundler errors without decodable revert data', async () => { + const bundlerClient = createBundlerClient({ + transport: custom({ request: mockBundlerRequest }), + chain, + }); + const extendedBundlerClient = bundlerClient.extend( + erc7710BundlerActions(), + ); + + const bundlerError = new Error('Bundler unavailable'); + stub(bundlerClient, 'sendUserOperation').rejects(bundlerError); + + const sendUserOperationWithDelegationArgs: SendUserOperationWithDelegationParameters = + { + publicClient, + calls: [{ to: randomAddress(), value: 0n }], + }; + + await expect( + extendedBundlerClient.sendUserOperationWithDelegation( + sendUserOperationWithDelegationArgs, + ), + ).rejects.toBe(bundlerError); + }); + it('should throw an error when SimpleFactory is provided as dependencies factory', async () => { const bundlerClient = createBundlerClient({ transport: custom({ request: mockBundlerRequest }), @@ -544,6 +623,40 @@ describe('erc7710RedeemDelegationAction', () => { }); }); + it('should surface human-readable revert reasons from transaction errors', async () => { + const extendedWalletClient = walletClient.extend(erc7710WalletActions()); + + const revertData = encodeErrorResult({ + abi: [ + { + type: 'error', + name: 'Error', + inputs: [{ name: 'message', type: 'string' }], + }, + ], + errorName: 'Error', + args: ['Execution target reverted'], + }); + + stub(walletClient, 'sendTransaction').rejects( + new Error(`Transaction simulation failed. Details: ${revertData}`), + ); + + const args: SendTransactionWithDelegationParameters = { + account, + chain, + to: randomAddress(), + value: 0n, + data: randomBytes(128), + permissionContext: [createDelegation()], + delegationManager: expectedDelegationManager, + }; + + await expect( + extendedWalletClient.sendTransactionWithDelegation(args), + ).rejects.toThrow('Transaction reverted: Execution target reverted'); + }); + it('should throw an error when DelegationManager does not match expected address for the chain', async () => { const extendedWalletClient = walletClient.extend(erc7710WalletActions()); From 88d99931359abed6e9887ed528e2a58e090fe2ab Mon Sep 17 00:00:00 2001 From: MJ Kiwi Date: Fri, 22 May 2026 11:38:56 +1200 Subject: [PATCH 2/6] Add human-readable revert reasons to changelog --- packages/smart-accounts-kit/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/smart-accounts-kit/CHANGELOG.md b/packages/smart-accounts-kit/CHANGELOG.md index 61860817..b08da009 100644 --- a/packages/smart-accounts-kit/CHANGELOG.md +++ b/packages/smart-accounts-kit/CHANGELOG.md @@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deprecated `erc20-token-revocation` in favor of `token-approval-revocation` ([#226](https://github.com/MetaMask/smart-accounts-kit/pull/226)) +### Fixed + +- Surface decoded, human-readable revert reasons from delegated transaction and user operation failures when revert data is available ([#245](https://github.com/MetaMask/smart-accounts-kit/pull/245)) + ## [1.5.0] ### Added From a80ddd60ac47ced4b731f6415412eeb4f2a681d8 Mon Sep 17 00:00:00 2001 From: MJ Kiwi Date: Mon, 25 May 2026 12:18:29 +1200 Subject: [PATCH 3/6] Implement human-readable revert reason decoding for delegated execution errors --- .../test/delegationManagement.test.ts | 80 +++++++++++- packages/smart-accounts-kit/CHANGELOG.md | 5 +- .../DelegationManager/decode.ts | 3 + .../DelegationManager/index.ts | 3 +- .../methods/redeemDelegations.ts | 18 +++ .../actions/erc7710RedeemDelegationAction.ts | 27 ++-- .../revertReason.ts => decodeRevertReason.ts} | 87 +++++++------ .../smart-accounts-kit/src/utils/index.ts | 6 + .../delegationManagement.test.ts | 122 +++++++++++++++++- .../erc7710RedeemDelegationAction.test.ts | 30 +++-- 10 files changed, 305 insertions(+), 76 deletions(-) create mode 100644 packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/decode.ts rename packages/smart-accounts-kit/src/{actions/revertReason.ts => decodeRevertReason.ts} (81%) diff --git a/packages/delegator-e2e/test/delegationManagement.test.ts b/packages/delegator-e2e/test/delegationManagement.test.ts index aca77877..3d497aa4 100644 --- a/packages/delegator-e2e/test/delegationManagement.test.ts +++ b/packages/delegator-e2e/test/delegationManagement.test.ts @@ -18,6 +18,7 @@ import { } from '@metamask/smart-accounts-kit/contracts'; import { gasPrice, + transport, sponsoredBundlerClient, deploySmartAccount, deployCounter, @@ -26,12 +27,17 @@ import { fundAddress, } from './utils/helpers'; import { expectUserOperationToSucceed } from './utils/assertions'; -import { encodeFunctionData, parseEther } from 'viem'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import { createWalletClient, encodeFunctionData, parseEther } from 'viem'; +import { + generatePrivateKey, + privateKeyToAccount, + type PrivateKeyAccount, +} from 'viem/accounts'; import CounterMetadata from './utils/counter/metadata.json'; let aliceSmartAccount: MetaMaskSmartAccount; let bobSmartAccount: MetaMaskSmartAccount; +let bob: PrivateKeyAccount; let aliceCounter: CounterContract; /** @@ -51,7 +57,7 @@ let aliceCounter: CounterContract; beforeEach(async () => { const alice = privateKeyToAccount(generatePrivateKey()); - const bob = privateKeyToAccount(generatePrivateKey()); + bob = privateKeyToAccount(generatePrivateKey()); aliceSmartAccount = await toMetaMaskSmartAccount({ client: publicClient, @@ -244,6 +250,74 @@ test('delegation management lifecycle: create, disable, enable, and check status expect(finalCount).toEqual(2n); }); +test('can decode raw simulate and execute redeemDelegations errors', async () => { + await fundAddress(bob.address); + + const bobWalletClient = createWalletClient({ + account: bob, + transport, + chain: publicClient.chain, + }); + + const delegation = createDelegation({ + to: bob.address, + from: aliceSmartAccount.address, + environment: aliceSmartAccount.environment, + scope: { + type: 'functionCall', + targets: [aliceCounter.address], + selectors: ['increment()'], + }, + }); + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ delegation }), + }; + + const execution = createExecution({ + target: aliceCounter.address, + callData: encodeFunctionData({ + abi: CounterMetadata.abi, + functionName: 'setCount', + args: [1n], + }), + }); + + const redeemParams = { + client: bobWalletClient, + delegationManagerAddress: aliceSmartAccount.environment.DelegationManager, + delegations: [[signedDelegation]], + modes: [ExecutionMode.SingleDefault], + executions: [[execution]], + }; + const expectedError = 'AllowedMethodsEnforcer:method-not-allowed'; + + const simulateError = await DelegationManager.simulate + .redeemDelegations(redeemParams) + .then( + () => undefined, + (error: unknown) => error, + ); + + expect(simulateError).toBeDefined(); + expect( + DelegationManager.decode.redeemDelegationsError(simulateError)?.message, + ).toBe(expectedError); + + const executeError = await DelegationManager.execute + .redeemDelegations(redeemParams) + .then( + () => undefined, + (error: unknown) => error, + ); + + expect(executeError).toBeDefined(); + expect( + DelegationManager.decode.redeemDelegationsError(executeError)?.message, + ).toBe(expectedError); +}); + test('only delegator can disable their own delegation', async () => { // Create a delegation from Alice to Bob const delegation = createDelegation({ diff --git a/packages/smart-accounts-kit/CHANGELOG.md b/packages/smart-accounts-kit/CHANGELOG.md index b08da009..a654ad07 100644 --- a/packages/smart-accounts-kit/CHANGELOG.md +++ b/packages/smart-accounts-kit/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ERC-7715 `token-approval-revocation` permission type ([#226](https://github.com/MetaMask/smart-accounts-kit/pull/226), [#237](https://github.com/MetaMask/smart-accounts-kit/pull/237)) - `CaveatBuilder` for `ApprovalRevocationEnforcer`, deployment address added to `SmartAccountsEnvironment` ([#226](https://github.com/metamask/smart-accounts-kit/pull/226), [#237](https://github.com/MetaMask/smart-accounts-kit/pull/237)) +- Helper for decoding revert reasons from delegated execution errors while preserving the original error output ([#245](https://github.com/MetaMask/smart-accounts-kit/pull/245)) ### Changed @@ -23,10 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deprecated `erc20-token-revocation` in favor of `token-approval-revocation` ([#226](https://github.com/MetaMask/smart-accounts-kit/pull/226)) -### Fixed - -- Surface decoded, human-readable revert reasons from delegated transaction and user operation failures when revert data is available ([#245](https://github.com/MetaMask/smart-accounts-kit/pull/245)) - ## [1.5.0] ### Added diff --git a/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/decode.ts b/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/decode.ts new file mode 100644 index 00000000..a0ac2ea4 --- /dev/null +++ b/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/decode.ts @@ -0,0 +1,3 @@ +import { decodeError as redeemDelegationsError } from './methods/redeemDelegations'; + +export { redeemDelegationsError }; diff --git a/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/index.ts b/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/index.ts index bbb1b79f..ecf92131 100644 --- a/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/index.ts +++ b/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/index.ts @@ -1,7 +1,8 @@ import * as constants from './constants'; +import * as decode from './decode'; import * as encode from './encode'; import * as execute from './execute'; import * as read from './read'; import * as simulate from './simulate'; -export { encode, execute, read, simulate, constants }; +export { decode, encode, execute, read, simulate, constants }; diff --git a/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/methods/redeemDelegations.ts b/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/methods/redeemDelegations.ts index 956a1663..79c47fc9 100644 --- a/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/methods/redeemDelegations.ts +++ b/packages/smart-accounts-kit/src/DelegationFramework/DelegationManager/methods/redeemDelegations.ts @@ -3,6 +3,10 @@ import type { Address, Client } from 'viem'; import { encodeFunctionData } from 'viem'; import { simulateContract, writeContract } from 'viem/actions'; +import { + decodeRevertReason, + type DecodedRevertReason, +} from '../../../decodeRevertReason'; import { encodeDelegations } from '../../../delegation'; import { encodeExecutionCalldatas } from '../../../executions'; import type { ExecutionMode, ExecutionStruct } from '../../../executions'; @@ -25,6 +29,10 @@ export type ExecuteRedeemDelegationsParameters = { delegationManagerAddress: Address; } & EncodeRedeemDelegationsParameters; +export type DecodeRedeemDelegationsErrorReturnType = + | DecodedRevertReason + | undefined; + export const simulate = async ({ client, delegationManagerAddress, @@ -77,3 +85,13 @@ export const encode = ({ ], }); }; + +/** + * Decodes revert data from errors thrown by `simulate` or `execute`. + * + * @param error - The original error thrown by viem or an RPC provider. + * @returns A decoded revert reason, if one can be recognized. + */ +export const decodeError = ( + error: unknown, +): DecodeRedeemDelegationsErrorReturnType => decodeRevertReason(error); diff --git a/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts b/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts index a37a2a09..f0c4b552 100644 --- a/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts +++ b/packages/smart-accounts-kit/src/actions/erc7710RedeemDelegationAction.ts @@ -26,7 +26,6 @@ import { } from '../executions'; import { getSmartAccountsEnvironment } from '../smartAccountsEnvironment'; import type { Call, PermissionContext } from '../types'; -import { surfaceRevertReason } from './revertReason'; export type DelegatedCall = Call & OneOf< @@ -107,17 +106,11 @@ export async function sendTransactionWithDelegationAction< ...rest } = args; - let hash: Hex; - - try { - hash = await client.sendTransaction({ - ...rest, - to: args.delegationManager, - data: calldata, - } as unknown as SendTransactionParameters); - } catch (error) { - throw surfaceRevertReason(error, 'Transaction'); - } + const hash = await client.sendTransaction({ + ...rest, + to: args.delegationManager, + data: calldata, + } as unknown as SendTransactionParameters); return hash; } @@ -252,11 +245,7 @@ export async function sendUserOperationWithDelegationAction< }; }); - try { - return await client.sendUserOperation( - parameters as unknown as SendUserOperationParameters, - ); - } catch (error) { - throw surfaceRevertReason(error, 'User Operation'); - } + return client.sendUserOperation( + parameters as unknown as SendUserOperationParameters, + ); } diff --git a/packages/smart-accounts-kit/src/actions/revertReason.ts b/packages/smart-accounts-kit/src/decodeRevertReason.ts similarity index 81% rename from packages/smart-accounts-kit/src/actions/revertReason.ts rename to packages/smart-accounts-kit/src/decodeRevertReason.ts index 6465579c..d69934a5 100644 --- a/packages/smart-accounts-kit/src/actions/revertReason.ts +++ b/packages/smart-accounts-kit/src/decodeRevertReason.ts @@ -17,55 +17,19 @@ const panicReasons: Record = { '81': 'Attempted to call a zero-initialized variable of internal function type.', }; -type DecodedRevertReason = { +export type DecodedRevertReason = { errorName: string; message: string; rawData: Hex; }; -class RevertReasonError extends Error { - decodedErrorName: string; - - rawData: Hex; - - constructor( - action: string, - decodedReason: DecodedRevertReason, - cause: Error, - ) { - super(`${action} reverted: ${decodedReason.message}`, { cause }); - this.name = 'RevertReasonError'; - this.decodedErrorName = decodedReason.errorName; - this.rawData = decodedReason.rawData; - } -} - -/** - * Re-wraps errors with decoded revert data while keeping the message concise. - * - * @param error - The original error thrown by viem or an RPC provider. - * @param action - The action name to include in the generated error. - * @returns The original error when no human-readable revert reason is found. - */ -export function surfaceRevertReason(error: unknown, action: string): unknown { - const decodedReason = getDecodedRevertReason(error); - - if (!decodedReason) { - return error; - } - - const cause = error instanceof Error ? error : new Error(String(error)); - - return new RevertReasonError(action, decodedReason, cause); -} - /** * Decodes the first recognized revert data candidate in an error chain. * - * @param error - The error object to inspect. + * @param error - The original error thrown by viem or an RPC provider. * @returns A decoded revert reason, if one can be recognized. */ -function getDecodedRevertReason( +export function decodeRevertReason( error: unknown, ): DecodedRevertReason | undefined { for (const rawData of getRevertDataCandidates(error)) { @@ -85,7 +49,9 @@ function getDecodedRevertReason( * @param rawData - ABI-encoded revert data. * @returns A decoded revert reason, if the data matches a known error. */ -function decodeRevertData(rawData: Hex): DecodedRevertReason | undefined { +export function decodeRevertData( + rawData: Hex, +): DecodedRevertReason | undefined { const abis = [[] as const, ...knownRevertAbis]; for (const abi of abis) { @@ -105,6 +71,16 @@ function decodeRevertData(rawData: Hex): DecodedRevertReason | undefined { } } + const decodedString = decodeRawString(rawData); + + if (decodedString) { + return { + errorName: 'Error', + message: decodedString, + rawData, + }; + } + return undefined; } @@ -142,6 +118,34 @@ function formatDecodedError( return `${errorName}${formattedArgs}`; } +/** + * Decodes revert payloads surfaced by some clients as raw UTF-8 bytes. + * + * @param rawData - Hex-encoded revert string bytes. + * @returns The decoded string when it looks like readable text. + */ +function decodeRawString(rawData: Hex): string | undefined { + const bytes = rawData.slice(2); + + if (bytes.length === 0 || bytes.length % 2 !== 0) { + return undefined; + } + + let value = ''; + + for (let index = 0; index < bytes.length; index += 2) { + const charCode = Number.parseInt(bytes.slice(index, index + 2), 16); + + if (Number.isNaN(charCode) || charCode < 0x20 || charCode > 0x7e) { + return undefined; + } + + value += String.fromCharCode(charCode); + } + + return value; +} + /** * Extracts hex revert data candidates from common viem and JSON-RPC error shapes. * @@ -217,11 +221,14 @@ function getRevertDataCandidates(error: unknown): Hex[] { addHexCandidates(record.raw); addHexCandidates(record.data); addHexCandidates(record.details); + addHexCandidates(record.reason); addHexCandidates(record.shortMessage); addHexCandidates(record.message); visit(record.data, depth + 1); + visit(record.details, depth + 1); visit(record.error, depth + 1); + visit(record.metaMessages, depth + 1); visit(record.originalError, depth + 1); visit(record.cause, depth + 1); }; diff --git a/packages/smart-accounts-kit/src/utils/index.ts b/packages/smart-accounts-kit/src/utils/index.ts index 18074e30..a98983d2 100644 --- a/packages/smart-accounts-kit/src/utils/index.ts +++ b/packages/smart-accounts-kit/src/utils/index.ts @@ -1,5 +1,11 @@ export { decodeCaveat } from '../caveats'; +export { + decodeRevertData, + decodeRevertReason, + type DecodedRevertReason, +} from '../decodeRevertReason'; + export { encodeDelegations, decodeDelegations, diff --git a/packages/smart-accounts-kit/test/DelegationFramework/DelegationManager/delegationManagement.test.ts b/packages/smart-accounts-kit/test/DelegationFramework/DelegationManager/delegationManagement.test.ts index 28c5b2a7..c8aa1674 100644 --- a/packages/smart-accounts-kit/test/DelegationFramework/DelegationManager/delegationManagement.test.ts +++ b/packages/smart-accounts-kit/test/DelegationFramework/DelegationManager/delegationManagement.test.ts @@ -1,5 +1,7 @@ +import { encodeErrorResult } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; -import { describe, expect, it } from 'vitest'; +import * as viemActions from 'viem/actions'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ScopeType } from '../../../src/constants'; import { createDelegation, encodeDelegations } from '../../../src/delegation'; @@ -7,7 +9,16 @@ import * as DelegationManager from '../../../src/DelegationFramework/DelegationM import { ExecutionMode, createExecution } from '../../../src/executions'; import type { SmartAccountsEnvironment } from '../../../src/types'; +vi.mock('viem/actions', () => ({ + simulateContract: vi.fn(), + writeContract: vi.fn(), +})); + describe('DelegationManager - Delegation Management', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + // we use a static environment so that we can assert static encoded data const environment: SmartAccountsEnvironment = { DelegationManager: '0xd5f99192AAb19b340b0dd722575b92Da2ca7A41c', @@ -86,6 +97,9 @@ describe('DelegationManager - Delegation Management', () => { expect(DelegationManager.encode.disableDelegation).toBeDefined(); expect(DelegationManager.encode.enableDelegation).toBeDefined(); expect(DelegationManager.encode.redeemDelegations).toBeDefined(); + + // Decode functions + expect(DelegationManager.decode.redeemDelegationsError).toBeDefined(); }); }); @@ -189,5 +203,111 @@ describe('DelegationManager - Delegation Management', () => { expect(encodedData).toStrictEqual(expectedEncodedData); }); + + it('should preserve raw simulate errors and decode them with the helper', async () => { + const delegation = createDelegation({ + to: bob.address, + from: alice.address, + environment, + scope: { + type: ScopeType.FunctionCall, + targets: [alice.address], + selectors: ['0x00000000'], + }, + }); + + const execution = createExecution({ + target: alice.address, + }); + + const revertData = encodeErrorResult({ + abi: [ + { + type: 'error', + name: 'Error', + inputs: [{ name: 'message', type: 'string' }], + }, + ], + errorName: 'Error', + args: ['AllowedTargetsEnforcer:target-address-not-allowed'], + }); + const simulateError = new Error( + `Execution reverted with data: ${revertData}`, + ); + + vi.mocked(viemActions.simulateContract).mockRejectedValue(simulateError); + + await expect( + DelegationManager.simulate.redeemDelegations({ + client: {} as any, + delegationManagerAddress: environment.DelegationManager, + delegations: [[delegation]], + modes: [ExecutionMode.SingleDefault], + executions: [[execution]], + }), + ).rejects.toBe(simulateError); + + expect( + DelegationManager.decode.redeemDelegationsError(simulateError), + ).toStrictEqual({ + errorName: 'Error', + message: 'AllowedTargetsEnforcer:target-address-not-allowed', + rawData: revertData, + }); + }); + + it('should preserve raw execute errors and decode them with the helper', async () => { + const delegation = createDelegation({ + to: bob.address, + from: alice.address, + environment, + scope: { + type: ScopeType.FunctionCall, + targets: [alice.address], + selectors: ['0x00000000'], + }, + }); + + const execution = createExecution({ + target: alice.address, + }); + const mockRequest = { to: environment.DelegationManager, data: '0x123' }; + + const revertData = encodeErrorResult({ + abi: [ + { + type: 'error', + name: 'Error', + inputs: [{ name: 'message', type: 'string' }], + }, + ], + errorName: 'Error', + args: ['AllowedMethodsEnforcer:method-not-allowed'], + }); + const executeError = new Error(`Transaction failed. raw: ${revertData}`); + + vi.mocked(viemActions.simulateContract).mockResolvedValue({ + request: mockRequest, + } as any); + vi.mocked(viemActions.writeContract).mockRejectedValue(executeError); + + await expect( + DelegationManager.execute.redeemDelegations({ + client: {} as any, + delegationManagerAddress: environment.DelegationManager, + delegations: [[delegation]], + modes: [ExecutionMode.SingleDefault], + executions: [[execution]], + }), + ).rejects.toBe(executeError); + + expect( + DelegationManager.decode.redeemDelegationsError(executeError), + ).toStrictEqual({ + errorName: 'Error', + message: 'AllowedMethodsEnforcer:method-not-allowed', + rawData: revertData, + }); + }); }); }); diff --git a/packages/smart-accounts-kit/test/actions/erc7710RedeemDelegationAction.test.ts b/packages/smart-accounts-kit/test/actions/erc7710RedeemDelegationAction.test.ts index f5df14f8..75fd191a 100644 --- a/packages/smart-accounts-kit/test/actions/erc7710RedeemDelegationAction.test.ts +++ b/packages/smart-accounts-kit/test/actions/erc7710RedeemDelegationAction.test.ts @@ -26,6 +26,7 @@ import type { SendUserOperationWithDelegationParameters, } from '../../src/actions/erc7710RedeemDelegationAction'; import { Implementation } from '../../src/constants'; +import { decodeRevertReason } from '../../src/decodeRevertReason'; import { encodeDelegations } from '../../src/delegation'; import { createExecution, @@ -267,7 +268,7 @@ describe('erc7710RedeemDelegationAction', () => { }); }); - it('should surface human-readable revert reasons from bundler error messages', async () => { + it('should preserve raw bundler errors and allow callers to decode revert reasons', async () => { const bundlerClient = createBundlerClient({ transport: custom({ request: mockBundlerRequest }), chain, @@ -315,9 +316,13 @@ describe('erc7710RedeemDelegationAction', () => { extendedBundlerClient.sendUserOperationWithDelegation( sendUserOperationWithDelegationArgs, ), - ).rejects.toThrow( - 'User Operation reverted: AllowedTargetsEnforcer:target-address-not-allowed', - ); + ).rejects.toBe(bundlerError); + + expect(decodeRevertReason(bundlerError)).toStrictEqual({ + errorName: 'Error', + message: 'AllowedTargetsEnforcer:target-address-not-allowed', + rawData: revertData, + }); }); it('should preserve bundler errors without decodable revert data', async () => { @@ -343,6 +348,8 @@ describe('erc7710RedeemDelegationAction', () => { sendUserOperationWithDelegationArgs, ), ).rejects.toBe(bundlerError); + + expect(decodeRevertReason(bundlerError)).toBeUndefined(); }); it('should throw an error when SimpleFactory is provided as dependencies factory', async () => { @@ -623,7 +630,7 @@ describe('erc7710RedeemDelegationAction', () => { }); }); - it('should surface human-readable revert reasons from transaction errors', async () => { + it('should preserve raw transaction errors and allow callers to decode revert reasons', async () => { const extendedWalletClient = walletClient.extend(erc7710WalletActions()); const revertData = encodeErrorResult({ @@ -638,9 +645,10 @@ describe('erc7710RedeemDelegationAction', () => { args: ['Execution target reverted'], }); - stub(walletClient, 'sendTransaction').rejects( - new Error(`Transaction simulation failed. Details: ${revertData}`), + const transactionError = new Error( + `Transaction simulation failed. Details: ${revertData}`, ); + stub(walletClient, 'sendTransaction').rejects(transactionError); const args: SendTransactionWithDelegationParameters = { account, @@ -654,7 +662,13 @@ describe('erc7710RedeemDelegationAction', () => { await expect( extendedWalletClient.sendTransactionWithDelegation(args), - ).rejects.toThrow('Transaction reverted: Execution target reverted'); + ).rejects.toBe(transactionError); + + expect(decodeRevertReason(transactionError)).toStrictEqual({ + errorName: 'Error', + message: 'Execution target reverted', + rawData: revertData, + }); }); it('should throw an error when DelegationManager does not match expected address for the chain', async () => { From 7b190e2eaf185a88bae0867dba2bf3546ef5e91e Mon Sep 17 00:00:00 2001 From: MJ Kiwi Date: Mon, 25 May 2026 12:52:31 +1200 Subject: [PATCH 4/6] Implement viem error decoding in revert reason handler and add corresponding tests --- .../src/decodeRevertReason.ts | 63 ++++++++++++++++++- .../test/decodeRevertReason.test.ts | 39 ++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 packages/smart-accounts-kit/test/decodeRevertReason.test.ts diff --git a/packages/smart-accounts-kit/src/decodeRevertReason.ts b/packages/smart-accounts-kit/src/decodeRevertReason.ts index d69934a5..da5feb23 100644 --- a/packages/smart-accounts-kit/src/decodeRevertReason.ts +++ b/packages/smart-accounts-kit/src/decodeRevertReason.ts @@ -1,6 +1,6 @@ import * as delegationAbis from '@metamask/delegation-abis'; -import { isHex } from 'viem'; -import type { Abi, AbiItem, Hex } from 'viem'; +import { BaseError, ContractFunctionRevertedError, isHex } from 'viem'; +import type { Abi, AbiItem, DecodeErrorResultReturnType, Hex } from 'viem'; import { decodeErrorResult, formatAbiItemWithArgs } from 'viem/utils'; const knownRevertAbis = Object.values(delegationAbis) as readonly Abi[]; @@ -32,6 +32,12 @@ export type DecodedRevertReason = { export function decodeRevertReason( error: unknown, ): DecodedRevertReason | undefined { + const decodedViemError = decodeViemContractRevert(error); + + if (decodedViemError) { + return decodedViemError; + } + for (const rawData of getRevertDataCandidates(error)) { const decoded = decodeRevertData(rawData); @@ -43,6 +49,39 @@ export function decodeRevertReason( return undefined; } +/** + * Extracts revert information that viem has already identified in its error + * chain before falling back to provider-specific string/object shapes. + * + * @param error - The original error thrown by viem. + * @returns A decoded revert reason, if viem exposed enough revert data. + */ +function decodeViemContractRevert( + error: unknown, +): DecodedRevertReason | undefined { + if (!(error instanceof BaseError)) { + return undefined; + } + + const revertError = error.walk( + (cause) => cause instanceof ContractFunctionRevertedError, + ); + + if (!(revertError instanceof ContractFunctionRevertedError)) { + return undefined; + } + + if (!revertError.raw) { + return undefined; + } + + if (revertError.data) { + return formatDecodeErrorResult(revertError.data, revertError.raw); + } + + return decodeRevertData(revertError.raw); +} + /** * Decodes raw revert data against standard Solidity errors and known SDK ABIs. * @@ -84,6 +123,26 @@ export function decodeRevertData( return undefined; } +/** + * Formats viem's decoded error result into the helper's return shape. + * + * @param decodedData - The decoded error result from viem. + * @param rawData - The ABI-encoded revert data. + * @returns Human-readable revert text. + */ +function formatDecodeErrorResult( + decodedData: DecodeErrorResultReturnType, + rawData: Hex, +): DecodedRevertReason { + const { abiItem, args, errorName } = decodedData; + + return { + errorName, + message: formatDecodedError(errorName, args, abiItem), + rawData, + }; +} + /** * Formats a decoded error into compact user-facing text. * diff --git a/packages/smart-accounts-kit/test/decodeRevertReason.test.ts b/packages/smart-accounts-kit/test/decodeRevertReason.test.ts new file mode 100644 index 00000000..3c42c01b --- /dev/null +++ b/packages/smart-accounts-kit/test/decodeRevertReason.test.ts @@ -0,0 +1,39 @@ +import { + BaseError, + ContractFunctionRevertedError, + encodeErrorResult, +} from 'viem'; +import { describe, expect, it } from 'vitest'; + +import { decodeRevertReason } from '../src/decodeRevertReason'; + +describe('decodeRevertReason', () => { + it('should decode viem contract function reverted errors', () => { + const abi = [ + { + type: 'error', + name: 'Error', + inputs: [{ name: 'message', type: 'string' }], + }, + ] as const; + const rawData = encodeErrorResult({ + abi, + errorName: 'Error', + args: ['AllowedMethodsEnforcer:method-not-allowed'], + }); + const revertError = new ContractFunctionRevertedError({ + abi, + data: rawData, + functionName: 'redeemDelegations', + }); + const executionError = new BaseError('Transaction simulation failed.', { + cause: revertError, + }); + + expect(decodeRevertReason(executionError)).toStrictEqual({ + errorName: 'Error', + message: 'AllowedMethodsEnforcer:method-not-allowed', + rawData, + }); + }); +}); From 726a7b6a21bb1a289acd46edb790566ec978c99c Mon Sep 17 00:00:00 2001 From: MJ Kiwi Date: Wed, 27 May 2026 09:57:56 +1200 Subject: [PATCH 5/6] Enhance revert reason decoding with additional tests and improved error handling --- .../src/decodeRevertReason.ts | 29 +- .../test/decodeRevertReason.test.ts | 287 ++++++++++++++++-- 2 files changed, 279 insertions(+), 37 deletions(-) diff --git a/packages/smart-accounts-kit/src/decodeRevertReason.ts b/packages/smart-accounts-kit/src/decodeRevertReason.ts index da5feb23..594791f5 100644 --- a/packages/smart-accounts-kit/src/decodeRevertReason.ts +++ b/packages/smart-accounts-kit/src/decodeRevertReason.ts @@ -3,7 +3,11 @@ import { BaseError, ContractFunctionRevertedError, isHex } from 'viem'; import type { Abi, AbiItem, DecodeErrorResultReturnType, Hex } from 'viem'; import { decodeErrorResult, formatAbiItemWithArgs } from 'viem/utils'; -const knownRevertAbis = Object.values(delegationAbis) as readonly Abi[]; +const knownRevertAbis = (Object.values(delegationAbis) as Abi[]).filter( + (abi) => abi.length > 0, +); + +const MAX_ERROR_TRAVERSAL_DEPTH = 8; const panicReasons: Record = { '1': 'An `assert` condition failed.', @@ -18,9 +22,9 @@ const panicReasons: Record = { }; export type DecodedRevertReason = { - errorName: string; - message: string; - rawData: Hex; + readonly errorName: string; + readonly message: string; + readonly rawData: Hex; }; /** @@ -128,7 +132,7 @@ export function decodeRevertData( * * @param decodedData - The decoded error result from viem. * @param rawData - The ABI-encoded revert data. - * @returns Human-readable revert text. + * @returns The structured decoded revert reason. */ function formatDecodeErrorResult( decodedData: DecodeErrorResultReturnType, @@ -174,13 +178,13 @@ function formatDecodedError( includeName: false, }); - return `${errorName}${formattedArgs}`; + return `${errorName}${formattedArgs ?? ''}`; } /** - * Decodes revert payloads surfaced by some clients as raw UTF-8 bytes. + * Decodes revert payloads surfaced by some clients as raw printable ASCII bytes. * - * @param rawData - Hex-encoded revert string bytes. + * @param rawData - Hex-encoded bytes that may represent a printable ASCII string. * @returns The decoded string when it looks like readable text. */ function decodeRawString(rawData: Hex): string | undefined { @@ -233,7 +237,7 @@ function getRevertDataCandidates(error: unknown): Hex[] { const addLabeledHexCandidates = (value: string): void => { for (const match of value.matchAll( - /(?:reason|revertData|raw|data):\s*(0x[0-9a-fA-F]+)/giu, + /\b(?:reason|revertData|raw|data):\s*(0x[0-9a-fA-F]+)/giu, )) { const [, candidate] = match; @@ -256,7 +260,12 @@ function getRevertDataCandidates(error: unknown): Hex[] { }; const visit = (value: unknown, depth = 0): void => { - if (value === null || value === undefined || seen.has(value) || depth > 8) { + if ( + value === null || + value === undefined || + seen.has(value) || + depth > MAX_ERROR_TRAVERSAL_DEPTH + ) { return; } diff --git a/packages/smart-accounts-kit/test/decodeRevertReason.test.ts b/packages/smart-accounts-kit/test/decodeRevertReason.test.ts index 3c42c01b..a999b3a3 100644 --- a/packages/smart-accounts-kit/test/decodeRevertReason.test.ts +++ b/packages/smart-accounts-kit/test/decodeRevertReason.test.ts @@ -1,39 +1,272 @@ +import * as delegationAbis from '@metamask/delegation-abis'; import { BaseError, ContractFunctionRevertedError, encodeErrorResult, + stringToHex, } from 'viem'; +import type { Hex } from 'viem'; import { describe, expect, it } from 'vitest'; -import { decodeRevertReason } from '../src/decodeRevertReason'; +import { + decodeRevertData, + decodeRevertReason, +} from '../src/decodeRevertReason'; describe('decodeRevertReason', () => { - it('should decode viem contract function reverted errors', () => { - const abi = [ - { - type: 'error', - name: 'Error', - inputs: [{ name: 'message', type: 'string' }], - }, - ] as const; - const rawData = encodeErrorResult({ - abi, - errorName: 'Error', - args: ['AllowedMethodsEnforcer:method-not-allowed'], - }); - const revertError = new ContractFunctionRevertedError({ - abi, - data: rawData, - functionName: 'redeemDelegations', - }); - const executionError = new BaseError('Transaction simulation failed.', { - cause: revertError, - }); - - expect(decodeRevertReason(executionError)).toStrictEqual({ - errorName: 'Error', - message: 'AllowedMethodsEnforcer:method-not-allowed', - rawData, + describe('viem BaseError chain', () => { + it('should decode a ContractFunctionRevertedError with Error(string)', () => { + const abi = [ + { + type: 'error', + name: 'Error', + inputs: [{ name: 'message', type: 'string' }], + }, + ] as const; + const rawData = encodeErrorResult({ + abi, + errorName: 'Error', + args: ['AllowedMethodsEnforcer:method-not-allowed'], + }); + const revertError = new ContractFunctionRevertedError({ + abi, + data: rawData, + functionName: 'redeemDelegations', + }); + const executionError = new BaseError('Transaction simulation failed.', { + cause: revertError, + }); + + expect(decodeRevertReason(executionError)).toStrictEqual({ + errorName: 'Error', + message: 'AllowedMethodsEnforcer:method-not-allowed', + rawData, + }); + }); + }); + + describe('raw hex extraction from error messages', () => { + it('should decode revert data embedded in a plain Error message', () => { + const rawData = encodeErrorResult({ + abi: [ + { + type: 'error', + name: 'Error', + inputs: [{ name: 'message', type: 'string' }], + }, + ], + errorName: 'Error', + args: ['some-revert-reason'], + }); + const error = new Error(`Execution reverted with data: ${rawData}`); + + expect(decodeRevertReason(error)).toStrictEqual({ + errorName: 'Error', + message: 'some-revert-reason', + rawData, + }); + }); + + it('should extract hex from labeled fields like reason: and data:', () => { + const rawData = encodeErrorResult({ + abi: [ + { + type: 'error', + name: 'Error', + inputs: [{ name: 'message', type: 'string' }], + }, + ], + errorName: 'Error', + args: ['test-reason'], + }); + const error = new Error(`Details: reason: ${rawData}`); + + expect(decodeRevertReason(error)).toStrictEqual({ + errorName: 'Error', + message: 'test-reason', + rawData, + }); + }); + + it('should extract hex from nested error objects', () => { + const rawData = encodeErrorResult({ + abi: [ + { + type: 'error', + name: 'Error', + inputs: [{ name: 'message', type: 'string' }], + }, + ], + errorName: 'Error', + args: ['nested-reason'], + }); + const error = { cause: { data: rawData as string } }; + + expect(decodeRevertReason(error)).toStrictEqual({ + errorName: 'Error', + message: 'nested-reason', + rawData, + }); + }); + }); + + describe('non-decodable inputs', () => { + it('should return undefined for null', () => { + expect(decodeRevertReason(null)).toBeUndefined(); + }); + + it('should return undefined for undefined', () => { + expect(decodeRevertReason(undefined)).toBeUndefined(); + }); + + it('should return undefined for a number', () => { + expect(decodeRevertReason(42)).toBeUndefined(); + }); + + it('should return undefined for a plain string without hex', () => { + expect(decodeRevertReason('just a string')).toBeUndefined(); + }); + + it('should return undefined for an Error without hex data', () => { + expect( + decodeRevertReason(new Error('Something went wrong')), + ).toBeUndefined(); + }); + }); +}); + +describe('decodeRevertData', () => { + describe('standard Solidity errors', () => { + it('should decode Error(string)', () => { + const rawData = encodeErrorResult({ + abi: [ + { + type: 'error', + name: 'Error', + inputs: [{ name: 'message', type: 'string' }], + }, + ], + errorName: 'Error', + args: ['AllowedTargetsEnforcer:target-address-not-allowed'], + }); + + expect(decodeRevertData(rawData)).toStrictEqual({ + errorName: 'Error', + message: 'AllowedTargetsEnforcer:target-address-not-allowed', + rawData, + }); + }); + + it('should decode Panic(uint256) with a known code', () => { + const rawData = encodeErrorResult({ + abi: [ + { + type: 'error', + name: 'Panic', + inputs: [{ name: 'code', type: 'uint256' }], + }, + ], + errorName: 'Panic', + args: [17n], + }); + + expect(decodeRevertData(rawData)).toStrictEqual({ + errorName: 'Panic', + message: 'Arithmetic operation resulted in underflow or overflow.', + rawData, + }); + }); + + it('should decode Panic(uint256) with an unknown code', () => { + const rawData = encodeErrorResult({ + abi: [ + { + type: 'error', + name: 'Panic', + inputs: [{ name: 'code', type: 'uint256' }], + }, + ], + errorName: 'Panic', + args: [999n], + }); + + expect(decodeRevertData(rawData)).toStrictEqual({ + errorName: 'Panic', + message: 'Panic(999)', + rawData, + }); + }); + }); + + describe('delegation framework errors', () => { + it('should decode a no-arg delegation error (FailedInnerCall)', () => { + const rawData = encodeErrorResult({ + abi: delegationAbis.Address, + errorName: 'FailedInnerCall', + }); + + const result = decodeRevertData(rawData); + expect(result).toBeDefined(); + expect(result?.errorName).toBe('FailedInnerCall'); + }); + + it('should decode a delegation error with arguments (Create2InsufficientBalance)', () => { + const rawData = encodeErrorResult({ + abi: delegationAbis.Create2, + errorName: 'Create2InsufficientBalance', + args: [100n, 200n], + }); + + const result = decodeRevertData(rawData); + expect(result).toBeDefined(); + expect(result?.errorName).toBe('Create2InsufficientBalance'); + expect(result?.message).toContain('Create2InsufficientBalance'); + expect(result?.rawData).toBe(rawData); + }); + + it('should decode ExecutionFailed from DeleGatorCore', () => { + const rawData = encodeErrorResult({ + abi: delegationAbis.DeleGatorCore, + errorName: 'ExecutionFailed', + }); + + const result = decodeRevertData(rawData); + expect(result).toBeDefined(); + expect(result?.errorName).toBe('ExecutionFailed'); + }); + }); + + describe('raw printable ASCII fallback', () => { + it('should decode printable ASCII hex bytes', () => { + const rawData = stringToHex('Hello'); + + expect(decodeRevertData(rawData)).toStrictEqual({ + errorName: 'Error', + message: 'Hello', + rawData, + }); + }); + + it('should return undefined for hex with non-printable bytes', () => { + const rawData = '0x0001' as Hex; + + expect(decodeRevertData(rawData)).toBeUndefined(); + }); + + it('should return undefined for empty hex', () => { + expect(decodeRevertData('0x' as Hex)).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('should return undefined for hex too short to be a selector', () => { + expect(decodeRevertData('0xabcd' as Hex)).toBeUndefined(); + }); + + it('should return undefined for unrecognized ABI-encoded data', () => { + expect( + decodeRevertData('0xdeadbeefdeadbeefdeadbeefdeadbeef' as Hex), + ).toBeUndefined(); }); }); }); From 16ef21bed508552dd704fd38ed887504a540b433 Mon Sep 17 00:00:00 2001 From: MJ Kiwi Date: Wed, 27 May 2026 12:24:31 +1200 Subject: [PATCH 6/6] Refactor revert reason decoding to streamline error handling and improve code clarity --- .../src/decodeRevertReason.ts | 48 ++++++++----------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/packages/smart-accounts-kit/src/decodeRevertReason.ts b/packages/smart-accounts-kit/src/decodeRevertReason.ts index 594791f5..b76e2186 100644 --- a/packages/smart-accounts-kit/src/decodeRevertReason.ts +++ b/packages/smart-accounts-kit/src/decodeRevertReason.ts @@ -1,12 +1,19 @@ import * as delegationAbis from '@metamask/delegation-abis'; import { BaseError, ContractFunctionRevertedError, isHex } from 'viem'; -import type { Abi, AbiItem, DecodeErrorResultReturnType, Hex } from 'viem'; +import type { Abi, AbiItem, Hex } from 'viem'; import { decodeErrorResult, formatAbiItemWithArgs } from 'viem/utils'; const knownRevertAbis = (Object.values(delegationAbis) as Abi[]).filter( (abi) => abi.length > 0, ); +// viem decodes standard Solidity Error(string) and Panic(uint256) with an empty ABI. +const standardSolidityErrorAbi: Abi = [] as const; +const revertReasonAbis: readonly Abi[] = [ + standardSolidityErrorAbi, + ...knownRevertAbis, +]; + const MAX_ERROR_TRAVERSAL_DEPTH = 8; const panicReasons: Record = { @@ -80,7 +87,12 @@ function decodeViemContractRevert( } if (revertError.data) { - return formatDecodeErrorResult(revertError.data, revertError.raw); + const { abiItem, args, errorName } = revertError.data; + return { + errorName, + message: formatDecodedError(errorName, args, abiItem), + rawData: revertError.raw, + }; } return decodeRevertData(revertError.raw); @@ -95,9 +107,7 @@ function decodeViemContractRevert( export function decodeRevertData( rawData: Hex, ): DecodedRevertReason | undefined { - const abis = [[] as const, ...knownRevertAbis]; - - for (const abi of abis) { + for (const abi of revertReasonAbis) { try { const { abiItem, args, errorName } = decodeErrorResult({ abi, @@ -127,26 +137,6 @@ export function decodeRevertData( return undefined; } -/** - * Formats viem's decoded error result into the helper's return shape. - * - * @param decodedData - The decoded error result from viem. - * @param rawData - The ABI-encoded revert data. - * @returns The structured decoded revert reason. - */ -function formatDecodeErrorResult( - decodedData: DecodeErrorResultReturnType, - rawData: Hex, -): DecodedRevertReason { - const { abiItem, args, errorName } = decodedData; - - return { - errorName, - message: formatDecodedError(errorName, args, abiItem), - rawData, - }; -} - /** * Formats a decoded error into compact user-facing text. * @@ -160,14 +150,14 @@ function formatDecodedError( args: readonly unknown[] | undefined, abiItem: AbiItem, ): string { + const [firstArg] = args ?? []; + if (errorName === 'Error') { - const [reason] = args ?? []; - return typeof reason === 'string' ? reason : errorName; + return typeof firstArg === 'string' ? firstArg : errorName; } if (errorName === 'Panic') { - const [code] = args ?? []; - const panicCode = String(code); + const panicCode = String(firstArg); return panicReasons[panicCode] ?? `Panic(${panicCode})`; }