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' && ( +