diff --git a/CHANGELOG.md b/CHANGELOG.md index 18c401c..2ad0e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [2.1.1] - 2025-01-27 + +### Added Changes + +- **Authorization parameter**: Added optional `authorization` parameter to `estimate()`, `send()`, `estimateBatches()`, and `sendBatches()` in `delegatedEoa` mode. This allows providing a freshly signed EIP-7702 authorization so delegation can occur atomically as part of the UserOperation. Not supported in `modular` mode. + ## [2.1.0] - 2025-01-27 ### Added Changes diff --git a/README.md b/README.md index e3203e8..a9f86bf 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,81 @@ const removeDelegation = async () => { }; ``` +### Using the authorization parameter (delegatedEoa mode) + +You can perform EOA delegation and a transaction atomically by passing a freshly signed authorization to `estimate()`, `send()`, `estimateBatches()`, or `sendBatches()`. + +#### Single Transaction Example + +```typescript +// 1) Get a signed authorization without executing the installation +const { authorization } = await kit.delegateSmartAccountToEoa({ + chainId: 137, + delegateImmediately: false, +}); + +if (!authorization) { + // Already delegated, proceed without the authorization parameter +} + +// 2) Create and name a transaction on the SAME chain as authorization.chainId +kit + .transaction({ + to: '0x000000000000000000000000000000000000dEaD', + value: '100000000000000', + chainId: authorization?.chainId ?? 137, + }) + .name({ transactionName: 'txWithAuth' }); + +// 3) Estimate or send by passing the authorization (delegation will be executed within the UserOp) +const named = kit.name({ transactionName: 'txWithAuth' }); +const estimate = await named.estimate({ authorization }); +const result = await named.send({ authorization }); +``` + +#### Batch Example + +```typescript +// 1) Get a signed authorization without executing the installation +const { authorization } = await kit.delegateSmartAccountToEoa({ + chainId: 137, + delegateImmediately: false, +}); + +if (!authorization) { + // Already delegated, proceed without the authorization parameter +} + +// 2) Create transactions on the SAME chain as authorization.chainId +kit + .transaction({ + to: '0x000000000000000000000000000000000000dEaD', + value: '100000000000000', + chainId: authorization?.chainId ?? 137, + }) + .name({ transactionName: 'batch-tx1' }) + .addToBatch({ batchName: 'auth-batch' }); + +kit + .transaction({ + to: '0x000000000000000000000000000000000000beef', + value: '200000000000000', + chainId: authorization?.chainId ?? 137, + }) + .name({ transactionName: 'batch-tx2' }) + .addToBatch({ batchName: 'auth-batch' }); + +// 3) Estimate or send batches by passing the authorization (delegation will be executed within the UserOp) +const estimate = await kit.estimateBatches({ authorization }); +const result = await kit.sendBatches({ authorization }); +``` + +Notes: +- The `authorization` must match the transaction `chainId` and Kernel v3.3 implementation. +- For multi-chain batches, the `authorization` will only be applied to chain groups matching the authorization's `chainId`. +- `authorization` is only supported in `delegatedEoa` mode; using it in `modular` mode will cause validation errors. +- If the EOA is already delegated, `authorization` is not required. + ### Multi-Chain Batch Operations Enhanced batch operations with chain-based grouping: @@ -471,6 +546,8 @@ const txHash = await kit.getTransactionHash( - `delegateSmartAccountToEoa()` - Delegate EOA to smart account - `undelegateSmartAccountToEoa()` - Remove EOA delegation +When calling `delegateSmartAccountToEoa({ delegateImmediately: false })`, the method returns an `authorization` object that can be passed to `estimate({ authorization })`, `send({ authorization })`, `estimateBatches({ authorization })`, and `sendBatches({ authorization })` for atomic delegation-and-execution flows. + ### Client Management Methods (delegatedEoa mode only) - `getPublicClient()` - Get viem PublicClient for a chain diff --git a/__tests__/EtherspotTransactionKit.test.ts b/__tests__/EtherspotTransactionKit.test.ts index fa39bf6..14625ce 100644 --- a/__tests__/EtherspotTransactionKit.test.ts +++ b/__tests__/EtherspotTransactionKit.test.ts @@ -1,4 +1,8 @@ import { ModularSdk, PaymasterApi } from '@etherspot/modular-sdk'; +import { + KERNEL_V3_3, + KernelVersionToAddressesMap, +} from '@zerodev/sdk/constants'; import { isAddress, parseEther } from 'viem'; // EtherspotPovider @@ -21,7 +25,6 @@ jest.mock('../lib/EtherspotProvider'); jest.mock('../lib/EtherspotUtils'); jest.mock('@etherspot/modular-sdk'); jest.mock('@zerodev/sdk', () => ({ - constants: { KERNEL_V3_3: 'KERNEL_V3_3' }, createKernelAccount: jest.fn().mockResolvedValue({ smartAccount: true }), })); jest.mock('viem', () => { @@ -2418,6 +2421,164 @@ describe('DelegatedEoa Mode Integration', () => { ); expect(result.errorType).toBe('VALIDATION_ERROR'); }); + + describe('authorization parameter', () => { + const realKernelAddress = KernelVersionToAddressesMap[KERNEL_V3_3] + .accountImplementationAddress as `0x${string}`; + const validAuthorization = { + address: realKernelAddress, + chainId: 1, + nonce: 0, + r: ('0x' + '1'.repeat(64)) as `0x${string}`, + s: ('0x' + '2'.repeat(64)) as `0x${string}`, + v: BigInt(27), + yParity: 0, + } as any; + const invalidKernelAuthorization = { + address: + '0xInvalidKernelAddress123456789012345678901234' as `0x${string}`, + chainId: 1, + nonce: 0, + r: ('0x' + '1'.repeat(64)) as `0x${string}`, + s: ('0x' + '2'.repeat(64)) as `0x${string}`, + v: BigInt(27), + yParity: 0, + } as any; + const wrongChainIdAuthorization = { + address: realKernelAddress, + chainId: 137, + nonce: 0, + r: ('0x' + '1'.repeat(64)) as `0x${string}`, + s: ('0x' + '2'.repeat(64)) as `0x${string}`, + v: BigInt(27), + yParity: 0, + } as any; + + it('should estimate with valid authorization when EOA is not designated', async () => { + const mockAccount = { + address: '0xdelegatedeoa123456789012345678901234567890', + encodeCalls: jest.fn().mockReturnValue('0xencodedcalls'), + } as any; + const mockBundlerClient = { + estimateUserOperationGas: jest.fn().mockResolvedValue({ + preVerificationGas: BigInt(100000), + verificationGasLimit: BigInt(100000), + callGasLimit: BigInt(100000), + }), + } as any; + const mockPublicClient = { + getCode: jest.fn().mockResolvedValue('0x'), // Not designated + estimateFeesPerGas: jest.fn().mockResolvedValue({ + maxFeePerGas: BigInt(20000000000), + maxPriorityFeePerGas: BigInt(2000000000), + }), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + + mockProvider.getDelegatedEoaAccount.mockResolvedValue(mockAccount); + mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'delegated-tx' }); + + const result = await transactionKit.estimate({ + authorization: validAuthorization, + }); + + expect(result.isEstimatedSuccessfully).toBe(true); + expect(mockBundlerClient.estimateUserOperationGas).toHaveBeenCalledWith( + expect.objectContaining({ + authorization: validAuthorization, + }) + ); + }); + + it('should reject authorization with wrong chainId', async () => { + const mockAccount = { + address: '0xdelegatedeoa123456789012345678901234567890', + encodeCalls: jest.fn().mockReturnValue('0xencodedcalls'), + } as any; + const mockPublicClient = { + getCode: jest.fn().mockResolvedValue('0x'), + } as any; + + mockProvider.getDelegatedEoaAccount.mockResolvedValue(mockAccount); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'delegated-tx' }); + + const result = await transactionKit.estimate({ + authorization: wrongChainIdAuthorization, + }); + + expect(result.isEstimatedSuccessfully).toBe(false); + expect(result.errorMessage).toContain( + 'Authorization chain ID (137) does not match transaction chain ID (1)' + ); + expect(result.errorType).toBe('VALIDATION_ERROR'); + }); + + it('should reject invalid Kernel authorization', async () => { + const mockAccount = { + address: '0xdelegatedeoa123456789012345678901234567890', + encodeCalls: jest.fn().mockReturnValue('0xencodedcalls'), + } as any; + const mockPublicClient = { + getCode: jest.fn().mockResolvedValue('0x'), + } as any; + + mockProvider.getDelegatedEoaAccount.mockResolvedValue(mockAccount); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'delegated-tx' }); + + const result = await transactionKit.estimate({ + authorization: invalidKernelAuthorization, + }); + + expect(result.isEstimatedSuccessfully).toBe(false); + expect(result.errorMessage).toContain( + 'does not match Kernel v3.3 implementation' + ); + expect(result.errorType).toBe('VALIDATION_ERROR'); + }); + + it('should reject authorization in modular mode', async () => { + mockProvider.getWalletMode.mockReturnValue('modular'); + + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'delegated-tx' }); + + const result = await transactionKit.estimate({ + authorization: validAuthorization, + }); + + expect(result.isEstimatedSuccessfully).toBe(false); + expect(result.errorMessage).toContain( + 'authorization is only supported in delegatedEoa mode' + ); + expect(result.errorType).toBe('VALIDATION_ERROR'); + }); + }); }); describe('send with delegatedEoa mode', () => { @@ -2486,6 +2647,118 @@ describe('DelegatedEoa Mode Integration', () => { ); expect(result.errorType).toBe('VALIDATION_ERROR'); }); + + describe('authorization parameter', () => { + const realKernelAddress = KernelVersionToAddressesMap[KERNEL_V3_3] + .accountImplementationAddress as `0x${string}`; + const validAuthorization = { + address: realKernelAddress, + chainId: 1, + nonce: 0, + r: ('0x' + '1'.repeat(64)) as `0x${string}`, + s: ('0x' + '2'.repeat(64)) as `0x${string}`, + v: BigInt(27), + yParity: 0, + } as any; + const wrongChainIdAuthorization = { + address: realKernelAddress, + chainId: 137, + nonce: 0, + r: ('0x' + '1'.repeat(64)) as `0x${string}`, + s: ('0x' + '2'.repeat(64)) as `0x${string}`, + v: BigInt(27), + yParity: 0, + } as any; + + it('should send with valid authorization when EOA is not designated', async () => { + const mockAccount = { + address: '0xdelegatedeoa123456789012345678901234567890', + encodeCalls: jest.fn().mockReturnValue('0xencodedcalls'), + } as any; + const mockBundlerClient = { + estimateUserOperationGas: jest.fn().mockResolvedValue({ + preVerificationGas: BigInt(100000), + verificationGasLimit: BigInt(100000), + callGasLimit: BigInt(100000), + }), + sendUserOperation: jest.fn().mockResolvedValue('0xuserOpHash'), + getUserOperation: jest.fn().mockResolvedValue({ + userOperation: { + sender: '0xdelegatedeoa123456789012345678901234567890', + nonce: BigInt(5), + callData: '0xencodedcalls', + callGasLimit: BigInt(100000), + verificationGasLimit: BigInt(100000), + preVerificationGas: BigInt(100000), + maxFeePerGas: BigInt(20000000000), + maxPriorityFeePerGas: BigInt(2000000000), + signature: '0xsig', + paymasterAndData: '0x', + }, + }), + } as any; + const mockPublicClient = { + getCode: jest.fn().mockResolvedValue('0x'), // Not designated + estimateFeesPerGas: jest.fn().mockResolvedValue({ + maxFeePerGas: BigInt(20000000000), + maxPriorityFeePerGas: BigInt(2000000000), + }), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + + mockProvider.getDelegatedEoaAccount.mockResolvedValue(mockAccount); + mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'delegated-send-tx' }); + + const result = await transactionKit.send({ + authorization: validAuthorization, + }); + + expect(result.isSentSuccessfully).toBe(true); + expect(mockBundlerClient.sendUserOperation).toHaveBeenCalledWith( + expect.objectContaining({ + authorization: validAuthorization, + }) + ); + }, 10000); // 10 second timeout + + it('should reject authorization with wrong chainId in send', async () => { + const mockAccount = { + address: '0xdelegatedeoa123456789012345678901234567890', + encodeCalls: jest.fn().mockReturnValue('0xencodedcalls'), + } as any; + const mockPublicClient = { + getCode: jest.fn().mockResolvedValue('0x'), + } as any; + + mockProvider.getDelegatedEoaAccount.mockResolvedValue(mockAccount); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'delegated-send-tx' }); + + const result = await transactionKit.send({ + authorization: wrongChainIdAuthorization, + }); + + expect(result.isEstimatedSuccessfully).toBe(false); + expect(result.errorMessage).toContain( + 'Authorization chain ID (137) does not match transaction chain ID (1)' + ); + expect(result.errorType).toBe('VALIDATION_ERROR'); + }); + }); }); describe('getTransactionHash with delegatedEoa mode', () => { @@ -2545,4 +2818,303 @@ describe('DelegatedEoa Mode Integration', () => { expect(result).toBeNull(); }); }); + + describe('estimateBatches with delegatedEoa mode and authorization', () => { + const realKernelAddress = KernelVersionToAddressesMap[KERNEL_V3_3] + .accountImplementationAddress as `0x${string}`; + const validAuthorization = { + address: realKernelAddress, + chainId: 1, + nonce: 0, + r: ('0x' + '1'.repeat(64)) as `0x${string}`, + s: ('0x' + '2'.repeat(64)) as `0x${string}`, + v: BigInt(27), + yParity: 0, + } as any; + + beforeEach(() => { + mockProvider.getWalletMode.mockReturnValue('delegatedEoa'); + transactionKit.reset(); + }); + + it('should estimate batches with valid authorization', async () => { + const mockAccount = { + address: '0xdelegatedeoa123456789012345678901234567890', + encodeCalls: jest.fn().mockReturnValue('0xencodedcalls'), + } as any; + const mockBundlerClient = { + estimateUserOperationGas: jest.fn().mockResolvedValue({ + preVerificationGas: BigInt(100000), + verificationGasLimit: BigInt(100000), + callGasLimit: BigInt(100000), + }), + } as any; + const mockPublicClient = { + getCode: jest + .fn() + .mockResolvedValueOnce('0x') // For isDelegateSmartAccountToEoa check - not designated + .mockResolvedValue('0x'), // For any subsequent calls + estimateFeesPerGas: jest.fn().mockResolvedValue({ + maxFeePerGas: BigInt(20000000000), + maxPriorityFeePerGas: BigInt(2000000000), + }), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + + mockProvider.getDelegatedEoaAccount.mockResolvedValue(mockAccount); + mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'batch-tx1' }); + transactionKit.addToBatch({ batchName: 'test-batch' }); + + const result = await transactionKit.estimateBatches({ + onlyBatchNames: ['test-batch'], + authorization: validAuthorization, + }); + + expect(result.isEstimatedSuccessfully).toBe(true); + if (result.batches['test-batch']?.errorMessage) { + throw new Error( + `Batch estimation failed: ${result.batches['test-batch'].errorMessage}` + ); + } + expect(mockBundlerClient.estimateUserOperationGas).toHaveBeenCalledWith( + expect.objectContaining({ + authorization: validAuthorization, + }) + ); + }); + + it('should reject authorization in modular mode for estimateBatches', async () => { + mockProvider.getWalletMode.mockReturnValue('modular'); + + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'batch-tx1' }); + transactionKit.addToBatch({ batchName: 'test-batch' }); + + await expect( + transactionKit.estimateBatches({ + onlyBatchNames: ['test-batch'], + authorization: validAuthorization, + }) + ).rejects.toThrow('authorization is only supported in delegatedEoa mode'); + }); + + it('should handle multi-chain batches with authorization (only matching chain)', async () => { + const mockAccount = { + address: '0xdelegatedeoa123456789012345678901234567890', + encodeCalls: jest.fn().mockReturnValue('0xencodedcalls'), + } as any; + const mockBundlerClientChain1 = { + estimateUserOperationGas: jest.fn().mockResolvedValue({ + preVerificationGas: BigInt(100000), + verificationGasLimit: BigInt(100000), + callGasLimit: BigInt(100000), + }), + } as any; + const mockBundlerClientChain137 = { + estimateUserOperationGas: jest.fn().mockResolvedValue({ + preVerificationGas: BigInt(100000), + verificationGasLimit: BigInt(100000), + callGasLimit: BigInt(100000), + }), + } as any; + const mockPublicClientChain1 = { + getCode: jest + .fn() + .mockResolvedValueOnce('0x') // For isDelegateSmartAccountToEoa check - not designated + .mockResolvedValue('0x'), // For any subsequent calls + estimateFeesPerGas: jest.fn().mockResolvedValue({ + maxFeePerGas: BigInt(20000000000), + maxPriorityFeePerGas: BigInt(2000000000), + }), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + const mockPublicClientChain137 = { + getCode: jest.fn().mockResolvedValue('0xef01001234'), // Chain 137 - designated + estimateFeesPerGas: jest.fn().mockResolvedValue({ + maxFeePerGas: BigInt(20000000000), + maxPriorityFeePerGas: BigInt(2000000000), + }), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + + mockProvider.getDelegatedEoaAccount.mockImplementation((chainId) => { + return Promise.resolve(mockAccount); + }); + mockProvider.getBundlerClient.mockImplementation((chainId) => { + return Promise.resolve( + chainId === 1 ? mockBundlerClientChain1 : mockBundlerClientChain137 + ); + }); + mockProvider.getPublicClient.mockImplementation((chainId) => { + return Promise.resolve( + chainId === 1 ? mockPublicClientChain1 : mockPublicClientChain137 + ); + }); + + // Add transaction on chain 1 (matches authorization) + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'batch-tx1' }); + transactionKit.addToBatch({ batchName: 'mixed-batch' }); + + // Add transaction on chain 137 (doesn't match authorization) + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 137, + value: '2000000000000000000', + }); + transactionKit.name({ transactionName: 'batch-tx2' }); + transactionKit.addToBatch({ batchName: 'mixed-batch' }); + + const result = await transactionKit.estimateBatches({ + onlyBatchNames: ['mixed-batch'], + authorization: validAuthorization, + }); + + if (result.batches['mixed-batch']?.errorMessage) { + throw new Error( + `Multi-chain batch estimation failed: ${result.batches['mixed-batch'].errorMessage}` + ); + } + expect(result.isEstimatedSuccessfully).toBe(true); + // Authorization should only be passed to chain 1 UserOperation + expect( + mockBundlerClientChain1.estimateUserOperationGas + ).toHaveBeenCalledWith( + expect.objectContaining({ + authorization: validAuthorization, + }) + ); + // Chain 137 should not receive authorization (different chainId) + expect( + mockBundlerClientChain137.estimateUserOperationGas + ).toHaveBeenCalledWith( + expect.not.objectContaining({ + authorization: expect.anything(), + }) + ); + }); + }); + + describe('sendBatches with delegatedEoa mode and authorization', () => { + const realKernelAddress = KernelVersionToAddressesMap[KERNEL_V3_3] + .accountImplementationAddress as `0x${string}`; + const validAuthorization = { + address: realKernelAddress, + chainId: 1, + nonce: 0, + r: ('0x' + '1'.repeat(64)) as `0x${string}`, + s: ('0x' + '2'.repeat(64)) as `0x${string}`, + v: BigInt(27), + yParity: 0, + } as any; + + beforeEach(() => { + mockProvider.getWalletMode.mockReturnValue('delegatedEoa'); + transactionKit.reset(); + }); + + it('should send batches with valid authorization', async () => { + const mockAccount = { + address: '0xdelegatedeoa123456789012345678901234567890', + encodeCalls: jest.fn().mockReturnValue('0xencodedcalls'), + } as any; + const mockBundlerClient = { + estimateUserOperationGas: jest.fn().mockResolvedValue({ + preVerificationGas: BigInt(100000), + verificationGasLimit: BigInt(100000), + callGasLimit: BigInt(100000), + }), + sendUserOperation: jest.fn().mockResolvedValue('0xuserOpHash'), + getUserOperation: jest.fn().mockResolvedValue({ + userOperation: { + sender: '0xdelegatedeoa123456789012345678901234567890', + nonce: BigInt(5), + callData: '0xencodedcalls', + callGasLimit: BigInt(100000), + verificationGasLimit: BigInt(100000), + preVerificationGas: BigInt(100000), + maxFeePerGas: BigInt(20000000000), + maxPriorityFeePerGas: BigInt(2000000000), + signature: '0xsig', + paymasterAndData: '0x', + }, + }), + } as any; + const mockPublicClient = { + getCode: jest + .fn() + .mockResolvedValueOnce('0x') // For isDelegateSmartAccountToEoa check - not designated + .mockResolvedValue('0x'), // For any subsequent calls + estimateFeesPerGas: jest.fn().mockResolvedValue({ + maxFeePerGas: BigInt(20000000000), + maxPriorityFeePerGas: BigInt(2000000000), + }), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + + mockProvider.getDelegatedEoaAccount.mockResolvedValue(mockAccount); + mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'batch-tx1' }); + transactionKit.addToBatch({ batchName: 'test-batch' }); + + const result = await transactionKit.sendBatches({ + onlyBatchNames: ['test-batch'], + authorization: validAuthorization, + }); + + if (result.batches['test-batch']?.errorMessage) { + throw new Error( + `Batch send failed: ${result.batches['test-batch'].errorMessage}` + ); + } + expect(result.isSentSuccessfully).toBe(true); + expect(mockBundlerClient.sendUserOperation).toHaveBeenCalledWith( + expect.objectContaining({ + authorization: validAuthorization, + }) + ); + }); + + it('should reject authorization in modular mode for sendBatches', async () => { + mockProvider.getWalletMode.mockReturnValue('modular'); + + transactionKit.transaction({ + to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6', + chainId: 1, + value: '1000000000000000000', + }); + transactionKit.name({ transactionName: 'batch-tx1' }); + transactionKit.addToBatch({ batchName: 'test-batch' }); + + await expect( + transactionKit.sendBatches({ + onlyBatchNames: ['test-batch'], + authorization: validAuthorization, + }) + ).rejects.toThrow('authorization is only supported in delegatedEoa mode'); + }); + }); }); diff --git a/example/package-lock.json b/example/package-lock.json index ff5a37c..a2f1f14 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -29,14 +29,15 @@ }, "..": { "name": "@etherspot/transaction-kit", - "version": "2.0.2", + "version": "2.1.1", "license": "MIT", "dependencies": { "@etherspot/eip1271-verification-util": "0.1.2", "@etherspot/modular-sdk": "6.1.1", + "@zerodev/sdk": "^5.5.3", "buffer": "6.0.3", "lodash": "4.17.21", - "viem": "2.21.54" + "viem": "2.38.0" }, "devDependencies": { "@babel/core": "7.22.0", diff --git a/example/src/App.tsx b/example/src/App.tsx index 6cb9d59..84fff07 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -624,6 +624,527 @@ const App = () => { }, ]; + // ============================================================================ + // Authorization Tests (DelegatedEoa Mode) + // ============================================================================ + const authorizationScenarios = [ + { + label: '💰 Estimate Single with Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Enable debug mode for detailed logs + kit.setDebugMode(true); + + // Get authorization + const authResult = await kit.delegateSmartAccountToEoa({ + delegateImmediately: false, + }); + if (!authResult.authorization) { + logAndUpdateState( + `⚠️ Authorization already active or failed: ${authResult.eoaAddress}` + ); + return; + } + const auth = authResult.authorization; + logAndUpdateState( + `📝 Authorization signed: address=${auth.address}, chainId=${auth.chainId}, nonce=${auth.nonce}` + ); + + // Create transaction on auth chain IMMEDIATELY after signing authorization + logAndUpdateState( + `📝 Creating transaction on chain ${auth.chainId}...` + ); + kit.transaction({ + chainId: auth.chainId, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + kit.name({ transactionName: 'txWithAuth' }); + logAndUpdateState(`✅ Transaction created and named 'txWithAuth'`); + + // Estimate with authorization IMMEDIATELY after creating transaction + logAndUpdateState(`📊 Calling estimate() with authorization...`); + const named = kit.name({ + transactionName: 'txWithAuth', + }) as INamedTransaction; + const result = await named.estimate({ authorization: auth } as any); + logAndUpdateState( + `✅ Estimate with auth: ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '💰 Estimate Single without Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + kit.setDebugMode(true); + // Create a small tx on chain 10 + kit.transaction({ + chainId: 10, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + kit.name({ transactionName: 'txWithoutAuth' }); + const named = kit.name({ + transactionName: 'txWithoutAuth', + }) as INamedTransaction; + const result = await named.estimate(); + logAndUpdateState( + `✅ Estimate without auth: ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '📤 Send Single with Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Enable debug mode for detailed logs + kit.setDebugMode(true); + + // Get authorization + const authResult = await kit.delegateSmartAccountToEoa({ + delegateImmediately: false, + }); + if (!authResult.authorization) { + logAndUpdateState( + `⚠️ Authorization already active or failed: ${authResult.eoaAddress}` + ); + return; + } + const auth = authResult.authorization; + logAndUpdateState( + `📝 Authorization signed: address=${auth.address}, chainId=${auth.chainId}, nonce=${auth.nonce}` + ); + + // Create transaction on auth chain + logAndUpdateState( + `📝 Creating transaction on chain ${auth.chainId}...` + ); + kit.transaction({ + chainId: auth.chainId, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + kit.name({ transactionName: 'txSendWithAuth' }); + logAndUpdateState( + `✅ Transaction created and named 'txSendWithAuth'` + ); + + // Send with authorization + logAndUpdateState(`📤 Calling send() with authorization...`); + const named = kit.name({ + transactionName: 'txSendWithAuth', + }) as INamedTransaction; + const result = await named.send({ authorization: auth } as any); + logAndUpdateState( + `✅ Send with auth: ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '📤 Send Single without Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + kit.setDebugMode(true); + // Create a small tx on chain 10 + kit.transaction({ + chainId: 10, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + kit.name({ transactionName: 'txSendNoAuth' }); + const named = kit.name({ + transactionName: 'txSendNoAuth', + }) as INamedTransaction; + const result = await named.send(); + logAndUpdateState( + `✅ Send without auth: ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '📊 Estimate Batches (Same Chain) with Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Get authorization + const authResult = await kit.delegateSmartAccountToEoa({ + delegateImmediately: false, + }); + if (!authResult.authorization) { + logAndUpdateState( + `⚠️ Authorization already active or failed: ${authResult.eoaAddress}` + ); + return; + } + const auth = authResult.authorization; + + // Create same-chain batch + kit.transaction({ + chainId: auth.chainId, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + ( + kit.name({ transactionName: 'sameBatch1' }) as INamedTransaction + ).addToBatch({ batchName: 'sameAuthEstimate' }); + + kit.transaction({ + chainId: auth.chainId, + to: '0x000000000000000000000000000000000000beef', + value: parseEther('0.00000000002').toString(), + }); + ( + kit.name({ transactionName: 'sameBatch2' }) as INamedTransaction + ).addToBatch({ batchName: 'sameAuthEstimate' }); + + const result = await kit.estimateBatches({ + onlyBatchNames: ['sameAuthEstimate'], + authorization: auth, + } as any); + logAndUpdateState( + `✅ Estimate same-chain with auth: ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '📊 Estimate Batches (Same Chain) without Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Create same-chain batch + kit.transaction({ + chainId: 10, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + ( + kit.name({ transactionName: 'sameNoAuth1' }) as INamedTransaction + ).addToBatch({ batchName: 'sameNoAuthEstimate' }); + + kit.transaction({ + chainId: 10, + to: '0x000000000000000000000000000000000000beef', + value: parseEther('0.00000000002').toString(), + }); + ( + kit.name({ transactionName: 'sameNoAuth2' }) as INamedTransaction + ).addToBatch({ batchName: 'sameNoAuthEstimate' }); + + const result = await kit.estimateBatches({ + onlyBatchNames: ['sameNoAuthEstimate'], + }); + logAndUpdateState( + `✅ Estimate same-chain without auth: ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '📊 Estimate Batches (Mixed Chains) with Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Get authorization + const authResult = await kit.delegateSmartAccountToEoa({ + delegateImmediately: false, + }); + if (!authResult.authorization) { + logAndUpdateState( + `⚠️ Authorization already active or failed: ${authResult.eoaAddress}` + ); + return; + } + const auth = authResult.authorization; + + // Create mixed-chain batch (one on 10, one on 137) + kit.transaction({ + chainId: auth.chainId, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + ( + kit.name({ transactionName: 'mixedAuth1' }) as INamedTransaction + ).addToBatch({ batchName: 'mixedAuthEstimate' }); + + kit.transaction({ + chainId: auth.chainId === 10 ? 137 : 10, + to: '0x000000000000000000000000000000000000beef', + value: parseEther('0.00000000002').toString(), + }); + ( + kit.name({ transactionName: 'mixedAuth2' }) as INamedTransaction + ).addToBatch({ batchName: 'mixedAuthEstimate' }); + + const result = await kit.estimateBatches({ + onlyBatchNames: ['mixedAuthEstimate'], + authorization: auth, + } as any); + logAndUpdateState( + `✅ Estimate mixed-chains with auth (auth used only on matching chain): ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '📊 Estimate Batches (Mixed Chains) without Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Create mixed-chain batch (10 and 137) + kit.transaction({ + chainId: 10, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + ( + kit.name({ transactionName: 'mixedNoAuth1' }) as INamedTransaction + ).addToBatch({ batchName: 'mixedNoAuthEstimate' }); + + kit.transaction({ + chainId: 137, + to: '0x000000000000000000000000000000000000beef', + value: parseEther('0.00000000002').toString(), + }); + ( + kit.name({ transactionName: 'mixedNoAuth2' }) as INamedTransaction + ).addToBatch({ batchName: 'mixedNoAuthEstimate' }); + + const result = await kit.estimateBatches({ + onlyBatchNames: ['mixedNoAuthEstimate'], + }); + logAndUpdateState( + `✅ Estimate mixed-chains without auth: ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '🚀 Send Batches (Same Chain) with Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Get authorization + const authResult = await kit.delegateSmartAccountToEoa({ + delegateImmediately: false, + }); + if (!authResult.authorization) { + logAndUpdateState( + `⚠️ Authorization already active or failed: ${authResult.eoaAddress}` + ); + return; + } + const auth = authResult.authorization; + + // Create same-chain batch + kit.transaction({ + chainId: auth.chainId, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + ( + kit.name({ transactionName: 'sameSendAuth1' }) as INamedTransaction + ).addToBatch({ batchName: 'sameAuthSend' }); + + kit.transaction({ + chainId: auth.chainId, + to: '0x000000000000000000000000000000000000beef', + value: parseEther('0.00000000002').toString(), + }); + ( + kit.name({ transactionName: 'sameSendAuth2' }) as INamedTransaction + ).addToBatch({ batchName: 'sameAuthSend' }); + + const result = await kit.sendBatches({ + onlyBatchNames: ['sameAuthSend'], + authorization: auth, + } as any); + logAndUpdateState( + `✅ Send same-chain with auth: ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '🚀 Send Batches (Same Chain) without Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Create same-chain batch + kit.transaction({ + chainId: 10, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + ( + kit.name({ + transactionName: 'sameSendNoAuth1', + }) as INamedTransaction + ).addToBatch({ batchName: 'sameNoAuthSend' }); + + kit.transaction({ + chainId: 10, + to: '0x000000000000000000000000000000000000beef', + value: parseEther('0.00000000002').toString(), + }); + ( + kit.name({ + transactionName: 'sameSendNoAuth2', + }) as INamedTransaction + ).addToBatch({ batchName: 'sameNoAuthSend' }); + + const result = await kit.sendBatches({ + onlyBatchNames: ['sameNoAuthSend'], + }); + logAndUpdateState( + `✅ Send same-chain without auth: ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '🚀 Send Batches (Mixed Chains) with Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Get authorization + const authResult = await kit.delegateSmartAccountToEoa({ + delegateImmediately: false, + }); + if (!authResult.authorization) { + logAndUpdateState( + `⚠️ Authorization already active or failed: ${authResult.eoaAddress}` + ); + return; + } + const auth = authResult.authorization; + + // Create mixed-chain batch (10 and 137) + kit.transaction({ + chainId: auth.chainId, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + ( + kit.name({ transactionName: 'mixedSendAuth1' }) as INamedTransaction + ).addToBatch({ batchName: 'mixedAuthSend' }); + + kit.transaction({ + chainId: auth.chainId === 10 ? 137 : 10, + to: '0x000000000000000000000000000000000000beef', + value: parseEther('0.00000000002').toString(), + }); + ( + kit.name({ transactionName: 'mixedSendAuth2' }) as INamedTransaction + ).addToBatch({ batchName: 'mixedAuthSend' }); + + const result = await kit.sendBatches({ + onlyBatchNames: ['mixedAuthSend'], + authorization: auth, + } as any); + logAndUpdateState( + `✅ Send mixed-chains with auth (auth used only on matching chain): ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '🚀 Send Batches (Mixed Chains) without Authorization', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Create mixed-chain batch (10 and 137) + kit.transaction({ + chainId: 10, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.00000000001').toString(), + }); + ( + kit.name({ + transactionName: 'mixedSendNoAuth1', + }) as INamedTransaction + ).addToBatch({ batchName: 'mixedNoAuthSend' }); + + kit.transaction({ + chainId: 137, + to: '0x000000000000000000000000000000000000beef', + value: parseEther('0.00000000002').toString(), + }); + ( + kit.name({ + transactionName: 'mixedSendNoAuth2', + }) as INamedTransaction + ).addToBatch({ batchName: 'mixedNoAuthSend' }); + + const result = await kit.sendBatches({ + onlyBatchNames: ['mixedNoAuthSend'], + }); + logAndUpdateState( + `✅ Send mixed-chains without auth: ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + { + label: '❌ Modular Mode: Estimate with Authorization (Should Fail)', + action: async (logAndUpdateState: (msg: string) => void) => { + try { + // Get authorization + const authResult = await kit.delegateSmartAccountToEoa({ + delegateImmediately: false, + }); + if (!authResult.authorization) { + logAndUpdateState( + `⚠️ Authorization already active or failed: ${authResult.eoaAddress}` + ); + return; + } + const auth = authResult.authorization; + + // Create transaction + kit.transaction({ + chainId: auth.chainId, + to: '0x000000000000000000000000000000000000dead', + value: parseEther('0.001').toString(), + }); + kit.name({ transactionName: 'modularTxWithAuth' }); + + // Try to estimate with authorization in modular mode (should be rejected) + const named = kit.name({ + transactionName: 'modularTxWithAuth', + }) as INamedTransaction; + const result = await named.estimate({ authorization: auth } as any); + logAndUpdateState( + `✅ Modular estimate with auth (validation error expected): ${JSON.stringify(result, bigIntReplacer)}` + ); + } catch (e) { + logAndUpdateState(`❌ Error: ${(e as Error).message}`); + } + }, + }, + ]; + // ============================================================================ // Error Handling & Edge Cases (All Modes) // ============================================================================ @@ -2149,6 +2670,46 @@ const App = () => { + {/* Authorization Tests */} + {walletMode === 'delegatedEoa' && ( +
+

+ 🔐 Authorization Tests (DelegatedEoa Mode) +

+
+ {authorizationScenarios.map((scenario, i) => ( + + ))} +
+
+ Each button signs authorization, creates transaction(s), and + estimates/sends with authorization. Transactions are created on the + authorization's chain. +
+
+ )} + {/* Error Handling & Edge Cases */}
{ @@ -1036,6 +1102,27 @@ export class EtherspotTransactionKit implements IInitial { // Only validate provider in modular mode const walletMode = this.#etherspotProvider.getWalletMode(); if (walletMode === 'modular') { + // Reject authorization in modular mode + if (authorization) { + const result = { + to: this.workingTransaction?.to || '', + chainId: + this.workingTransaction?.chainId ?? + this.#etherspotProvider.getChainId(), + errorMessage: + 'authorization is only supported in delegatedEoa mode. Remove authorization or switch walletMode to delegatedEoa.', + errorType: 'VALIDATION_ERROR' as const, + isEstimatedSuccessfully: false, + }; + log( + 'estimate(): authorization provided in modular mode', + result, + this.debugMode + ); + this.isEstimating = false; + this.containsEstimatingError = true; + return { ...result, ...this }; + } log('estimate(): Getting provider...', undefined, this.debugMode); const provider = this.getProvider(); log('estimate(): Got provider:', provider, this.debugMode); @@ -1156,7 +1243,7 @@ export class EtherspotTransactionKit implements IInitial { this.debugMode ); - // Check if EOA is designated (has EIP-7702 authorization) + // Check if EOA is designated (has EIP-7702 authorization), unless authorization is provided const isDelegateSmartAccountToEoaDelegated = await this.isDelegateSmartAccountToEoa(transactionChainId); log( @@ -1165,21 +1252,69 @@ export class EtherspotTransactionKit implements IInitial { this.debugMode ); - // If EOA is not designated, return error - user must authorize first - if (!isDelegateSmartAccountToEoaDelegated) { + // If EOA is not designated and no authorization provided, return error + if (!isDelegateSmartAccountToEoaDelegated && !authorization) { log( 'estimate(): EOA is not designated. User must authorize EIP-7702 delegation first.', { eoaAddress: delegatedEoaAccount.address }, this.debugMode ); return setErrorAndReturn( - 'EOA is not yet designated as a smart account. The EOA must first authorize EIP-7702 delegation before transactions can be estimated. ' + + 'EOA is not yet designated as a smart account. The EOA must first authorize EIP-7702 delegation before transactions can be estimated, or provide authorization parameter. ' + 'This is a one-time authorization that designates the EOA to use smart account functionality.', 'VALIDATION_ERROR', {} ); } + // Validate authorization matches Kernel v3.3 implementation if provided + if (authorization && transactionChainId) { + // Validate authorization chain ID matches transaction chain ID + const authChainId = authorization?.chainId; + if ( + authChainId !== undefined && + authChainId !== transactionChainId + ) { + return setErrorAndReturn( + `Invalid authorization: Authorization chain ID (${authChainId}) does not match transaction chain ID (${transactionChainId}). ` + + `Please use an authorization signed for chain ${transactionChainId}.`, + 'VALIDATION_ERROR', + {} + ); + } + + const isValidAuthorization = this.validateKernelAuthorization( + authorization, + transactionChainId + ); + if (!isValidAuthorization) { + const expectedKernelAddress = KernelVersionToAddressesMap[ + KERNEL_V3_3 + ].accountImplementationAddress as `0x${string}`; + return setErrorAndReturn( + `Invalid authorization: The provided authorization does not match Kernel v3.3 implementation. ` + + `Only Kernel v3.3 (${expectedKernelAddress}) authorizations are supported. ` + + `Please use delegateSmartAccountToEoa() to get a valid Kernel authorization.`, + 'VALIDATION_ERROR', + {} + ); + } + } + + // Log if using authorization for delegation + if (authorization && !isDelegateSmartAccountToEoaDelegated) { + log( + 'estimate(): Using provided authorization to delegate EOA during transaction estimation', + { + authorizationAddress: authorization?.address, + authorizationChainId: authorization?.chainId, + authorizationNonce: authorization?.nonce, + transactionChainId, + }, + this.debugMode + ); + } + // Prepare the call const call = { to: (this.workingTransaction!.to || '') as `0x${string}`, @@ -1194,6 +1329,9 @@ export class EtherspotTransactionKit implements IInitial { const gasEstimate = await bundlerClient.estimateUserOperationGas({ account: delegatedEoaAccount, calls: [call], + ...(authorization && !isDelegateSmartAccountToEoaDelegated + ? { authorization } + : {}), }); log( @@ -1448,6 +1586,7 @@ export class EtherspotTransactionKit implements IInitial { * @param params - (Optional) Send parameters: * - `paymasterDetails`: Paymaster API details for sponsored transactions. Not supported in delegatedEoa mode (validation error). * - `userOpOverrides`: Optional overrides for user operation fields. Not supported in delegatedEoa mode (validation error). + * - `authorization`: EIP-7702 authorization to delegate EOA during transaction execution (delegatedEoa mode only). If provided, delegation happens as part of the transaction UserOp. Authorization must match the transaction's chainId and Kernel v3.3 implementation. Not supported in modular mode (validation error). * * @returns A promise that resolves to a `TransactionSendResult` combined with `ISentTransaction`, containing: * - Transaction details (to, value, data, chainId) @@ -1479,13 +1618,15 @@ export class EtherspotTransactionKit implements IInitial { * - Call after specifying and naming a transaction. * - For batch sending, use `sendBatches()` instead. * - **delegatedEoa mode:** - * - Requires the EOA to be designated (EIP-7702). If not, returns a validation error instructing to authorize first. + * - Requires the EOA to be designated (EIP-7702). If not designated and no `authorization` provided, returns a validation error instructing to authorize first. + * - If `authorization` parameter is provided, uses it to delegate the EOA during transaction execution. * - Obtains the delegated EOA account and bundler client to submit a UserOp. * - `paymasterDetails` and `userOpOverrides` are not supported. */ async send({ paymasterDetails, userOpOverrides, + authorization, }: SendSingleTransactionParams = {}): Promise< TransactionSendResult & ISentTransaction > { @@ -1515,6 +1656,28 @@ export class EtherspotTransactionKit implements IInitial { // Only validate provider in modular mode const walletMode = this.#etherspotProvider.getWalletMode(); if (walletMode === 'modular') { + // Reject authorization in modular mode + if (authorization) { + const result = { + to: this.workingTransaction?.to || '', + chainId: + this.workingTransaction?.chainId ?? + this.#etherspotProvider.getChainId(), + errorMessage: + 'authorization is only supported in delegatedEoa mode. Remove authorization or switch walletMode to delegatedEoa.', + errorType: 'VALIDATION_ERROR' as const, + isEstimatedSuccessfully: false, + isSentSuccessfully: false, + }; + log( + 'send(): authorization provided in modular mode', + result, + this.debugMode + ); + this.isSending = false; + this.containsSendingError = true; + return { ...result, ...this }; + } log('send(): Getting provider...', undefined, this.debugMode); const provider = this.getProvider(); log('send(): Got provider:', provider, this.debugMode); @@ -1611,7 +1774,7 @@ export class EtherspotTransactionKit implements IInitial { this.debugMode ); - // Check if EOA is designated (has EIP-7702 authorization) + // Check if EOA is designated (has EIP-7702 authorization), unless authorization is provided log( 'send(): Checking EOA designation status...', undefined, @@ -1625,21 +1788,69 @@ export class EtherspotTransactionKit implements IInitial { this.debugMode ); - // If EOA is not designated, return error - user must authorize first - if (!isDelegateSmartAccountToEoaDelegated) { + // If EOA is not designated and no authorization provided, return error + if (!isDelegateSmartAccountToEoaDelegated && !authorization) { log( 'send(): EOA is not designated. User must authorize EIP-7702 delegation first.', { eoaAddress: delegatedEoaAccount.address }, this.debugMode ); return setErrorAndReturn( - 'EOA is not yet designated as a smart account. The EOA must first authorize EIP-7702 delegation before transactions can be sent. ' + + 'EOA is not yet designated as a smart account. The EOA must first authorize EIP-7702 delegation before transactions can be sent, or provide authorization parameter. ' + 'This is a one-time authorization that designates the EOA to use smart account functionality.', 'VALIDATION_ERROR', {} ); } + // Validate authorization matches Kernel v3.3 implementation if provided + if (authorization && transactionChainId) { + // Validate authorization chain ID matches transaction chain ID + const authChainId = authorization?.chainId; + if ( + authChainId !== undefined && + authChainId !== transactionChainId + ) { + return setErrorAndReturn( + `Invalid authorization: Authorization chain ID (${authChainId}) does not match transaction chain ID (${transactionChainId}). ` + + `Please use an authorization signed for chain ${transactionChainId}.`, + 'VALIDATION_ERROR', + {} + ); + } + + const isValidAuthorization = this.validateKernelAuthorization( + authorization, + transactionChainId + ); + if (!isValidAuthorization) { + const expectedKernelAddress = KernelVersionToAddressesMap[ + KERNEL_V3_3 + ].accountImplementationAddress as `0x${string}`; + return setErrorAndReturn( + `Invalid authorization: The provided authorization does not match Kernel v3.3 implementation. ` + + `Only Kernel v3.3 (${expectedKernelAddress}) authorizations are supported. ` + + `Please use delegateSmartAccountToEoa() to get a valid Kernel authorization.`, + 'VALIDATION_ERROR', + {} + ); + } + } + + // Log if using authorization for delegation + if (authorization && !isDelegateSmartAccountToEoaDelegated) { + log( + 'send(): Using provided authorization to delegate EOA during transaction execution', + { + authorizationAddress: authorization?.address, + authorizationChainId: authorization?.chainId, + authorizationNonce: authorization?.nonce, + transactionChainId, + }, + this.debugMode + ); + } + // Prepare the call const call = { to: (this.workingTransaction!.to || '') as `0x${string}`, @@ -1656,6 +1867,9 @@ export class EtherspotTransactionKit implements IInitial { userOpHash = await bundlerClient.sendUserOperation({ account: delegatedEoaAccount, calls: [call], + ...(authorization && !isDelegateSmartAccountToEoaDelegated + ? { authorization } + : {}), }); log('send(): Got userOpHash:', userOpHash, this.debugMode); } catch (sendError) { @@ -2039,6 +2253,7 @@ export class EtherspotTransactionKit implements IInitial { * @param params - (Optional) Estimation parameters: * - `onlyBatchNames`: Array of batch names to estimate. If omitted, all batches are estimated. * - `paymasterDetails`: Paymaster API details for sponsored transactions (modular mode only). + * - `authorization`: EIP-7702 authorization to delegate EOA during batch estimation (delegatedEoa mode only). If provided, delegation happens as part of the batch UserOp. Authorization must match Kernel v3.3 implementation. For multi-chain batches, authorization is only applied to chain groups matching the authorization's chainId. Not supported in modular mode (validation error). * * @returns A promise that resolves to a `BatchEstimateResult` containing: * - A mapping of batch names to their estimation results with chain group breakdown @@ -2057,7 +2272,8 @@ export class EtherspotTransactionKit implements IInitial { * - Supports mixed-chain batches with proper cost aggregation * - **EIP-7702 Validation (DelegatedEoa Mode):** * - Validates EOA designation before estimation using `isDelegateSmartAccountToEoa()` check - * - Requires prior authorization via `delegateSmartAccountToEoa()` method + * - If `authorization` parameter is provided, uses it to delegate the EOA during batch estimation + * - Otherwise, requires prior authorization via `delegateSmartAccountToEoa()` method * - **Cost Aggregation:** * - Tracks costs at both chain group and batch levels * - Calculates total costs using current gas prices from `estimateFeesPerGas()` @@ -2073,6 +2289,7 @@ export class EtherspotTransactionKit implements IInitial { async estimateBatches({ onlyBatchNames, paymasterDetails, + authorization, }: EstimateBatchesParams = {}): Promise { // ======================================================================== // STEP 1: INPUT VALIDATION AND SETUP @@ -2135,6 +2352,14 @@ export class EtherspotTransactionKit implements IInitial { // Modular mode requires a Web3 provider for SDK operations if (walletMode === 'modular') { + // Reject authorization in modular mode + if (authorization) { + this.isEstimating = false; + this.containsEstimatingError = true; + this.throwError( + 'estimateBatches(): authorization is only supported in delegatedEoa mode. Remove authorization or switch walletMode to delegatedEoa.' + ); + } // Get the provider const provider = this.getProvider(); // Validation: if there is no provider, return error @@ -2261,12 +2486,12 @@ export class EtherspotTransactionKit implements IInitial { // EIP-7702 VALIDATION: Check if EOA is designated for smart wallet // ==================================================================== - // Ensure EOA is designated (EIP-7702) on this chain + // Ensure EOA is designated (EIP-7702) on this chain, unless authorization is provided const isDesignated = await this.isDelegateSmartAccountToEoa(groupChainId); - if (!isDesignated) { + if (!isDesignated && !authorization) { const errorMessage = - 'EOA is not designated for EIP-7702. Please authorize first via delegateSmartAccountToEoa().'; + 'EOA is not designated for EIP-7702. Please authorize first via delegateSmartAccountToEoa() or provide authorization parameter.'; groupTxs.forEach((tx) => { const resultObj = { to: tx.to || '', @@ -2290,6 +2515,55 @@ export class EtherspotTransactionKit implements IInitial { continue; } + // Validate authorization matches Kernel v3.3 implementation if provided + // Note: Authorization will only be used for chain groups where chainId matches + // (enforced by the conditional `authorization && !isDesignated` when calling bundler) + if (authorization) { + const isValidAuthorization = this.validateKernelAuthorization( + authorization, + groupChainId + ); + if (!isValidAuthorization) { + const expectedKernelAddress = KernelVersionToAddressesMap[ + KERNEL_V3_3 + ].accountImplementationAddress as `0x${string}`; + const errorMessage = + `Invalid authorization: The provided authorization does not match Kernel v3.3 implementation. ` + + `Only Kernel v3.3 (${expectedKernelAddress}) authorizations are supported. ` + + `Please use delegateSmartAccountToEoa() to get a valid Kernel authorization.`; + groupTxs.forEach((tx) => { + const resultObj = { + to: tx.to || '', + value: tx.value?.toString(), + data: tx.data, + chainId: tx.chainId || groupChainId, + errorMessage, + errorType: 'VALIDATION_ERROR' as const, + isEstimatedSuccessfully: false, + }; + groupEstimated.push(resultObj); + estimatedTransactions.push(resultObj); + }); + + chainGroups[groupChainId] = { + transactions: groupEstimated, + errorMessage, + isEstimatedSuccessfully: false, + }; + batchAllGroupsSuccessful = false; + continue; + } + } + + // Log if using authorization for delegation + if (authorization && !isDesignated) { + log( + `estimateBatches(): Using provided authorization to delegate EOA during batch estimation (chain ${groupChainId})`, + undefined, + this.debugMode + ); + } + // ==================================================================== // GAS ESTIMATION: Get gas limits from bundler // ==================================================================== @@ -2300,9 +2574,18 @@ export class EtherspotTransactionKit implements IInitial { undefined, this.debugMode ); + // Only use authorization if chain IDs match (for multi-chain batch compatibility) + // Require authChainId to be defined and match groupChainId for security + const authChainId = authorization?.chainId; + const shouldUseAuthorization = + authorization && + !isDesignated && + authChainId !== undefined && + authChainId === groupChainId; const gasEstimate = await bundlerClient.estimateUserOperationGas({ account: delegatedEoaAccount, calls, + ...(shouldUseAuthorization ? { authorization } : {}), }); log( `estimateBatches(): Got gas estimate for batch ${batchName} (chain ${groupChainId})`, @@ -2789,7 +3072,9 @@ export class EtherspotTransactionKit implements IInitial { * Provides comprehensive batch sending with support for both modular and delegatedEoa wallet modes. Groups transactions by chainId for efficient multi-chain processing, estimates gas and costs, and sends user operations with proper error handling. * * @param params - (Optional) Send parameters: + * - `onlyBatchNames`: Array of batch names to send (if undefined, sends all batches). * - `paymasterDetails`: Paymaster API details for sponsored transactions (modular mode only). + * - `authorization`: EIP-7702 authorization to delegate EOA during batch execution (delegatedEoa mode only). If provided, delegation happens as part of the batch UserOp. Authorization must match Kernel v3.3 implementation. For multi-chain batches, authorization is only applied to chain groups matching the authorization's chainId. Not supported in modular mode (validation error). * * @returns A promise that resolves to a `BatchSendResult` containing: * - A mapping of batch names to their send results with chain group breakdown @@ -2809,7 +3094,8 @@ export class EtherspotTransactionKit implements IInitial { * - Supports mixed-chain batches with proper cost aggregation * - **EIP-7702 Validation (DelegatedEoa Mode):** * - Validates EOA designation before sending using `isDelegateSmartAccountToEoa()` check - * - Requires prior authorization via `delegateSmartAccountToEoa()` method + * - If `authorization` parameter is provided, uses it to delegate the EOA during batch execution + * - Otherwise, requires prior authorization via `delegateSmartAccountToEoa()` method * - **Gas Estimation & Cost Calculation:** * - Estimates gas using bundler client (delegatedEoa) or SDK (modular) * - Calculates total costs using current gas prices from `estimateFeesPerGas()` @@ -2828,6 +3114,7 @@ export class EtherspotTransactionKit implements IInitial { async sendBatches({ onlyBatchNames, paymasterDetails, + authorization, }: SendBatchesParams = {}): Promise { // ======================================================================== // STEP 1: INPUT VALIDATION AND SETUP @@ -2887,6 +3174,14 @@ export class EtherspotTransactionKit implements IInitial { // Modular mode requires a Web3 provider for SDK operations if (walletMode === 'modular') { + // Reject authorization in modular mode + if (authorization) { + this.isSending = false; + this.containsSendingError = true; + this.throwError( + 'sendBatches(): authorization is only supported in delegatedEoa mode. Remove authorization or switch walletMode to delegatedEoa.' + ); + } // Get the provider const provider = this.getProvider(); // Validation: if there is no provider, return error @@ -3027,12 +3322,12 @@ export class EtherspotTransactionKit implements IInitial { // EIP-7702 VALIDATION: Check if EOA is designated for smart wallet // ==================================================================== - // Ensure EOA is designated (EIP-7702) on this chain + // Ensure EOA is designated (EIP-7702) on this chain, unless authorization is provided const isDesignated = await this.isDelegateSmartAccountToEoa(groupChainId); - if (!isDesignated) { + if (!isDesignated && !authorization) { const errorMessage = - 'EOA is not designated for EIP-7702. Please authorize first via delegateSmartAccountToEoa().'; + 'EOA is not designated for EIP-7702. Please authorize first via delegateSmartAccountToEoa() or provide authorization parameter.'; groupTxs.forEach((tx) => { const resultObj = { to: tx.to || '', @@ -3058,6 +3353,57 @@ export class EtherspotTransactionKit implements IInitial { continue; } + // Validate authorization matches Kernel v3.3 implementation if provided + // Note: Authorization will only be used for chain groups where chainId matches + // (enforced by the conditional `authorization && !isDesignated` when calling bundler) + if (authorization) { + const isValidAuthorization = this.validateKernelAuthorization( + authorization, + groupChainId + ); + if (!isValidAuthorization) { + const expectedKernelAddress = KernelVersionToAddressesMap[ + KERNEL_V3_3 + ].accountImplementationAddress as `0x${string}`; + const errorMessage = + `Invalid authorization: The provided authorization does not match Kernel v3.3 implementation. ` + + `Only Kernel v3.3 (${expectedKernelAddress}) authorizations are supported. ` + + `Please use delegateSmartAccountToEoa() to get a valid Kernel authorization.`; + groupTxs.forEach((tx) => { + const resultObj = { + to: tx.to || '', + value: tx.value?.toString(), + data: tx.data, + chainId: tx.chainId || groupChainId, + errorMessage, + errorType: 'VALIDATION_ERROR' as const, + isEstimatedSuccessfully: false, + isSentSuccessfully: false, + }; + groupSent.push(resultObj); + sentTransactions.push(resultObj); + }); + + chainGroups[groupChainId] = { + transactions: groupSent, + errorMessage, + isEstimatedSuccessfully: false, + isSentSuccessfully: false, + }; + batchAllGroupsSuccessful = false; + continue; + } + } + + // Log if using authorization for delegation + if (authorization && !isDesignated) { + log( + `sendBatches(): Using provided authorization to delegate EOA during batch execution (chain ${groupChainId})`, + undefined, + this.debugMode + ); + } + // ==================================================================== // GAS ESTIMATION: Get gas limits from bundler // ==================================================================== @@ -3068,9 +3414,18 @@ export class EtherspotTransactionKit implements IInitial { undefined, this.debugMode ); + // Only use authorization if chain IDs match (for multi-chain batch compatibility) + // Require authChainId to be defined and match groupChainId for security + const authChainId = authorization?.chainId; + const shouldUseAuthorization = + authorization && + !isDesignated && + authChainId !== undefined && + authChainId === groupChainId; const gasEstimate = await bundlerClient.estimateUserOperationGas({ account: delegatedEoaAccount, calls, + ...(shouldUseAuthorization ? { authorization } : {}), }); log( `sendBatches(): Got gas estimate for batch ${batchName} (chain ${groupChainId})`, @@ -3111,9 +3466,11 @@ export class EtherspotTransactionKit implements IInitial { ); let userOpHash: string; try { + // Only use authorization if chain IDs match (reuse check from estimation above) userOpHash = await bundlerClient.sendUserOperation({ account: delegatedEoaAccount, calls: calls, + ...(shouldUseAuthorization ? { authorization } : {}), }); log( `sendBatches(): Got userOpHash for batch ${batchName} (chain ${groupChainId}):`, diff --git a/lib/interfaces/index.ts b/lib/interfaces/index.ts index 8eb6621..e546ef8 100644 --- a/lib/interfaces/index.ts +++ b/lib/interfaces/index.ts @@ -228,11 +228,13 @@ export interface EstimateSingleTransactionParams { paymasterDetails?: PaymasterApi; gasDetails?: TransactionGasInfoForUserOp; callGasLimit?: bigint; + authorization?: SignAuthorizationReturnType; } export interface SendSingleTransactionParams { paymasterDetails?: PaymasterApi; userOpOverrides?: Partial; + authorization?: SignAuthorizationReturnType; } export interface TransactionEstimateResult { @@ -338,11 +340,13 @@ export interface NameParams { export interface SendBatchesParams { onlyBatchNames?: string[]; paymasterDetails?: PaymasterApi; + authorization?: SignAuthorizationReturnType; } export interface EstimateBatchesParams { onlyBatchNames?: string[]; paymasterDetails?: PaymasterApi; + authorization?: SignAuthorizationReturnType; } type EtherspotPromiseOrValue = T | Promise; diff --git a/package-lock.json b/package-lock.json index 9737b33..02ae9a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@etherspot/transaction-kit", - "version": "2.1.0", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@etherspot/transaction-kit", - "version": "2.1.0", + "version": "2.1.1", "license": "MIT", "dependencies": { "@etherspot/eip1271-verification-util": "0.1.2", diff --git a/package.json b/package.json index 45a2295..07c1aed 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@etherspot/transaction-kit", "description": "Framework-agnostic Etherspot Transaction Kit", - "version": "2.1.0", + "version": "2.1.1", "main": "dist/cjs/index.js", "scripts": { "rollup:build": "NODE_OPTIONS=--max-old-space-size=8192 rollup -c --bundleConfigAsCjs",