From 551e84911c0720e78406137a5ec5b514b37c83cb Mon Sep 17 00:00:00 2001 From: Jean Regisser Date: Wed, 16 Jul 2025 15:39:18 +0200 Subject: [PATCH 1/9] feat(4337): add toUserOperation utility --- src/account-abstraction/index.ts | 1 + .../userOperation/toUserOperation.test.ts | 255 ++++++++++++++++++ .../utils/userOperation/toUserOperation.ts | 139 ++++++++++ 3 files changed, 395 insertions(+) create mode 100644 src/account-abstraction/utils/userOperation/toUserOperation.test.ts create mode 100644 src/account-abstraction/utils/userOperation/toUserOperation.ts diff --git a/src/account-abstraction/index.ts b/src/account-abstraction/index.ts index 76ad7f38d8..978c5459c1 100644 --- a/src/account-abstraction/index.ts +++ b/src/account-abstraction/index.ts @@ -253,3 +253,4 @@ export { getUserOperationTypedData, } from './utils/userOperation/getUserOperationTypedData.js' export { toPackedUserOperation } from './utils/userOperation/toPackedUserOperation.js' +export { toUserOperation } from './utils/userOperation/toUserOperation.js' diff --git a/src/account-abstraction/utils/userOperation/toUserOperation.test.ts b/src/account-abstraction/utils/userOperation/toUserOperation.test.ts new file mode 100644 index 0000000000..c8a95e8efa --- /dev/null +++ b/src/account-abstraction/utils/userOperation/toUserOperation.test.ts @@ -0,0 +1,255 @@ +import { expect, test } from 'vitest' +import type { UserOperation } from '../../types/userOperation.js' +import { toPackedUserOperation } from './toPackedUserOperation.js' +import { toUserOperation } from './toUserOperation.js' + +test('round trip: basic user operation', () => { + const userOp: UserOperation = { + callData: '0x', + callGasLimit: 200000n, + maxFeePerGas: 20000000000n, + maxPriorityFeePerGas: 1000000000n, + nonce: 0n, + preVerificationGas: 50000n, + sender: '0x1234567890123456789012345678901234567890', + signature: '0x', + verificationGasLimit: 100000n, + } + + expect(toUserOperation(toPackedUserOperation(userOp))).toStrictEqual({ + ...userOp, + // packed format adds these empty fields for absent optional data + initCode: '0x', + paymasterAndData: '0x', + }) +}) + +test('round trip: with factory fields', () => { + const original: UserOperation = { + callData: '0x', + callGasLimit: 200000n, + maxFeePerGas: 20000000000n, + maxPriorityFeePerGas: 1000000000n, + nonce: 0n, + preVerificationGas: 50000n, + sender: '0x1234567890123456789012345678901234567890', + signature: '0x', + verificationGasLimit: 100000n, + factory: '0x1234567890123456789012345678901234567890', + factoryData: '0xdeadbeef', + } + + expect(toUserOperation(toPackedUserOperation(original))).toStrictEqual({ + ...original, + // packed format adds these empty fields for absent optional data + paymasterAndData: '0x', + }) +}) + +test('round trip: complete operation with all fields', () => { + const original: UserOperation = { + callData: + '0xb61d27f60000000000000000000000004e59b44847b379578588920ca78fbf26c0b4956c', + callGasLimit: 211314n, + maxFeePerGas: 49900000336n, + maxPriorityFeePerGas: 1650000n, + nonce: 32324340618445458439256401772544n, + preVerificationGas: 66333n, + sender: '0x11e70472DC78AD2e11650B497cAB1f55a647914c', + signature: + '0xbf0c42a5f75c5159bb9c32864e6ff3cccb7bef6a0e019aaca9b54740efb6ad5f4a80b2d4106cc67ac94d7a8bcb345362210c2165b67e48f4646039065d58865e1b', + verificationGasLimit: 61585n, + factory: '0x1234567890123456789012345678901234567890', + factoryData: '0xdeadbeef', + paymaster: '0x777777777777aec03fd955926dbf81597e66834c', + paymasterVerificationGasLimit: 150000n, + paymasterPostOpGasLimit: 100000n, + paymasterData: '0xcafebabe', + } + + expect(toUserOperation(toPackedUserOperation(original))).toStrictEqual( + original, + ) +}) + +test('idempotency: preserves already unpacked user operation', () => { + const original: UserOperation = { + callData: '0x', + callGasLimit: 200000n, + maxFeePerGas: 20000000000n, + maxPriorityFeePerGas: 1000000000n, + nonce: 0n, + preVerificationGas: 50000n, + sender: '0x1234567890123456789012345678901234567890', + signature: '0x', + verificationGasLimit: 100000n, + } + + expect(toUserOperation(original)).toStrictEqual(original) +}) + +test('edge case: mixed packed and unpacked fields', () => { + // Test with some fields already unpacked and some still packed + const mixedUserOperation = { + sender: '0x1234567890123456789012345678901234567890' as const, + nonce: 0n, + initCode: '0x' as const, + callData: '0x' as const, + // Already unpacked gas limits - these should be preserved + callGasLimit: 300000n, + verificationGasLimit: 150000n, + preVerificationGas: 50000n, + // Still packed gas fees - these should be unpacked + gasFees: + '0x0000000000000000000000003b9aca000000000000000000000004a817c800' as const, + paymasterAndData: '0x' as const, + signature: '0x' as const, + } as any // Using 'as any' since this mixed format doesn't match either type + + expect(toUserOperation(mixedUserOperation)).toStrictEqual({ + sender: '0x1234567890123456789012345678901234567890', + nonce: 0n, + initCode: '0x', + callData: '0x', + callGasLimit: 300000n, + verificationGasLimit: 150000n, + preVerificationGas: 50000n, + maxPriorityFeePerGas: 1000000000n, + maxFeePerGas: 20000000000n, + paymasterAndData: '0x', + signature: '0x', + }) +}) + +test('edge case: zero values in packed format', () => { + const packedUserOperation = { + sender: '0x1234567890123456789012345678901234567890' as const, + nonce: 0n, + initCode: '0x' as const, + callData: '0x' as const, + // All zeros + accountGasLimits: + '0x00000000000000000000000000000000000000000000000000000000000000000' as const, + preVerificationGas: 0n, + gasFees: + '0x00000000000000000000000000000000000000000000000000000000000000000' as const, + paymasterAndData: '0x' as const, + signature: '0x' as const, + } + + expect(toUserOperation(packedUserOperation)).toStrictEqual({ + sender: '0x1234567890123456789012345678901234567890', + nonce: 0n, + initCode: '0x', + callData: '0x', + verificationGasLimit: 0n, + callGasLimit: 0n, + preVerificationGas: 0n, + maxPriorityFeePerGas: 0n, + maxFeePerGas: 0n, + paymasterAndData: '0x', + signature: '0x', + }) +}) + +test('edge case: short paymasterAndData not unpacked', () => { + // Test that short paymasterAndData (less than minimum packed format) is preserved as-is + const packedUserOperation = { + sender: '0x1234567890123456789012345678901234567890' as const, + nonce: 0n, + initCode: '0x' as const, + callData: '0x' as const, + accountGasLimits: + '0x00000000000000000000000030d400000000000000000000000001e8480' as const, + preVerificationGas: 50000n, + gasFees: + '0x0000000000000000000000003b9aca000000000000000000000004a817c800' as const, + paymasterAndData: '0x1234' as const, // Too short to be packed format + signature: '0x' as const, + } + + expect(toUserOperation(packedUserOperation)).toStrictEqual({ + sender: '0x1234567890123456789012345678901234567890', + nonce: 0n, + initCode: '0x', + callData: '0x', + verificationGasLimit: 819200000n, + callGasLimit: 2000000n, + preVerificationGas: 50000n, + maxPriorityFeePerGas: 1000000000n, + maxFeePerGas: 20000000000n, + paymasterAndData: '0x1234', // Should preserve short paymasterAndData as-is + signature: '0x', + }) +}) + +test('edge case: EIP-7702 authorization prefix in initCode', () => { + // Test handling of EIP-7702 authorization prefix + const packedUserOperation = { + sender: '0x1234567890123456789012345678901234567890' as const, + nonce: 0n, + initCode: '0x7702000000000000000000000000000000000000' as const, + callData: '0x' as const, + accountGasLimits: + '0x00000000000000000000000030d400000000000000000000000001e8480' as const, + preVerificationGas: 50000n, + gasFees: + '0x0000000000000000000000003b9aca000000000000000000000004a817c800' as const, + paymasterAndData: '0x' as const, + signature: '0x' as const, + } + + expect(toUserOperation(packedUserOperation)).toStrictEqual({ + sender: '0x1234567890123456789012345678901234567890', + nonce: 0n, + callData: '0x', + verificationGasLimit: 819200000n, + callGasLimit: 2000000n, + preVerificationGas: 50000n, + maxPriorityFeePerGas: 1000000000n, + maxFeePerGas: 20000000000n, + paymasterAndData: '0x', + signature: '0x', + factory: '0x7702000000000000000000000000000000000000', + }) +}) + +test('edge case: packed paymaster unpacking', () => { + // Test unpacking of properly formatted packed paymasterAndData + const packedUserOperation = { + sender: '0x1234567890123456789012345678901234567890' as const, + nonce: 0n, + initCode: '0x' as const, + callData: '0x' as const, + accountGasLimits: + '0x00000000000000000000000030d400000000000000000000000001e8480' as const, + preVerificationGas: 50000n, + gasFees: + '0x0000000000000000000000003b9aca000000000000000000000004a817c800' as const, + // Packed paymaster: paymaster(20) + verificationGasLimit(16) + postOpGasLimit(16) + data + // paymaster: 777777777777aec03fd955926dbf81597e66834c (20 bytes) + // verificationGasLimit: 000000000000000000000000000249f0 (16 bytes = 150000) + // postOpGasLimit: 000000000000000000000000000186a0 (16 bytes = 100000) + // data: cafebabe (4 bytes) + paymasterAndData: + '0x777777777777aec03fd955926dbf81597e66834c000000000000000000000000000249f0000000000000000000000000000186a0cafebabe' as const, + signature: '0x' as const, + } + + expect(toUserOperation(packedUserOperation)).toStrictEqual({ + sender: '0x1234567890123456789012345678901234567890', + nonce: 0n, + initCode: '0x', + callData: '0x', + verificationGasLimit: 819200000n, + callGasLimit: 2000000n, + preVerificationGas: 50000n, + maxPriorityFeePerGas: 1000000000n, + maxFeePerGas: 20000000000n, + signature: '0x', + paymaster: '0x777777777777aec03fd955926dbf81597e66834c', + paymasterVerificationGasLimit: 150000n, + paymasterPostOpGasLimit: 100000n, + paymasterData: '0xcafebabe', + }) +}) diff --git a/src/account-abstraction/utils/userOperation/toUserOperation.ts b/src/account-abstraction/utils/userOperation/toUserOperation.ts new file mode 100644 index 0000000000..07acfc28b9 --- /dev/null +++ b/src/account-abstraction/utils/userOperation/toUserOperation.ts @@ -0,0 +1,139 @@ +import { slice } from '../../../utils/data/slice.js' +import { hexToBigInt } from '../../../utils/encoding/fromHex.js' +import type { + PackedUserOperation, + UserOperation, +} from '../../types/userOperation.js' + +/** + * Converts a PackedUserOperation to UserOperation format by unpacking packed fields. + * + * This function is the reverse operation of `toPackedUserOperation`. It operates + * field-by-field, using existing unpacked fields when available and only unpacking + * packed fields when the unpacked versions don't exist. + * + * @param packedUserOperation - The user operation (with packed or unpacked fields) + * @returns The unpacked UserOperation + * + * @example + * ```ts + * import { toUserOperation } from 'viem/account-abstraction' + * + * // Unpacks packed fields when individual fields don't exist + * const packedUserOp = { + * sender: '0x...', + * accountGasLimits: '0x...', // will be unpacked to verificationGasLimit + callGasLimit + * gasFees: '0x...', // will be unpacked to maxPriorityFeePerGas + maxFeePerGas + * // ... + * } + * + * // Uses existing unpacked fields when available + * const mixedUserOp = { + * sender: '0x...', + * callGasLimit: 1000000n, // uses this instead of unpacking accountGasLimits + * gasFees: '0x...', // will unpack maxPriorityFeePerGas + maxFeePerGas since they don't exist + * // ... + * } + * + * const result = toUserOperation(packedUserOp) + * ``` + */ +export function toUserOperation( + packedUserOperation: PackedUserOperation | UserOperation, +): UserOperation { + const userOp: Record = { ...packedUserOperation } + + // Handle gas limits: use existing individual fields or unpack accountGasLimits + if ( + !userOp.verificationGasLimit && + !userOp.callGasLimit && + userOp.accountGasLimits + ) { + userOp.verificationGasLimit = hexToBigInt( + slice(userOp.accountGasLimits, 0, 16) as `0x${string}`, + ) + userOp.callGasLimit = hexToBigInt( + slice(userOp.accountGasLimits, 16, 32) as `0x${string}`, + ) + } + + // Handle gas fees: use existing individual fields or unpack gasFees + if (!userOp.maxPriorityFeePerGas && !userOp.maxFeePerGas && userOp.gasFees) { + userOp.maxPriorityFeePerGas = hexToBigInt( + slice(userOp.gasFees, 0, 16) as `0x${string}`, + ) + userOp.maxFeePerGas = hexToBigInt( + slice(userOp.gasFees, 16, 32) as `0x${string}`, + ) + } + + // Handle initCode: use existing individual fields or unpack initCode (reverse of getInitCode) + if ( + !userOp.factory && + !userOp.factoryData && + userOp.initCode && + userOp.initCode !== '0x' + ) { + const initCodeHex = userOp.initCode as `0x${string}` + + // Check for EIP-7702 authorization (exactly equals the prefix, meaning no authorization) + const eip7702Prefix = '0x7702000000000000000000000000000000000000' + if (initCodeHex === eip7702Prefix) { + userOp.factory = eip7702Prefix as `0x${string}` + // Remove packed initCode since we've unpacked it + delete userOp.initCode + } else { + // Normal case or EIP-7702 with authorization (can't distinguish, so treat as normal) + // factory (20 bytes) + factoryData (rest) + userOp.factory = slice(initCodeHex, 0, 20) + const factoryDataSlice = slice(initCodeHex, 20) + if (factoryDataSlice.length > 2) { + userOp.factoryData = factoryDataSlice + } + // Remove packed initCode since we've unpacked it + delete userOp.initCode + } + } + + // Handle paymaster: use existing individual fields or unpack paymasterAndData + if ( + !userOp.paymaster && + !userOp.paymasterVerificationGasLimit && + !userOp.paymasterPostOpGasLimit && + !userOp.paymasterData + ) { + // Only unpack paymasterAndData if this appears to be packed format (has packed fields) + const hasPackedFields = userOp.accountGasLimits || userOp.gasFees + if ( + userOp.paymasterAndData && + userOp.paymasterAndData !== '0x' && + hasPackedFields && + userOp.paymasterAndData.length >= 106 + ) { + // 2 + 20*2 + 16*2 + 16*2 = 106 chars minimum for packed format + // paymaster (20 bytes) + paymasterVerificationGasLimit (16 bytes) + paymasterPostOpGasLimit (16 bytes) + paymasterData (rest) + userOp.paymaster = slice(userOp.paymasterAndData, 0, 20) as `0x${string}` + userOp.paymasterVerificationGasLimit = hexToBigInt( + slice(userOp.paymasterAndData, 20, 36) as `0x${string}`, + ) + userOp.paymasterPostOpGasLimit = hexToBigInt( + slice(userOp.paymasterAndData, 36, 52) as `0x${string}`, + ) + const paymasterDataSlice = slice( + userOp.paymasterAndData, + 52, + ) as `0x${string}` + if (paymasterDataSlice.length > 2) { + userOp.paymasterData = paymasterDataSlice + } + // Remove packed paymasterAndData since we've unpacked it + delete userOp.paymasterAndData + } + } + + // Remove packed fields to ensure clean unpacked format + delete userOp.accountGasLimits + delete userOp.gasFees + + return userOp as UserOperation +} From 9b838387a191369d3c0a3a7d2f7c99c2bb8d77cd Mon Sep 17 00:00:00 2001 From: Jean Regisser Date: Wed, 16 Jul 2025 16:38:18 +0200 Subject: [PATCH 2/9] Add changeset --- .changeset/selfish-goats-appear.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/selfish-goats-appear.md diff --git a/.changeset/selfish-goats-appear.md b/.changeset/selfish-goats-appear.md new file mode 100644 index 0000000000..973a1551bd --- /dev/null +++ b/.changeset/selfish-goats-appear.md @@ -0,0 +1,5 @@ +--- +"viem": minor +--- + +**Account Abstraction**: Added `toUserOperation` utility to convert `PackedUserOperation` to `UserOperation`. From 89d8dbaeec88c582da43cffa6c4f9780b724c778 Mon Sep 17 00:00:00 2001 From: Jean Regisser Date: Wed, 16 Jul 2025 16:45:56 +0200 Subject: [PATCH 3/9] Format --- .../utils/userOperation/toUserOperation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/account-abstraction/utils/userOperation/toUserOperation.test.ts b/src/account-abstraction/utils/userOperation/toUserOperation.test.ts index c8a95e8efa..d897a47ce3 100644 --- a/src/account-abstraction/utils/userOperation/toUserOperation.test.ts +++ b/src/account-abstraction/utils/userOperation/toUserOperation.test.ts @@ -229,7 +229,7 @@ test('edge case: packed paymaster unpacking', () => { // Packed paymaster: paymaster(20) + verificationGasLimit(16) + postOpGasLimit(16) + data // paymaster: 777777777777aec03fd955926dbf81597e66834c (20 bytes) // verificationGasLimit: 000000000000000000000000000249f0 (16 bytes = 150000) - // postOpGasLimit: 000000000000000000000000000186a0 (16 bytes = 100000) + // postOpGasLimit: 000000000000000000000000000186a0 (16 bytes = 100000) // data: cafebabe (4 bytes) paymasterAndData: '0x777777777777aec03fd955926dbf81597e66834c000000000000000000000000000249f0000000000000000000000000000186a0cafebabe' as const, From b0990dd146ede81bde020c1135bd511c15910910 Mon Sep 17 00:00:00 2001 From: Jean Regisser Date: Wed, 16 Jul 2025 17:01:00 +0200 Subject: [PATCH 4/9] Remove redundant as const --- .../userOperation/toUserOperation.test.ts | 85 +++++++++---------- 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/src/account-abstraction/utils/userOperation/toUserOperation.test.ts b/src/account-abstraction/utils/userOperation/toUserOperation.test.ts index d897a47ce3..3b0f47148e 100644 --- a/src/account-abstraction/utils/userOperation/toUserOperation.test.ts +++ b/src/account-abstraction/utils/userOperation/toUserOperation.test.ts @@ -1,5 +1,8 @@ import { expect, test } from 'vitest' -import type { UserOperation } from '../../types/userOperation.js' +import type { + PackedUserOperation, + UserOperation, +} from '../../types/userOperation.js' import { toPackedUserOperation } from './toPackedUserOperation.js' import { toUserOperation } from './toUserOperation.js' @@ -91,19 +94,18 @@ test('idempotency: preserves already unpacked user operation', () => { test('edge case: mixed packed and unpacked fields', () => { // Test with some fields already unpacked and some still packed const mixedUserOperation = { - sender: '0x1234567890123456789012345678901234567890' as const, + sender: '0x1234567890123456789012345678901234567890', nonce: 0n, - initCode: '0x' as const, - callData: '0x' as const, + initCode: '0x', + callData: '0x', // Already unpacked gas limits - these should be preserved callGasLimit: 300000n, verificationGasLimit: 150000n, preVerificationGas: 50000n, // Still packed gas fees - these should be unpacked - gasFees: - '0x0000000000000000000000003b9aca000000000000000000000004a817c800' as const, - paymasterAndData: '0x' as const, - signature: '0x' as const, + gasFees: '0x0000000000000000000000003b9aca000000000000000000000004a817c800', + paymasterAndData: '0x', + signature: '0x', } as any // Using 'as any' since this mixed format doesn't match either type expect(toUserOperation(mixedUserOperation)).toStrictEqual({ @@ -122,19 +124,19 @@ test('edge case: mixed packed and unpacked fields', () => { }) test('edge case: zero values in packed format', () => { - const packedUserOperation = { - sender: '0x1234567890123456789012345678901234567890' as const, + const packedUserOperation: PackedUserOperation = { + sender: '0x1234567890123456789012345678901234567890', nonce: 0n, - initCode: '0x' as const, - callData: '0x' as const, + initCode: '0x', + callData: '0x', // All zeros accountGasLimits: - '0x00000000000000000000000000000000000000000000000000000000000000000' as const, + '0x00000000000000000000000000000000000000000000000000000000000000000', preVerificationGas: 0n, gasFees: - '0x00000000000000000000000000000000000000000000000000000000000000000' as const, - paymasterAndData: '0x' as const, - signature: '0x' as const, + '0x00000000000000000000000000000000000000000000000000000000000000000', + paymasterAndData: '0x', + signature: '0x', } expect(toUserOperation(packedUserOperation)).toStrictEqual({ @@ -154,18 +156,17 @@ test('edge case: zero values in packed format', () => { test('edge case: short paymasterAndData not unpacked', () => { // Test that short paymasterAndData (less than minimum packed format) is preserved as-is - const packedUserOperation = { - sender: '0x1234567890123456789012345678901234567890' as const, + const packedUserOperation: PackedUserOperation = { + sender: '0x1234567890123456789012345678901234567890', nonce: 0n, - initCode: '0x' as const, - callData: '0x' as const, + initCode: '0x', + callData: '0x', accountGasLimits: - '0x00000000000000000000000030d400000000000000000000000001e8480' as const, + '0x00000000000000000000000030d400000000000000000000000001e8480', preVerificationGas: 50000n, - gasFees: - '0x0000000000000000000000003b9aca000000000000000000000004a817c800' as const, - paymasterAndData: '0x1234' as const, // Too short to be packed format - signature: '0x' as const, + gasFees: '0x0000000000000000000000003b9aca000000000000000000000004a817c800', + paymasterAndData: '0x1234', // Too short to be packed format + signature: '0x', } expect(toUserOperation(packedUserOperation)).toStrictEqual({ @@ -185,18 +186,17 @@ test('edge case: short paymasterAndData not unpacked', () => { test('edge case: EIP-7702 authorization prefix in initCode', () => { // Test handling of EIP-7702 authorization prefix - const packedUserOperation = { - sender: '0x1234567890123456789012345678901234567890' as const, + const packedUserOperation: PackedUserOperation = { + sender: '0x1234567890123456789012345678901234567890', nonce: 0n, - initCode: '0x7702000000000000000000000000000000000000' as const, - callData: '0x' as const, + initCode: '0x7702000000000000000000000000000000000000', + callData: '0x', accountGasLimits: - '0x00000000000000000000000030d400000000000000000000000001e8480' as const, + '0x00000000000000000000000030d400000000000000000000000001e8480', preVerificationGas: 50000n, - gasFees: - '0x0000000000000000000000003b9aca000000000000000000000004a817c800' as const, - paymasterAndData: '0x' as const, - signature: '0x' as const, + gasFees: '0x0000000000000000000000003b9aca000000000000000000000004a817c800', + paymasterAndData: '0x', + signature: '0x', } expect(toUserOperation(packedUserOperation)).toStrictEqual({ @@ -216,24 +216,23 @@ test('edge case: EIP-7702 authorization prefix in initCode', () => { test('edge case: packed paymaster unpacking', () => { // Test unpacking of properly formatted packed paymasterAndData - const packedUserOperation = { - sender: '0x1234567890123456789012345678901234567890' as const, + const packedUserOperation: PackedUserOperation = { + sender: '0x1234567890123456789012345678901234567890', nonce: 0n, - initCode: '0x' as const, - callData: '0x' as const, + initCode: '0x', + callData: '0x', accountGasLimits: - '0x00000000000000000000000030d400000000000000000000000001e8480' as const, + '0x00000000000000000000000030d400000000000000000000000001e8480', preVerificationGas: 50000n, - gasFees: - '0x0000000000000000000000003b9aca000000000000000000000004a817c800' as const, + gasFees: '0x0000000000000000000000003b9aca000000000000000000000004a817c800', // Packed paymaster: paymaster(20) + verificationGasLimit(16) + postOpGasLimit(16) + data // paymaster: 777777777777aec03fd955926dbf81597e66834c (20 bytes) // verificationGasLimit: 000000000000000000000000000249f0 (16 bytes = 150000) // postOpGasLimit: 000000000000000000000000000186a0 (16 bytes = 100000) // data: cafebabe (4 bytes) paymasterAndData: - '0x777777777777aec03fd955926dbf81597e66834c000000000000000000000000000249f0000000000000000000000000000186a0cafebabe' as const, - signature: '0x' as const, + '0x777777777777aec03fd955926dbf81597e66834c000000000000000000000000000249f0000000000000000000000000000186a0cafebabe', + signature: '0x', } expect(toUserOperation(packedUserOperation)).toStrictEqual({ From 11c416915b8b8353526cc6d04af6ba40188bfd4c Mon Sep 17 00:00:00 2001 From: Jean Regisser Date: Wed, 16 Jul 2025 19:24:56 +0200 Subject: [PATCH 5/9] Better type safety --- .../utils/userOperation/toUserOperation.ts | 102 +++++++++--------- 1 file changed, 49 insertions(+), 53 deletions(-) diff --git a/src/account-abstraction/utils/userOperation/toUserOperation.ts b/src/account-abstraction/utils/userOperation/toUserOperation.ts index 07acfc28b9..9cbc6a6f1f 100644 --- a/src/account-abstraction/utils/userOperation/toUserOperation.ts +++ b/src/account-abstraction/utils/userOperation/toUserOperation.ts @@ -12,7 +12,7 @@ import type { * field-by-field, using existing unpacked fields when available and only unpacking * packed fields when the unpacked versions don't exist. * - * @param packedUserOperation - The user operation (with packed or unpacked fields) + * @param userOperation - The user operation (with packed or unpacked fields) * @returns The unpacked UserOperation * * @example @@ -39,101 +39,97 @@ import type { * ``` */ export function toUserOperation( - packedUserOperation: PackedUserOperation | UserOperation, + userOperation: PackedUserOperation | UserOperation, ): UserOperation { - const userOp: Record = { ...packedUserOperation } + const { accountGasLimits, gasFees, ...restUserOperation } = + userOperation as PackedUserOperation + const result = { ...restUserOperation } as unknown as UserOperation // Handle gas limits: use existing individual fields or unpack accountGasLimits if ( - !userOp.verificationGasLimit && - !userOp.callGasLimit && - userOp.accountGasLimits + !('verificationGasLimit' in userOperation) && + !('callGasLimit' in userOperation) && + 'accountGasLimits' in userOperation ) { - userOp.verificationGasLimit = hexToBigInt( - slice(userOp.accountGasLimits, 0, 16) as `0x${string}`, + result.verificationGasLimit = hexToBigInt( + slice(userOperation.accountGasLimits, 0, 16), ) - userOp.callGasLimit = hexToBigInt( - slice(userOp.accountGasLimits, 16, 32) as `0x${string}`, + result.callGasLimit = hexToBigInt( + slice(userOperation.accountGasLimits, 16, 32), ) } // Handle gas fees: use existing individual fields or unpack gasFees - if (!userOp.maxPriorityFeePerGas && !userOp.maxFeePerGas && userOp.gasFees) { - userOp.maxPriorityFeePerGas = hexToBigInt( - slice(userOp.gasFees, 0, 16) as `0x${string}`, - ) - userOp.maxFeePerGas = hexToBigInt( - slice(userOp.gasFees, 16, 32) as `0x${string}`, + if ( + !('maxPriorityFeePerGas' in userOperation) && + !('maxFeePerGas' in userOperation) && + 'gasFees' in userOperation + ) { + result.maxPriorityFeePerGas = hexToBigInt( + slice(userOperation.gasFees, 0, 16), ) + result.maxFeePerGas = hexToBigInt(slice(userOperation.gasFees, 16, 32)) } // Handle initCode: use existing individual fields or unpack initCode (reverse of getInitCode) if ( - !userOp.factory && - !userOp.factoryData && - userOp.initCode && - userOp.initCode !== '0x' + !('factory' in userOperation) && + !('factoryData' in userOperation) && + userOperation.initCode && + userOperation.initCode !== '0x' ) { - const initCodeHex = userOp.initCode as `0x${string}` - // Check for EIP-7702 authorization (exactly equals the prefix, meaning no authorization) const eip7702Prefix = '0x7702000000000000000000000000000000000000' - if (initCodeHex === eip7702Prefix) { - userOp.factory = eip7702Prefix as `0x${string}` + if (userOperation.initCode === eip7702Prefix) { + result.factory = eip7702Prefix // Remove packed initCode since we've unpacked it - delete userOp.initCode + delete result.initCode } else { // Normal case or EIP-7702 with authorization (can't distinguish, so treat as normal) // factory (20 bytes) + factoryData (rest) - userOp.factory = slice(initCodeHex, 0, 20) - const factoryDataSlice = slice(initCodeHex, 20) + result.factory = slice(userOperation.initCode, 0, 20) + const factoryDataSlice = slice(userOperation.initCode, 20) if (factoryDataSlice.length > 2) { - userOp.factoryData = factoryDataSlice + result.factoryData = factoryDataSlice } // Remove packed initCode since we've unpacked it - delete userOp.initCode + delete result.initCode } } // Handle paymaster: use existing individual fields or unpack paymasterAndData if ( - !userOp.paymaster && - !userOp.paymasterVerificationGasLimit && - !userOp.paymasterPostOpGasLimit && - !userOp.paymasterData + !('paymaster' in userOperation) && + !('paymasterVerificationGasLimit' in userOperation) && + !('paymasterPostOpGasLimit' in userOperation) && + !('paymasterData' in userOperation) ) { // Only unpack paymasterAndData if this appears to be packed format (has packed fields) - const hasPackedFields = userOp.accountGasLimits || userOp.gasFees + const hasPackedFields = + 'accountGasLimits' in userOperation || 'gasFees' in userOperation if ( - userOp.paymasterAndData && - userOp.paymasterAndData !== '0x' && hasPackedFields && - userOp.paymasterAndData.length >= 106 + userOperation.paymasterAndData && + userOperation.paymasterAndData !== '0x' && + userOperation.paymasterAndData.length >= 106 ) { // 2 + 20*2 + 16*2 + 16*2 = 106 chars minimum for packed format // paymaster (20 bytes) + paymasterVerificationGasLimit (16 bytes) + paymasterPostOpGasLimit (16 bytes) + paymasterData (rest) - userOp.paymaster = slice(userOp.paymasterAndData, 0, 20) as `0x${string}` - userOp.paymasterVerificationGasLimit = hexToBigInt( - slice(userOp.paymasterAndData, 20, 36) as `0x${string}`, + result.paymaster = slice(userOperation.paymasterAndData, 0, 20) + result.paymasterVerificationGasLimit = hexToBigInt( + slice(userOperation.paymasterAndData, 20, 36), ) - userOp.paymasterPostOpGasLimit = hexToBigInt( - slice(userOp.paymasterAndData, 36, 52) as `0x${string}`, + result.paymasterPostOpGasLimit = hexToBigInt( + slice(userOperation.paymasterAndData, 36, 52), ) - const paymasterDataSlice = slice( - userOp.paymasterAndData, - 52, - ) as `0x${string}` + const paymasterDataSlice = slice(userOperation.paymasterAndData, 52) if (paymasterDataSlice.length > 2) { - userOp.paymasterData = paymasterDataSlice + result.paymasterData = paymasterDataSlice } // Remove packed paymasterAndData since we've unpacked it - delete userOp.paymasterAndData + delete result.paymasterAndData } } - // Remove packed fields to ensure clean unpacked format - delete userOp.accountGasLimits - delete userOp.gasFees - - return userOp as UserOperation + return result } From a7f36babe131b5afd2f9b1a7dee9f973943a958b Mon Sep 17 00:00:00 2001 From: Jean Regisser Date: Thu, 17 Jul 2025 11:41:52 +0200 Subject: [PATCH 6/9] Better handle 0.6 UserOperation --- .../userOperation/toUserOperation.test.ts | 40 ++++++++++--- .../utils/userOperation/toUserOperation.ts | 60 ++++++++++--------- 2 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/account-abstraction/utils/userOperation/toUserOperation.test.ts b/src/account-abstraction/utils/userOperation/toUserOperation.test.ts index 3b0f47148e..3d01fca1d5 100644 --- a/src/account-abstraction/utils/userOperation/toUserOperation.test.ts +++ b/src/account-abstraction/utils/userOperation/toUserOperation.test.ts @@ -28,7 +28,7 @@ test('round trip: basic user operation', () => { }) test('round trip: with factory fields', () => { - const original: UserOperation = { + const userOp: UserOperation = { callData: '0x', callGasLimit: 200000n, maxFeePerGas: 20000000000n, @@ -42,15 +42,15 @@ test('round trip: with factory fields', () => { factoryData: '0xdeadbeef', } - expect(toUserOperation(toPackedUserOperation(original))).toStrictEqual({ - ...original, + expect(toUserOperation(toPackedUserOperation(userOp))).toStrictEqual({ + ...userOp, // packed format adds these empty fields for absent optional data paymasterAndData: '0x', }) }) test('round trip: complete operation with all fields', () => { - const original: UserOperation = { + const userOp: UserOperation = { callData: '0xb61d27f60000000000000000000000004e59b44847b379578588920ca78fbf26c0b4956c', callGasLimit: 211314n, @@ -70,13 +70,11 @@ test('round trip: complete operation with all fields', () => { paymasterData: '0xcafebabe', } - expect(toUserOperation(toPackedUserOperation(original))).toStrictEqual( - original, - ) + expect(toUserOperation(toPackedUserOperation(userOp))).toStrictEqual(userOp) }) test('idempotency: preserves already unpacked user operation', () => { - const original: UserOperation = { + const userOp: UserOperation = { callData: '0x', callGasLimit: 200000n, maxFeePerGas: 20000000000n, @@ -88,7 +86,7 @@ test('idempotency: preserves already unpacked user operation', () => { verificationGasLimit: 100000n, } - expect(toUserOperation(original)).toStrictEqual(original) + expect(toUserOperation(userOp)).toStrictEqual(userOp) }) test('edge case: mixed packed and unpacked fields', () => { @@ -252,3 +250,27 @@ test('edge case: packed paymaster unpacking', () => { paymasterData: '0xcafebabe', }) }) + +test('edge case: v0.6 format preserved (no packed fields)', () => { + // Test that initCode and paymasterAndData are preserved when no packed fields present + // This simulates EntryPoint v0.6 format where these fields aren't packed + const userOpV06: UserOperation<'0.6'> = { + sender: '0x1234567890123456789012345678901234567890', + nonce: 0n, + // initCode in v0.6 format - should be preserved as-is + initCode: '0x1234567890123456789012345678901234567890deadbeef', + callData: '0x', + // Individual gas fields (no packed accountGasLimits) + callGasLimit: 200000n, + verificationGasLimit: 100000n, + preVerificationGas: 50000n, + // Individual gas fee fields (no packed gasFees) + maxPriorityFeePerGas: 1000000000n, + maxFeePerGas: 20000000000n, + // paymasterAndData in v0.6 format - should be preserved as-is + paymasterAndData: '0x777777777777aec03fd955926dbf81597e66834ccafebabe', + signature: '0x', + } + + expect(toUserOperation(userOpV06)).toStrictEqual(userOpV06) +}) diff --git a/src/account-abstraction/utils/userOperation/toUserOperation.ts b/src/account-abstraction/utils/userOperation/toUserOperation.ts index 9cbc6a6f1f..2e4b79d9d5 100644 --- a/src/account-abstraction/utils/userOperation/toUserOperation.ts +++ b/src/account-abstraction/utils/userOperation/toUserOperation.ts @@ -45,6 +45,13 @@ export function toUserOperation( userOperation as PackedUserOperation const result = { ...restUserOperation } as unknown as UserOperation + // Check if this appears to be packed format (has packed fields) + // These are the only fields that are known to be packed fields + // initCode and paymasterAndData could be from EntryPoint v0.6 + // See UserOperation type for more details + const hasPackedFields = + 'accountGasLimits' in userOperation || 'gasFees' in userOperation + // Handle gas limits: use existing individual fields or unpack accountGasLimits if ( !('verificationGasLimit' in userOperation) && @@ -75,6 +82,8 @@ export function toUserOperation( if ( !('factory' in userOperation) && !('factoryData' in userOperation) && + // Only unpack initCode if this appears to be packed format (has packed fields) + hasPackedFields && userOperation.initCode && userOperation.initCode !== '0x' ) { @@ -82,8 +91,6 @@ export function toUserOperation( const eip7702Prefix = '0x7702000000000000000000000000000000000000' if (userOperation.initCode === eip7702Prefix) { result.factory = eip7702Prefix - // Remove packed initCode since we've unpacked it - delete result.initCode } else { // Normal case or EIP-7702 with authorization (can't distinguish, so treat as normal) // factory (20 bytes) + factoryData (rest) @@ -92,9 +99,9 @@ export function toUserOperation( if (factoryDataSlice.length > 2) { result.factoryData = factoryDataSlice } - // Remove packed initCode since we've unpacked it - delete result.initCode } + // Remove packed initCode since we've unpacked it + delete result.initCode } // Handle paymaster: use existing individual fields or unpack paymasterAndData @@ -102,33 +109,28 @@ export function toUserOperation( !('paymaster' in userOperation) && !('paymasterVerificationGasLimit' in userOperation) && !('paymasterPostOpGasLimit' in userOperation) && - !('paymasterData' in userOperation) - ) { + !('paymasterData' in userOperation) && // Only unpack paymasterAndData if this appears to be packed format (has packed fields) - const hasPackedFields = - 'accountGasLimits' in userOperation || 'gasFees' in userOperation - if ( - hasPackedFields && - userOperation.paymasterAndData && - userOperation.paymasterAndData !== '0x' && - userOperation.paymasterAndData.length >= 106 - ) { - // 2 + 20*2 + 16*2 + 16*2 = 106 chars minimum for packed format - // paymaster (20 bytes) + paymasterVerificationGasLimit (16 bytes) + paymasterPostOpGasLimit (16 bytes) + paymasterData (rest) - result.paymaster = slice(userOperation.paymasterAndData, 0, 20) - result.paymasterVerificationGasLimit = hexToBigInt( - slice(userOperation.paymasterAndData, 20, 36), - ) - result.paymasterPostOpGasLimit = hexToBigInt( - slice(userOperation.paymasterAndData, 36, 52), - ) - const paymasterDataSlice = slice(userOperation.paymasterAndData, 52) - if (paymasterDataSlice.length > 2) { - result.paymasterData = paymasterDataSlice - } - // Remove packed paymasterAndData since we've unpacked it - delete result.paymasterAndData + hasPackedFields && + userOperation.paymasterAndData && + userOperation.paymasterAndData !== '0x' && + userOperation.paymasterAndData.length >= 106 + ) { + // 2 + 20*2 + 16*2 + 16*2 = 106 chars minimum for packed format + // paymaster (20 bytes) + paymasterVerificationGasLimit (16 bytes) + paymasterPostOpGasLimit (16 bytes) + paymasterData (rest) + result.paymaster = slice(userOperation.paymasterAndData, 0, 20) + result.paymasterVerificationGasLimit = hexToBigInt( + slice(userOperation.paymasterAndData, 20, 36), + ) + result.paymasterPostOpGasLimit = hexToBigInt( + slice(userOperation.paymasterAndData, 36, 52), + ) + const paymasterDataSlice = slice(userOperation.paymasterAndData, 52) + if (paymasterDataSlice.length > 2) { + result.paymasterData = paymasterDataSlice } + // Remove packed paymasterAndData since we've unpacked it + delete result.paymasterAndData } return result From 9bbc555ae84a86fa7d73d47533f495ea480668ec Mon Sep 17 00:00:00 2001 From: Jean Regisser Date: Thu, 17 Jul 2025 11:49:24 +0200 Subject: [PATCH 7/9] Improve docs --- .../utils/userOperation/toUserOperation.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/account-abstraction/utils/userOperation/toUserOperation.ts b/src/account-abstraction/utils/userOperation/toUserOperation.ts index 2e4b79d9d5..208babf4c3 100644 --- a/src/account-abstraction/utils/userOperation/toUserOperation.ts +++ b/src/account-abstraction/utils/userOperation/toUserOperation.ts @@ -12,11 +12,10 @@ import type { * field-by-field, using existing unpacked fields when available and only unpacking * packed fields when the unpacked versions don't exist. * - * @param userOperation - The user operation (with packed or unpacked fields) - * @returns The unpacked UserOperation + * @param userOperation - The user operation (with packed or unpacked fields). {@link PackedUserOperation} | {@link UserOperation} + * @returns The unpacked User Operation. {@link UserOperation} * * @example - * ```ts * import { toUserOperation } from 'viem/account-abstraction' * * // Unpacks packed fields when individual fields don't exist @@ -36,7 +35,6 @@ import type { * } * * const result = toUserOperation(packedUserOp) - * ``` */ export function toUserOperation( userOperation: PackedUserOperation | UserOperation, From 729e643d424cc598f8e8ecde4d2e9750731963c8 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Jul 2025 16:08:34 +0100 Subject: [PATCH 8/9] chore: up ox --- pnpm-lock.yaml | 52 +++- .../userOperation/toUserOperation.test.ts | 276 ------------------ .../utils/userOperation/toUserOperation.ts | 136 +-------- src/package.json | 2 +- 4 files changed, 48 insertions(+), 418 deletions(-) delete mode 100644 src/account-abstraction/utils/userOperation/toUserOperation.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24fbba6fb4..f21ddf90ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -571,8 +571,8 @@ importers: specifier: 1.0.7 version: 1.0.7(ws@8.18.2) ox: - specifier: 0.8.1 - version: 0.8.1(typescript@5.8.3)(zod@3.23.8) + specifier: 0.8.6 + version: 0.8.6(typescript@5.8.3)(zod@3.23.8) ws: specifier: 8.18.2 version: 8.18.2 @@ -5105,6 +5105,14 @@ packages: typescript: optional: true + ox@0.8.6: + resolution: {integrity: sha512-eiKcgiVVEGDtEpEdFi1EGoVVI48j6icXHce9nFwCNM7CKG3uoCXKdr4TPhS00Iy1TR2aWSF1ltPD0x/YgqIL9w==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -6241,8 +6249,8 @@ packages: typescript: optional: true - viem@2.31.7: - resolution: {integrity: sha512-mpB8Hp6xK77E/b/yJmpAIQcxcOfpbrwWNItjnXaIA8lxZYt4JS433Pge2gg6Hp3PwyFtaUMh01j5L8EXnLTjQQ==} + viem@2.33.1: + resolution: {integrity: sha512-++Dkj8HvSOLPMKEs+ZBNNcWbBRlUHcXNWktjIU22hgr6YmbUldV1sPTGLZa6BYRm06WViMjXj6HIsHt8rD+ZKQ==} peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -7779,7 +7787,7 @@ snapshots: pino-pretty: 10.3.1 prom-client: 14.2.0 type-fest: 4.39.0 - viem: 2.33.0(typescript@5.8.3)(zod@3.23.8) + viem: 2.33.1(typescript@5.8.3)(zod@3.23.8) yargs: 17.7.2 zod: 3.23.8 zod-validation-error: 1.5.0(zod@3.23.8) @@ -11707,6 +11715,36 @@ snapshots: transitivePeerDependencies: - zod + ox@0.8.6(typescript@5.6.2)(zod@3.23.8): + dependencies: + '@adraffy/ens-normalize': 1.11.0 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.2 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.0.8(typescript@5.6.2)(zod@3.23.8) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.6.2 + transitivePeerDependencies: + - zod + + ox@0.8.6(typescript@5.8.3)(zod@3.23.8): + dependencies: + '@adraffy/ens-normalize': 1.11.0 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.2 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.0.8(typescript@5.8.3)(zod@3.23.8) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - zod + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -13039,7 +13077,7 @@ snapshots: - utf-8-validate - zod - viem@2.33.0(typescript@5.8.3)(zod@3.23.8): + viem@2.33.1(typescript@5.8.3)(zod@3.23.8): dependencies: '@noble/curves': 1.9.2 '@noble/hashes': 1.8.0 @@ -13064,7 +13102,7 @@ snapshots: '@scure/bip39': 1.6.0 abitype: 1.0.8(typescript@5.6.2)(zod@3.23.8) isows: 1.0.7(ws@8.18.2) - ox: 0.8.1(typescript@5.6.2)(zod@3.23.8) + ox: 0.8.6(typescript@5.6.2)(zod@3.23.8) ws: 8.18.2 optionalDependencies: typescript: 5.6.2 diff --git a/src/account-abstraction/utils/userOperation/toUserOperation.test.ts b/src/account-abstraction/utils/userOperation/toUserOperation.test.ts deleted file mode 100644 index 3d01fca1d5..0000000000 --- a/src/account-abstraction/utils/userOperation/toUserOperation.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { expect, test } from 'vitest' -import type { - PackedUserOperation, - UserOperation, -} from '../../types/userOperation.js' -import { toPackedUserOperation } from './toPackedUserOperation.js' -import { toUserOperation } from './toUserOperation.js' - -test('round trip: basic user operation', () => { - const userOp: UserOperation = { - callData: '0x', - callGasLimit: 200000n, - maxFeePerGas: 20000000000n, - maxPriorityFeePerGas: 1000000000n, - nonce: 0n, - preVerificationGas: 50000n, - sender: '0x1234567890123456789012345678901234567890', - signature: '0x', - verificationGasLimit: 100000n, - } - - expect(toUserOperation(toPackedUserOperation(userOp))).toStrictEqual({ - ...userOp, - // packed format adds these empty fields for absent optional data - initCode: '0x', - paymasterAndData: '0x', - }) -}) - -test('round trip: with factory fields', () => { - const userOp: UserOperation = { - callData: '0x', - callGasLimit: 200000n, - maxFeePerGas: 20000000000n, - maxPriorityFeePerGas: 1000000000n, - nonce: 0n, - preVerificationGas: 50000n, - sender: '0x1234567890123456789012345678901234567890', - signature: '0x', - verificationGasLimit: 100000n, - factory: '0x1234567890123456789012345678901234567890', - factoryData: '0xdeadbeef', - } - - expect(toUserOperation(toPackedUserOperation(userOp))).toStrictEqual({ - ...userOp, - // packed format adds these empty fields for absent optional data - paymasterAndData: '0x', - }) -}) - -test('round trip: complete operation with all fields', () => { - const userOp: UserOperation = { - callData: - '0xb61d27f60000000000000000000000004e59b44847b379578588920ca78fbf26c0b4956c', - callGasLimit: 211314n, - maxFeePerGas: 49900000336n, - maxPriorityFeePerGas: 1650000n, - nonce: 32324340618445458439256401772544n, - preVerificationGas: 66333n, - sender: '0x11e70472DC78AD2e11650B497cAB1f55a647914c', - signature: - '0xbf0c42a5f75c5159bb9c32864e6ff3cccb7bef6a0e019aaca9b54740efb6ad5f4a80b2d4106cc67ac94d7a8bcb345362210c2165b67e48f4646039065d58865e1b', - verificationGasLimit: 61585n, - factory: '0x1234567890123456789012345678901234567890', - factoryData: '0xdeadbeef', - paymaster: '0x777777777777aec03fd955926dbf81597e66834c', - paymasterVerificationGasLimit: 150000n, - paymasterPostOpGasLimit: 100000n, - paymasterData: '0xcafebabe', - } - - expect(toUserOperation(toPackedUserOperation(userOp))).toStrictEqual(userOp) -}) - -test('idempotency: preserves already unpacked user operation', () => { - const userOp: UserOperation = { - callData: '0x', - callGasLimit: 200000n, - maxFeePerGas: 20000000000n, - maxPriorityFeePerGas: 1000000000n, - nonce: 0n, - preVerificationGas: 50000n, - sender: '0x1234567890123456789012345678901234567890', - signature: '0x', - verificationGasLimit: 100000n, - } - - expect(toUserOperation(userOp)).toStrictEqual(userOp) -}) - -test('edge case: mixed packed and unpacked fields', () => { - // Test with some fields already unpacked and some still packed - const mixedUserOperation = { - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - initCode: '0x', - callData: '0x', - // Already unpacked gas limits - these should be preserved - callGasLimit: 300000n, - verificationGasLimit: 150000n, - preVerificationGas: 50000n, - // Still packed gas fees - these should be unpacked - gasFees: '0x0000000000000000000000003b9aca000000000000000000000004a817c800', - paymasterAndData: '0x', - signature: '0x', - } as any // Using 'as any' since this mixed format doesn't match either type - - expect(toUserOperation(mixedUserOperation)).toStrictEqual({ - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - initCode: '0x', - callData: '0x', - callGasLimit: 300000n, - verificationGasLimit: 150000n, - preVerificationGas: 50000n, - maxPriorityFeePerGas: 1000000000n, - maxFeePerGas: 20000000000n, - paymasterAndData: '0x', - signature: '0x', - }) -}) - -test('edge case: zero values in packed format', () => { - const packedUserOperation: PackedUserOperation = { - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - initCode: '0x', - callData: '0x', - // All zeros - accountGasLimits: - '0x00000000000000000000000000000000000000000000000000000000000000000', - preVerificationGas: 0n, - gasFees: - '0x00000000000000000000000000000000000000000000000000000000000000000', - paymasterAndData: '0x', - signature: '0x', - } - - expect(toUserOperation(packedUserOperation)).toStrictEqual({ - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - initCode: '0x', - callData: '0x', - verificationGasLimit: 0n, - callGasLimit: 0n, - preVerificationGas: 0n, - maxPriorityFeePerGas: 0n, - maxFeePerGas: 0n, - paymasterAndData: '0x', - signature: '0x', - }) -}) - -test('edge case: short paymasterAndData not unpacked', () => { - // Test that short paymasterAndData (less than minimum packed format) is preserved as-is - const packedUserOperation: PackedUserOperation = { - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - initCode: '0x', - callData: '0x', - accountGasLimits: - '0x00000000000000000000000030d400000000000000000000000001e8480', - preVerificationGas: 50000n, - gasFees: '0x0000000000000000000000003b9aca000000000000000000000004a817c800', - paymasterAndData: '0x1234', // Too short to be packed format - signature: '0x', - } - - expect(toUserOperation(packedUserOperation)).toStrictEqual({ - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - initCode: '0x', - callData: '0x', - verificationGasLimit: 819200000n, - callGasLimit: 2000000n, - preVerificationGas: 50000n, - maxPriorityFeePerGas: 1000000000n, - maxFeePerGas: 20000000000n, - paymasterAndData: '0x1234', // Should preserve short paymasterAndData as-is - signature: '0x', - }) -}) - -test('edge case: EIP-7702 authorization prefix in initCode', () => { - // Test handling of EIP-7702 authorization prefix - const packedUserOperation: PackedUserOperation = { - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - initCode: '0x7702000000000000000000000000000000000000', - callData: '0x', - accountGasLimits: - '0x00000000000000000000000030d400000000000000000000000001e8480', - preVerificationGas: 50000n, - gasFees: '0x0000000000000000000000003b9aca000000000000000000000004a817c800', - paymasterAndData: '0x', - signature: '0x', - } - - expect(toUserOperation(packedUserOperation)).toStrictEqual({ - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - callData: '0x', - verificationGasLimit: 819200000n, - callGasLimit: 2000000n, - preVerificationGas: 50000n, - maxPriorityFeePerGas: 1000000000n, - maxFeePerGas: 20000000000n, - paymasterAndData: '0x', - signature: '0x', - factory: '0x7702000000000000000000000000000000000000', - }) -}) - -test('edge case: packed paymaster unpacking', () => { - // Test unpacking of properly formatted packed paymasterAndData - const packedUserOperation: PackedUserOperation = { - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - initCode: '0x', - callData: '0x', - accountGasLimits: - '0x00000000000000000000000030d400000000000000000000000001e8480', - preVerificationGas: 50000n, - gasFees: '0x0000000000000000000000003b9aca000000000000000000000004a817c800', - // Packed paymaster: paymaster(20) + verificationGasLimit(16) + postOpGasLimit(16) + data - // paymaster: 777777777777aec03fd955926dbf81597e66834c (20 bytes) - // verificationGasLimit: 000000000000000000000000000249f0 (16 bytes = 150000) - // postOpGasLimit: 000000000000000000000000000186a0 (16 bytes = 100000) - // data: cafebabe (4 bytes) - paymasterAndData: - '0x777777777777aec03fd955926dbf81597e66834c000000000000000000000000000249f0000000000000000000000000000186a0cafebabe', - signature: '0x', - } - - expect(toUserOperation(packedUserOperation)).toStrictEqual({ - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - initCode: '0x', - callData: '0x', - verificationGasLimit: 819200000n, - callGasLimit: 2000000n, - preVerificationGas: 50000n, - maxPriorityFeePerGas: 1000000000n, - maxFeePerGas: 20000000000n, - signature: '0x', - paymaster: '0x777777777777aec03fd955926dbf81597e66834c', - paymasterVerificationGasLimit: 150000n, - paymasterPostOpGasLimit: 100000n, - paymasterData: '0xcafebabe', - }) -}) - -test('edge case: v0.6 format preserved (no packed fields)', () => { - // Test that initCode and paymasterAndData are preserved when no packed fields present - // This simulates EntryPoint v0.6 format where these fields aren't packed - const userOpV06: UserOperation<'0.6'> = { - sender: '0x1234567890123456789012345678901234567890', - nonce: 0n, - // initCode in v0.6 format - should be preserved as-is - initCode: '0x1234567890123456789012345678901234567890deadbeef', - callData: '0x', - // Individual gas fields (no packed accountGasLimits) - callGasLimit: 200000n, - verificationGasLimit: 100000n, - preVerificationGas: 50000n, - // Individual gas fee fields (no packed gasFees) - maxPriorityFeePerGas: 1000000000n, - maxFeePerGas: 20000000000n, - // paymasterAndData in v0.6 format - should be preserved as-is - paymasterAndData: '0x777777777777aec03fd955926dbf81597e66834ccafebabe', - signature: '0x', - } - - expect(toUserOperation(userOpV06)).toStrictEqual(userOpV06) -}) diff --git a/src/account-abstraction/utils/userOperation/toUserOperation.ts b/src/account-abstraction/utils/userOperation/toUserOperation.ts index 208babf4c3..0de18c5a65 100644 --- a/src/account-abstraction/utils/userOperation/toUserOperation.ts +++ b/src/account-abstraction/utils/userOperation/toUserOperation.ts @@ -1,135 +1,3 @@ -import { slice } from '../../../utils/data/slice.js' -import { hexToBigInt } from '../../../utils/encoding/fromHex.js' -import type { - PackedUserOperation, - UserOperation, -} from '../../types/userOperation.js' +import * as UserOperation from 'ox/erc4337/UserOperation' -/** - * Converts a PackedUserOperation to UserOperation format by unpacking packed fields. - * - * This function is the reverse operation of `toPackedUserOperation`. It operates - * field-by-field, using existing unpacked fields when available and only unpacking - * packed fields when the unpacked versions don't exist. - * - * @param userOperation - The user operation (with packed or unpacked fields). {@link PackedUserOperation} | {@link UserOperation} - * @returns The unpacked User Operation. {@link UserOperation} - * - * @example - * import { toUserOperation } from 'viem/account-abstraction' - * - * // Unpacks packed fields when individual fields don't exist - * const packedUserOp = { - * sender: '0x...', - * accountGasLimits: '0x...', // will be unpacked to verificationGasLimit + callGasLimit - * gasFees: '0x...', // will be unpacked to maxPriorityFeePerGas + maxFeePerGas - * // ... - * } - * - * // Uses existing unpacked fields when available - * const mixedUserOp = { - * sender: '0x...', - * callGasLimit: 1000000n, // uses this instead of unpacking accountGasLimits - * gasFees: '0x...', // will unpack maxPriorityFeePerGas + maxFeePerGas since they don't exist - * // ... - * } - * - * const result = toUserOperation(packedUserOp) - */ -export function toUserOperation( - userOperation: PackedUserOperation | UserOperation, -): UserOperation { - const { accountGasLimits, gasFees, ...restUserOperation } = - userOperation as PackedUserOperation - const result = { ...restUserOperation } as unknown as UserOperation - - // Check if this appears to be packed format (has packed fields) - // These are the only fields that are known to be packed fields - // initCode and paymasterAndData could be from EntryPoint v0.6 - // See UserOperation type for more details - const hasPackedFields = - 'accountGasLimits' in userOperation || 'gasFees' in userOperation - - // Handle gas limits: use existing individual fields or unpack accountGasLimits - if ( - !('verificationGasLimit' in userOperation) && - !('callGasLimit' in userOperation) && - 'accountGasLimits' in userOperation - ) { - result.verificationGasLimit = hexToBigInt( - slice(userOperation.accountGasLimits, 0, 16), - ) - result.callGasLimit = hexToBigInt( - slice(userOperation.accountGasLimits, 16, 32), - ) - } - - // Handle gas fees: use existing individual fields or unpack gasFees - if ( - !('maxPriorityFeePerGas' in userOperation) && - !('maxFeePerGas' in userOperation) && - 'gasFees' in userOperation - ) { - result.maxPriorityFeePerGas = hexToBigInt( - slice(userOperation.gasFees, 0, 16), - ) - result.maxFeePerGas = hexToBigInt(slice(userOperation.gasFees, 16, 32)) - } - - // Handle initCode: use existing individual fields or unpack initCode (reverse of getInitCode) - if ( - !('factory' in userOperation) && - !('factoryData' in userOperation) && - // Only unpack initCode if this appears to be packed format (has packed fields) - hasPackedFields && - userOperation.initCode && - userOperation.initCode !== '0x' - ) { - // Check for EIP-7702 authorization (exactly equals the prefix, meaning no authorization) - const eip7702Prefix = '0x7702000000000000000000000000000000000000' - if (userOperation.initCode === eip7702Prefix) { - result.factory = eip7702Prefix - } else { - // Normal case or EIP-7702 with authorization (can't distinguish, so treat as normal) - // factory (20 bytes) + factoryData (rest) - result.factory = slice(userOperation.initCode, 0, 20) - const factoryDataSlice = slice(userOperation.initCode, 20) - if (factoryDataSlice.length > 2) { - result.factoryData = factoryDataSlice - } - } - // Remove packed initCode since we've unpacked it - delete result.initCode - } - - // Handle paymaster: use existing individual fields or unpack paymasterAndData - if ( - !('paymaster' in userOperation) && - !('paymasterVerificationGasLimit' in userOperation) && - !('paymasterPostOpGasLimit' in userOperation) && - !('paymasterData' in userOperation) && - // Only unpack paymasterAndData if this appears to be packed format (has packed fields) - hasPackedFields && - userOperation.paymasterAndData && - userOperation.paymasterAndData !== '0x' && - userOperation.paymasterAndData.length >= 106 - ) { - // 2 + 20*2 + 16*2 + 16*2 = 106 chars minimum for packed format - // paymaster (20 bytes) + paymasterVerificationGasLimit (16 bytes) + paymasterPostOpGasLimit (16 bytes) + paymasterData (rest) - result.paymaster = slice(userOperation.paymasterAndData, 0, 20) - result.paymasterVerificationGasLimit = hexToBigInt( - slice(userOperation.paymasterAndData, 20, 36), - ) - result.paymasterPostOpGasLimit = hexToBigInt( - slice(userOperation.paymasterAndData, 36, 52), - ) - const paymasterDataSlice = slice(userOperation.paymasterAndData, 52) - if (paymasterDataSlice.length > 2) { - result.paymasterData = paymasterDataSlice - } - // Remove packed paymasterAndData since we've unpacked it - delete result.paymasterAndData - } - - return result -} +export const toUserOperation = UserOperation.from diff --git a/src/package.json b/src/package.json index f9eda3da08..ab2cd60554 100644 --- a/src/package.json +++ b/src/package.json @@ -163,7 +163,7 @@ "@scure/bip39": "1.6.0", "abitype": "1.0.8", "isows": "1.0.7", - "ox": "0.8.1", + "ox": "0.8.6", "ws": "8.18.2" }, "license": "MIT", From 95750b5b86846336377f93e0d107b071e262e3f5 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Sun, 27 Jul 2025 16:10:08 +0100 Subject: [PATCH 9/9] Update selfish-goats-appear.md --- .changeset/selfish-goats-appear.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/selfish-goats-appear.md b/.changeset/selfish-goats-appear.md index 973a1551bd..dea1bc8045 100644 --- a/.changeset/selfish-goats-appear.md +++ b/.changeset/selfish-goats-appear.md @@ -1,5 +1,5 @@ --- -"viem": minor +"viem": patch --- **Account Abstraction**: Added `toUserOperation` utility to convert `PackedUserOperation` to `UserOperation`.