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 61860817..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 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/decodeRevertReason.ts b/packages/smart-accounts-kit/src/decodeRevertReason.ts new file mode 100644 index 00000000..b76e2186 --- /dev/null +++ b/packages/smart-accounts-kit/src/decodeRevertReason.ts @@ -0,0 +1,297 @@ +import * as delegationAbis from '@metamask/delegation-abis'; +import { BaseError, ContractFunctionRevertedError, isHex } 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 = { + '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.', +}; + +export type DecodedRevertReason = { + readonly errorName: string; + readonly message: string; + readonly rawData: Hex; +}; + +/** + * Decodes the first recognized revert data candidate in an error chain. + * + * @param error - The original error thrown by viem or an RPC provider. + * @returns A decoded revert reason, if one can be recognized. + */ +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); + + if (decoded) { + return decoded; + } + } + + 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) { + const { abiItem, args, errorName } = revertError.data; + return { + errorName, + message: formatDecodedError(errorName, args, abiItem), + rawData: revertError.raw, + }; + } + + return decodeRevertData(revertError.raw); +} + +/** + * 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. + */ +export function decodeRevertData( + rawData: Hex, +): DecodedRevertReason | undefined { + for (const abi of revertReasonAbis) { + 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. + } + } + + const decodedString = decodeRawString(rawData); + + if (decodedString) { + return { + errorName: 'Error', + message: decodedString, + rawData, + }; + } + + 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 { + const [firstArg] = args ?? []; + + if (errorName === 'Error') { + return typeof firstArg === 'string' ? firstArg : errorName; + } + + if (errorName === 'Panic') { + const panicCode = String(firstArg); + return panicReasons[panicCode] ?? `Panic(${panicCode})`; + } + + const formattedArgs = formatAbiItemWithArgs({ + abiItem, + args: args ?? [], + includeFunctionName: false, + includeName: false, + }); + + return `${errorName}${formattedArgs ?? ''}`; +} + +/** + * Decodes revert payloads surfaced by some clients as raw printable ASCII 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 { + 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. + * + * @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( + /\b(?: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 > MAX_ERROR_TRAVERSAL_DEPTH + ) { + 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.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); + }; + + visit(error); + + return candidates; +} 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 3a4e3856..75fd191a 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'; @@ -25,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, @@ -266,6 +268,90 @@ describe('erc7710RedeemDelegationAction', () => { }); }); + it('should preserve raw bundler errors and allow callers to decode revert reasons', 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.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 () => { + 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); + + expect(decodeRevertReason(bundlerError)).toBeUndefined(); + }); + it('should throw an error when SimpleFactory is provided as dependencies factory', async () => { const bundlerClient = createBundlerClient({ transport: custom({ request: mockBundlerRequest }), @@ -544,6 +630,47 @@ describe('erc7710RedeemDelegationAction', () => { }); }); + it('should preserve raw transaction errors and allow callers to decode revert reasons', 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'], + }); + + const transactionError = new Error( + `Transaction simulation failed. Details: ${revertData}`, + ); + stub(walletClient, 'sendTransaction').rejects(transactionError); + + const args: SendTransactionWithDelegationParameters = { + account, + chain, + to: randomAddress(), + value: 0n, + data: randomBytes(128), + permissionContext: [createDelegation()], + delegationManager: expectedDelegationManager, + }; + + await expect( + extendedWalletClient.sendTransactionWithDelegation(args), + ).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 () => { const extendedWalletClient = walletClient.extend(erc7710WalletActions()); 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..a999b3a3 --- /dev/null +++ b/packages/smart-accounts-kit/test/decodeRevertReason.test.ts @@ -0,0 +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 { + decodeRevertData, + decodeRevertReason, +} from '../src/decodeRevertReason'; + +describe('decodeRevertReason', () => { + 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(); + }); + }); +});