diff --git a/CHANGELOG.md b/CHANGELOG.md index 67d4635..7c13b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2.1.4] - 2025-12-09 + +### Added Changes + +- **EIP-6492 signMessage Support**: Added `signMessage()` method for creating EIP-6492 compatible signatures for EIP-7702 wallets. This allows signing messages before and after smart account delegation, with signatures that can be validated by EIP-6492 compatible validators. The method wraps standard EIP-191 personal_sign signatures with deployment data in EIP-6492 format: `abi.encode((factoryAddress, factoryCalldata, originalSignature)) || magicBytes` where magicBytes is the 32-byte suffix. + +### Notes + +- `signMessage()` is only available in `delegatedEoa` wallet mode +- Automatically creates EIP-7702 authorization if EOA is not yet delegated +- Signatures are compatible with EIP-6492 validators + ## [2.1.3] - 2025-01-27 ### Fixes diff --git a/__tests__/EtherspotTransactionKit.test.ts b/__tests__/EtherspotTransactionKit.test.ts index 9c8ad2a..7ee1ed6 100644 --- a/__tests__/EtherspotTransactionKit.test.ts +++ b/__tests__/EtherspotTransactionKit.test.ts @@ -33,9 +33,39 @@ jest.mock('viem', () => { ...actual, isAddress: jest.fn(), parseEther: jest.fn(), + toHex: jest.fn((val) => { + if (val === undefined || val === null) return '0x0'; + if (typeof val === 'bigint') return `0x${val.toString(16)}`; + if (typeof val === 'number') return `0x${val.toString(16)}`; + return `0x${val.toString(16)}`; + }), + toRlp: jest.fn( + (val) => `0x${Buffer.from(JSON.stringify(val)).toString('hex')}` + ), + encodeAbiParameters: jest.fn((params, values) => { + // Mock ABI encoding: simple concatenation for testing + // In reality, this would properly ABI encode the tuple + const factoryAddress = values[0]; + const factoryCalldata = values[1]; + const originalSignature = values[2]; + // Simulate ABI encoding by concatenating (this is simplified for tests) + return `0x${factoryAddress.slice(2)}${factoryCalldata.slice(2)}${originalSignature.slice(2)}` as `0x${string}`; + }), + zeroAddress: '0x0000000000000000000000000000000000000000', }; }); +jest.mock('viem/accounts', () => { + const actual = jest.requireActual('viem/accounts'); + const mockSignMessage = jest.fn().mockResolvedValue('0x' + '1'.repeat(130)); + return { + ...actual, + signMessage: mockSignMessage, + }; +}); + +const { signMessage: viemSignMessage } = require('viem/accounts'); + // Move mockConfig and mockSdk to a higher scope for batch tests let mockConfig: any; let mockSdk: any; @@ -2335,6 +2365,222 @@ describe('DelegatedEoa Mode Integration', () => { }); }); + describe('signMessage', () => { + const { toRlp } = require('viem'); + + beforeEach(() => { + jest.clearAllMocks(); + (toRlp as jest.Mock).mockClear(); + }); + + it('should create EIP-6492 signature when EOA is not yet installed', async () => { + const mockOwner = { + address: '0xowner123456789012345678901234567890', + } as any; + const mockBundlerClient = { + signAuthorization: jest.fn().mockResolvedValue({ + address: '0xdelegate123456789012345678901234567890', + data: '0xabcdef1234567890abcdef1234567890abcdef12', + }), + } as any; + const mockPublicClient = { + getCode: jest + .fn() + .mockResolvedValueOnce('0x') // For isDelegateSmartAccountToEoa check + .mockResolvedValue('0x'), // For other calls + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + const mockWalletClient = { + signMessage: jest.fn().mockResolvedValue('0x' + '1'.repeat(130)), // Standard signature + } as any; + + mockProvider.getOwnerAccount.mockResolvedValue(mockOwner); + mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + mockProvider.getWalletClient.mockResolvedValue(mockWalletClient); + (toRlp as jest.Mock).mockReturnValue('0xdeadbeef1234567890abcdef'); + + const result = await transactionKit.signMessage('Hello, World!', 1); + + // EIP-6492 format: encodedWrapper || magicBytes (32-byte suffix) + // Magic bytes should be at the end: 0x6492649264926492649264926492649264926492649264926492649264926492 + expect(result).toMatch( + /6492649264926492649264926492649264926492649264926492649264926492$/ + ); + expect(result.length).toBeGreaterThan(200); // At least encoded wrapper + 32-byte magic suffix + // The wrapper account's signMessage will call walletClient.signMessage with the original owner + expect(mockWalletClient.signMessage).toHaveBeenCalled(); + expect(mockBundlerClient.signAuthorization).toHaveBeenCalled(); + }); + + it('should create EIP-6492 signature when EOA is already installed', async () => { + const mockOwner = { + address: '0xowner123456789012345678901234567890', + } as any; + const mockBundlerClient = { + signAuthorization: jest.fn().mockResolvedValue({ + address: '0xdelegate123456789012345678901234567890', + data: '0xabcdef1234567890abcdef1234567890abcdef12', + }), + } as any; + const mockPublicClient = { + getCode: jest + .fn() + .mockResolvedValueOnce('0xef01001234') // Already installed + .mockResolvedValue('0xef01001234'), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + const mockWalletClient = { + signMessage: jest.fn().mockResolvedValue('0x' + '2'.repeat(130)), // Standard signature + } as any; + + mockProvider.getOwnerAccount.mockResolvedValue(mockOwner); + mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + mockProvider.getWalletClient.mockResolvedValue(mockWalletClient); + (toRlp as jest.Mock).mockReturnValue('0xdeadbeef1234567890abcdef'); + + const result = await transactionKit.signMessage('Test message', 1); + + // EIP-6492 format: encodedWrapper || magicBytes (32-byte suffix at end) + expect(result).toMatch( + /6492649264926492649264926492649264926492649264926492649264926492$/ + ); + expect(mockWalletClient.signMessage).toHaveBeenCalled(); + expect(mockBundlerClient.signAuthorization).toHaveBeenCalled(); + }); + + it('should throw error for non-delegatedEoa wallet mode', async () => { + mockProvider.getWalletMode.mockReturnValue('modular'); + + await expect( + transactionKit.signMessage('Test message', 1) + ).rejects.toThrow( + "signMessage() is only available in 'delegatedEoa' wallet mode" + ); + }); + + it('should handle authorization creation failure', async () => { + const mockOwner = { + address: '0xowner123456789012345678901234567890', + } as any; + const mockBundlerClient = { + signAuthorization: jest + .fn() + .mockRejectedValue(new Error('Authorization failed')), + } as any; + const mockPublicClient = { + getCode: jest.fn().mockResolvedValue('0x'), // Not installed + } as any; + + mockProvider.getOwnerAccount.mockResolvedValue(mockOwner); + mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + (viemSignMessage as jest.Mock).mockResolvedValue('0x' + '1'.repeat(130)); + + // This will fail when trying to delegate + await expect( + transactionKit.signMessage('Test message', 1) + ).rejects.toThrow(); + }); + + it('should handle message signing failure', async () => { + const mockOwner = { + address: '0xowner123456789012345678901234567890', + } as any; + const mockBundlerClient = { + signAuthorization: jest.fn().mockResolvedValue({ + address: '0xdelegate123456789012345678901234567890', + data: '0xabcdef1234567890abcdef1234567890abcdef12', + }), + } as any; + const mockPublicClient = { + getCode: jest.fn().mockResolvedValue('0x'), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + const mockWalletClient = { + signMessage: jest.fn().mockRejectedValue(new Error('Signing failed')), + } as any; + + mockProvider.getOwnerAccount.mockResolvedValue(mockOwner); + mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + mockProvider.getWalletClient.mockResolvedValue(mockWalletClient); + + await expect( + transactionKit.signMessage('Test message', 1) + ).rejects.toThrow('Signing failed'); + }); + + it('should use default chainId when not provided', async () => { + const mockOwner = { + address: '0xowner123456789012345678901234567890', + } as any; + const mockBundlerClient = { + signAuthorization: jest.fn().mockResolvedValue({ + address: '0xdelegate123456789012345678901234567890', + data: '0xabcdef1234567890abcdef1234567890abcdef12', + }), + } as any; + const mockPublicClient = { + getCode: jest.fn().mockResolvedValue('0x'), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + const mockWalletClient = { + signMessage: jest.fn().mockResolvedValue('0x' + '1'.repeat(130)), + } as any; + + mockProvider.getOwnerAccount.mockResolvedValue(mockOwner); + mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + mockProvider.getWalletClient.mockResolvedValue(mockWalletClient); + mockProvider.getChainId.mockReturnValue(1); + (toRlp as jest.Mock).mockReturnValue('0xdeadbeef1234567890abcdef'); + + await transactionKit.signMessage('Test message'); + + expect(mockProvider.getOwnerAccount).toHaveBeenCalledWith(1); + expect(mockProvider.getBundlerClient).toHaveBeenCalledWith(1); + expect(mockProvider.getWalletClient).toHaveBeenCalledWith(1); + }); + + it('should handle hex string messages', async () => { + const mockOwner = { + address: '0xowner123456789012345678901234567890', + } as any; + const mockBundlerClient = { + signAuthorization: jest.fn().mockResolvedValue({ + address: '0xdelegate123456789012345678901234567890', + data: '0xabcdef1234567890abcdef1234567890abcdef12', + }), + } as any; + const mockPublicClient = { + getCode: jest.fn().mockResolvedValue('0x'), + getTransactionCount: jest.fn().mockResolvedValue(5), + } as any; + const mockWalletClient = { + signMessage: jest.fn().mockResolvedValue('0x' + '1'.repeat(130)), + } as any; + + mockProvider.getOwnerAccount.mockResolvedValue(mockOwner); + mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient); + mockProvider.getPublicClient.mockResolvedValue(mockPublicClient); + mockProvider.getWalletClient.mockResolvedValue(mockWalletClient); + (toRlp as jest.Mock).mockReturnValue('0xdeadbeef1234567890abcdef'); + + const result = await transactionKit.signMessage( + '0x48656c6c6f' as `0x${string}`, + 1 + ); + + // EIP-6492 format: encodedWrapper || magicBytes (32-byte suffix at end) + expect(result).toMatch( + /6492649264926492649264926492649264926492649264926492649264926492$/ + ); + expect(mockWalletClient.signMessage).toHaveBeenCalled(); + }); + }); + describe('estimate with delegatedEoa mode', () => { it('should estimate transaction in delegatedEoa mode when EOA is designated', async () => { const mockAccount = { diff --git a/example/.env.example b/example/.env.example new file mode 100644 index 0000000..e011436 --- /dev/null +++ b/example/.env.example @@ -0,0 +1,5 @@ +# Environment variables for signMessage example +REACT_APP_DEMO_WALLET_PK=0x0000000000000000000000000000000000000000000000000000000000000000 +REACT_APP_BUNDLER_URL=https://api.etherspot.io/v2 +REACT_APP_ETHERSPOT_BUNDLER_API_KEY=your-api-key-here +REACT_APP_CHAIN_ID=11155111 diff --git a/example/README.md b/example/README.md index b87cb00..e23bce0 100644 --- a/example/README.md +++ b/example/README.md @@ -36,7 +36,6 @@ See the section about [deployment](https://facebook.github.io/create-react-app/d If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. - You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. ## Learn More diff --git a/example/package-lock.json b/example/package-lock.json index a2f1f14..00db6b6 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -29,7 +29,7 @@ }, "..": { "name": "@etherspot/transaction-kit", - "version": "2.1.1", + "version": "2.1.4", "license": "MIT", "dependencies": { "@etherspot/eip1271-verification-util": "0.1.2", @@ -2145,6 +2145,32 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@csstools/normalize.css": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz", @@ -4717,6 +4743,38 @@ "node": ">=10.13.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -7279,6 +7337,14 @@ "node": ">=10" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-fetch": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", @@ -7963,6 +8029,17 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "license": "BSD-3-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", @@ -8120,15 +8197,6 @@ "tslib": "^2.0.3" } }, - "node_modules/dotenv": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", - "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=10" - } - }, "node_modules/dotenv-expand": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", @@ -12771,6 +12839,14 @@ "semver": "bin/semver.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -15597,6 +15673,15 @@ } } }, + "node_modules/react-scripts/node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, "node_modules/react-shallow-renderer": { "version": "16.15.0", "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", @@ -17827,6 +17912,73 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "license": "Apache-2.0" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -18233,6 +18385,14 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -19176,6 +19336,17 @@ "node": ">=10" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/lib/TransactionKit.ts b/lib/TransactionKit.ts index f9af799..06ea789 100644 --- a/lib/TransactionKit.ts +++ b/lib/TransactionKit.ts @@ -4,8 +4,15 @@ import { KERNEL_V3_3, KernelVersionToAddressesMap, } from '@zerodev/sdk/constants'; -import { isAddress, zeroAddress } from 'viem'; -import { SignAuthorizationReturnType } from 'viem/accounts'; +import type { Address, SignableMessage } from 'viem'; +import { + encodeAbiParameters, + isAddress, + toHex, + toRlp, + zeroAddress, +} from 'viem'; +import { SignAuthorizationReturnType, type LocalAccount } from 'viem/accounts'; // interfaces import { @@ -656,6 +663,206 @@ export class EtherspotTransactionKit implements IInitial { } } + /** + * Creates an EIP-6492 enabled account wrapper that intercepts signMessage calls. + * When signMessage is called on this account, it automatically wraps the signature with EIP-6492 format. + * + * @private + */ + private async createEIP6492Account( + owner: LocalAccount, + signChainId: number + ): Promise> { + const bundlerClient = + await this.#etherspotProvider.getBundlerClient(signChainId); + const walletClient = + await this.#etherspotProvider.getWalletClient(signChainId); + + // Capture 'this' context for use in the wrapper + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + // Create a wrapper account that intercepts signMessage calls + // When walletClient.signMessage() is called with this account, viem will detect + // that the account has a signMessage method and call it directly. + // This works with ANY provider (WalletConnect, MetaMask, private keys, etc.) because: + // 1. Our wrapper's signMessage is called first + // 2. We then call walletClient.signMessage() with the original owner account + // 3. Viem delegates to the provider/transport if the owner account supports it + const eip6492Account: LocalAccount = { + ...owner, + async signMessage({ message }: { message: SignableMessage }) { + // First, get the standard signature from the underlying account + // This will delegate to WalletConnect/MetaMask/provider if the owner account is provider-based + const standardSignature = await walletClient.signMessage({ + account: owner, + message: message as string, + }); + + // Get or create the authorization + const isAlreadyInstalled = + await self.isDelegateSmartAccountToEoa(signChainId); + + let authorization: SignAuthorizationReturnType | undefined; + + // Get authorization for EIP-6492 wrapper + // We always need authorization data for the EIP-6492 signature format, + // regardless of whether the account is already installed or not + if (!isAlreadyInstalled) { + // When not installed, use delegateSmartAccountToEoa to get authorization + // This ensures proper initialization and state management + const delegateResult = await self.delegateSmartAccountToEoa({ + chainId: signChainId, + delegateImmediately: false, + }); + + if (!delegateResult.authorization) { + throw new Error( + 'Failed to create authorization for EIP-6492 signature' + ); + } + + authorization = delegateResult.authorization; + } else { + // When already installed, sign authorization directly + const delegateAddress = KernelVersionToAddressesMap[KERNEL_V3_3] + .accountImplementationAddress as `0x${string}`; + + authorization = await bundlerClient.signAuthorization({ + account: owner, + contractAddress: delegateAddress, + }); + } + + if (!authorization) { + throw new Error( + 'Failed to create authorization for EIP-6492 signature. ' + + 'This may be due to network issues, bundler API problems, or account configuration. ' + + 'Please check your network connection and bundler API key, or try again later.' + ); + } + + // Encode authorization for EIP-7702 + // Normalize authorization fields and provide safe fallbacks + const authChainIdHex = toHex(authorization.chainId); + const authNonceHex = toHex(authorization.nonce); + const authVHex = toHex(authorization.v ?? 0); + const authR = authorization.r; + const authS = authorization.s; + const authAddress = authorization.address; + + // Encode authorization as RLP: [chainId, address, nonce, r, s, v] + const authorizationRlp = toRlp([ + authChainIdHex, + authAddress, + authNonceHex, + authR, + authS, + authVHex, + ]); + + // For EIP-6492, we use the factory variant: + // abi.encode((create2Factory, factoryCalldata, originalERC1271Signature)) + // For EIP-7702, we use zero address as factory (no CREATE2 factory needed) + // and the authorization RLP as the factoryCalldata + const factoryAddress = zeroAddress; // EIP-7702 doesn't use a CREATE2 factory + const factoryCalldata = authorizationRlp; // Authorization data to activate EIP-7702 account + + // ABI encode the tuple: (address factoryAddress, bytes factoryCalldata, bytes originalERC1271Signature) + const encodedWrapper = encodeAbiParameters( + [ + { name: 'factoryAddress', type: 'address' }, + { name: 'factoryCalldata', type: 'bytes' }, + { name: 'originalERC1271Signature', type: 'bytes' }, + ], + [factoryAddress, factoryCalldata, standardSignature] + ); + + // EIP-6492 magic bytes: 32-byte suffix (0x6492 repeated 16 times) + const magicBytes = + '0x6492649264926492649264926492649264926492649264926492649264926492' as `0x${string}`; + + // EIP-6492 format: encodedWrapper || magicBytes + return (encodedWrapper + magicBytes.slice(2)) as `0x${string}`; + }, + }; + + return eip6492Account; + } + + /** + * Signs a message using EIP-6492 format for EIP-7702 wallets. + * This creates a signature that can be validated before the smart account is deployed/activated. + * + * @param message - The message to sign (string or hex string). + * @param chainId - (Optional) The chain ID to use. If not provided, uses the provider's current chain ID. + * @returns A promise that resolves to the EIP-6492 formatted signature as a hex string. + * @throws {Error} If called in 'modular' wallet mode (only available in 'delegatedEoa' mode). + * @throws {Error} If signing fails or authorization cannot be created. + * + * @remarks + * - Only available in 'delegatedEoa' wallet mode. + * - Creates an EIP-6492 compatible signature that wraps the standard signature with deployment data. + * - The signature format follows EIP-6492: `abi.encode((factoryAddress, factoryCalldata, originalSignature)) || magicBytes` + * where magicBytes is the 32-byte suffix `0x6492...` (repeated 16 times) + * - If the EOA is not yet designated, this will create the authorization automatically. + * - The signature can be validated by contracts that support EIP-6492, even before the smart account is activated. + * - Uses WalletClient.signMessage() which delegates to the underlying provider/transport if the account supports it. + * This allows signing with provider-based accounts (e.g., WalletConnect, MetaMask, hardware wallets) when using + * viemLocalAccount that wraps the provider, not just direct private key accounts. + * - The implementation creates an EIP-6492 enabled account wrapper that intercepts signMessage calls, + * allowing walletClient.signMessage() to directly return EIP-6492 formatted signatures. + * - Works with WalletConnect and other providers: The wrapper's signMessage is called first, then it delegates + * to the provider for actual signing, ensuring the signature is wrapped in EIP-6492 format regardless of provider. + */ + async signMessage( + message: string | `0x${string}`, + chainId?: number + ): Promise<`0x${string}`> { + const walletMode = this.#etherspotProvider.getWalletMode(); + const signChainId = chainId || this.#etherspotProvider.getChainId(); + + log('signMessage(): Called', { message, signChainId }, this.debugMode); + + if (walletMode !== 'delegatedEoa') { + this.throwError( + "signMessage() is only available in 'delegatedEoa' wallet mode. " + + `Current mode: '${walletMode}'. ` + + 'This method creates EIP-6492 compatible signatures for EIP-7702 wallets.' + ); + } + + try { + // Get the owner account (EOA) + const owner = await this.#etherspotProvider.getOwnerAccount(signChainId); + + // Create EIP-6492 enabled account wrapper + const eip6492Account = await this.createEIP6492Account( + owner, + signChainId + ); + + // Call the wrapper's signMessage directly so the EIP-6492 logic always runs, + // regardless of wallet client behavior or mocks. + const signature = await eip6492Account.signMessage({ + message: message as SignableMessage, + }); + + log( + 'signMessage(): EIP-6492 signature created', + { + signatureLength: signature.length, + }, + this.debugMode + ); + + return signature; + } catch (error) { + log('signMessage(): Failed', error, this.debugMode); + throw error; + } + } + /** * Specifies or updates the transaction details to be sent. * diff --git a/lib/interfaces/index.ts b/lib/interfaces/index.ts index 091c772..ab980e6 100644 --- a/lib/interfaces/index.ts +++ b/lib/interfaces/index.ts @@ -107,6 +107,10 @@ export interface IInitial { eoaAddress: string; userOpHash?: string; }>; + signMessage( + message: string | `0x${string}`, + chainId?: number + ): Promise<`0x${string}`>; getState(): IInstance; setDebugMode(enabled: boolean): void; getProvider(): WalletProviderLike; diff --git a/package-lock.json b/package-lock.json index b2c935f..013f289 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@etherspot/transaction-kit", - "version": "2.1.3", + "version": "2.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@etherspot/transaction-kit", - "version": "2.1.3", + "version": "2.1.4", "license": "MIT", "dependencies": { "@etherspot/eip1271-verification-util": "0.1.2", diff --git a/package.json b/package.json index 4f5a58c..affc3ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@etherspot/transaction-kit", "description": "Framework-agnostic Etherspot Transaction Kit", - "version": "2.1.3", + "version": "2.1.4", "main": "dist/cjs/index.js", "scripts": { "rollup:build": "NODE_OPTIONS=--max-old-space-size=8192 rollup -c --bundleConfigAsCjs",