Skip to content

Commit d00b679

Browse files
authored
Merge pull request #198 from etherspot/signature-fix
fix: Implement custom 6492 compatible signMessage
2 parents 9274f02 + 0677db9 commit d00b679

9 files changed

Lines changed: 660 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## [2.1.4] - 2025-12-09
4+
5+
### Added Changes
6+
7+
- **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.
8+
9+
### Notes
10+
11+
- `signMessage()` is only available in `delegatedEoa` wallet mode
12+
- Automatically creates EIP-7702 authorization if EOA is not yet delegated
13+
- Signatures are compatible with EIP-6492 validators
14+
315
## [2.1.3] - 2025-01-27
416

517
### Fixes

__tests__/EtherspotTransactionKit.test.ts

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,39 @@ jest.mock('viem', () => {
3333
...actual,
3434
isAddress: jest.fn(),
3535
parseEther: jest.fn(),
36+
toHex: jest.fn((val) => {
37+
if (val === undefined || val === null) return '0x0';
38+
if (typeof val === 'bigint') return `0x${val.toString(16)}`;
39+
if (typeof val === 'number') return `0x${val.toString(16)}`;
40+
return `0x${val.toString(16)}`;
41+
}),
42+
toRlp: jest.fn(
43+
(val) => `0x${Buffer.from(JSON.stringify(val)).toString('hex')}`
44+
),
45+
encodeAbiParameters: jest.fn((params, values) => {
46+
// Mock ABI encoding: simple concatenation for testing
47+
// In reality, this would properly ABI encode the tuple
48+
const factoryAddress = values[0];
49+
const factoryCalldata = values[1];
50+
const originalSignature = values[2];
51+
// Simulate ABI encoding by concatenating (this is simplified for tests)
52+
return `0x${factoryAddress.slice(2)}${factoryCalldata.slice(2)}${originalSignature.slice(2)}` as `0x${string}`;
53+
}),
54+
zeroAddress: '0x0000000000000000000000000000000000000000',
3655
};
3756
});
3857

58+
jest.mock('viem/accounts', () => {
59+
const actual = jest.requireActual('viem/accounts');
60+
const mockSignMessage = jest.fn().mockResolvedValue('0x' + '1'.repeat(130));
61+
return {
62+
...actual,
63+
signMessage: mockSignMessage,
64+
};
65+
});
66+
67+
const { signMessage: viemSignMessage } = require('viem/accounts');
68+
3969
// Move mockConfig and mockSdk to a higher scope for batch tests
4070
let mockConfig: any;
4171
let mockSdk: any;
@@ -2335,6 +2365,222 @@ describe('DelegatedEoa Mode Integration', () => {
23352365
});
23362366
});
23372367

2368+
describe('signMessage', () => {
2369+
const { toRlp } = require('viem');
2370+
2371+
beforeEach(() => {
2372+
jest.clearAllMocks();
2373+
(toRlp as jest.Mock).mockClear();
2374+
});
2375+
2376+
it('should create EIP-6492 signature when EOA is not yet installed', async () => {
2377+
const mockOwner = {
2378+
address: '0xowner123456789012345678901234567890',
2379+
} as any;
2380+
const mockBundlerClient = {
2381+
signAuthorization: jest.fn().mockResolvedValue({
2382+
address: '0xdelegate123456789012345678901234567890',
2383+
data: '0xabcdef1234567890abcdef1234567890abcdef12',
2384+
}),
2385+
} as any;
2386+
const mockPublicClient = {
2387+
getCode: jest
2388+
.fn()
2389+
.mockResolvedValueOnce('0x') // For isDelegateSmartAccountToEoa check
2390+
.mockResolvedValue('0x'), // For other calls
2391+
getTransactionCount: jest.fn().mockResolvedValue(5),
2392+
} as any;
2393+
const mockWalletClient = {
2394+
signMessage: jest.fn().mockResolvedValue('0x' + '1'.repeat(130)), // Standard signature
2395+
} as any;
2396+
2397+
mockProvider.getOwnerAccount.mockResolvedValue(mockOwner);
2398+
mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient);
2399+
mockProvider.getPublicClient.mockResolvedValue(mockPublicClient);
2400+
mockProvider.getWalletClient.mockResolvedValue(mockWalletClient);
2401+
(toRlp as jest.Mock).mockReturnValue('0xdeadbeef1234567890abcdef');
2402+
2403+
const result = await transactionKit.signMessage('Hello, World!', 1);
2404+
2405+
// EIP-6492 format: encodedWrapper || magicBytes (32-byte suffix)
2406+
// Magic bytes should be at the end: 0x6492649264926492649264926492649264926492649264926492649264926492
2407+
expect(result).toMatch(
2408+
/6492649264926492649264926492649264926492649264926492649264926492$/
2409+
);
2410+
expect(result.length).toBeGreaterThan(200); // At least encoded wrapper + 32-byte magic suffix
2411+
// The wrapper account's signMessage will call walletClient.signMessage with the original owner
2412+
expect(mockWalletClient.signMessage).toHaveBeenCalled();
2413+
expect(mockBundlerClient.signAuthorization).toHaveBeenCalled();
2414+
});
2415+
2416+
it('should create EIP-6492 signature when EOA is already installed', async () => {
2417+
const mockOwner = {
2418+
address: '0xowner123456789012345678901234567890',
2419+
} as any;
2420+
const mockBundlerClient = {
2421+
signAuthorization: jest.fn().mockResolvedValue({
2422+
address: '0xdelegate123456789012345678901234567890',
2423+
data: '0xabcdef1234567890abcdef1234567890abcdef12',
2424+
}),
2425+
} as any;
2426+
const mockPublicClient = {
2427+
getCode: jest
2428+
.fn()
2429+
.mockResolvedValueOnce('0xef01001234') // Already installed
2430+
.mockResolvedValue('0xef01001234'),
2431+
getTransactionCount: jest.fn().mockResolvedValue(5),
2432+
} as any;
2433+
const mockWalletClient = {
2434+
signMessage: jest.fn().mockResolvedValue('0x' + '2'.repeat(130)), // Standard signature
2435+
} as any;
2436+
2437+
mockProvider.getOwnerAccount.mockResolvedValue(mockOwner);
2438+
mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient);
2439+
mockProvider.getPublicClient.mockResolvedValue(mockPublicClient);
2440+
mockProvider.getWalletClient.mockResolvedValue(mockWalletClient);
2441+
(toRlp as jest.Mock).mockReturnValue('0xdeadbeef1234567890abcdef');
2442+
2443+
const result = await transactionKit.signMessage('Test message', 1);
2444+
2445+
// EIP-6492 format: encodedWrapper || magicBytes (32-byte suffix at end)
2446+
expect(result).toMatch(
2447+
/6492649264926492649264926492649264926492649264926492649264926492$/
2448+
);
2449+
expect(mockWalletClient.signMessage).toHaveBeenCalled();
2450+
expect(mockBundlerClient.signAuthorization).toHaveBeenCalled();
2451+
});
2452+
2453+
it('should throw error for non-delegatedEoa wallet mode', async () => {
2454+
mockProvider.getWalletMode.mockReturnValue('modular');
2455+
2456+
await expect(
2457+
transactionKit.signMessage('Test message', 1)
2458+
).rejects.toThrow(
2459+
"signMessage() is only available in 'delegatedEoa' wallet mode"
2460+
);
2461+
});
2462+
2463+
it('should handle authorization creation failure', async () => {
2464+
const mockOwner = {
2465+
address: '0xowner123456789012345678901234567890',
2466+
} as any;
2467+
const mockBundlerClient = {
2468+
signAuthorization: jest
2469+
.fn()
2470+
.mockRejectedValue(new Error('Authorization failed')),
2471+
} as any;
2472+
const mockPublicClient = {
2473+
getCode: jest.fn().mockResolvedValue('0x'), // Not installed
2474+
} as any;
2475+
2476+
mockProvider.getOwnerAccount.mockResolvedValue(mockOwner);
2477+
mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient);
2478+
mockProvider.getPublicClient.mockResolvedValue(mockPublicClient);
2479+
(viemSignMessage as jest.Mock).mockResolvedValue('0x' + '1'.repeat(130));
2480+
2481+
// This will fail when trying to delegate
2482+
await expect(
2483+
transactionKit.signMessage('Test message', 1)
2484+
).rejects.toThrow();
2485+
});
2486+
2487+
it('should handle message signing failure', async () => {
2488+
const mockOwner = {
2489+
address: '0xowner123456789012345678901234567890',
2490+
} as any;
2491+
const mockBundlerClient = {
2492+
signAuthorization: jest.fn().mockResolvedValue({
2493+
address: '0xdelegate123456789012345678901234567890',
2494+
data: '0xabcdef1234567890abcdef1234567890abcdef12',
2495+
}),
2496+
} as any;
2497+
const mockPublicClient = {
2498+
getCode: jest.fn().mockResolvedValue('0x'),
2499+
getTransactionCount: jest.fn().mockResolvedValue(5),
2500+
} as any;
2501+
const mockWalletClient = {
2502+
signMessage: jest.fn().mockRejectedValue(new Error('Signing failed')),
2503+
} as any;
2504+
2505+
mockProvider.getOwnerAccount.mockResolvedValue(mockOwner);
2506+
mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient);
2507+
mockProvider.getPublicClient.mockResolvedValue(mockPublicClient);
2508+
mockProvider.getWalletClient.mockResolvedValue(mockWalletClient);
2509+
2510+
await expect(
2511+
transactionKit.signMessage('Test message', 1)
2512+
).rejects.toThrow('Signing failed');
2513+
});
2514+
2515+
it('should use default chainId when not provided', async () => {
2516+
const mockOwner = {
2517+
address: '0xowner123456789012345678901234567890',
2518+
} as any;
2519+
const mockBundlerClient = {
2520+
signAuthorization: jest.fn().mockResolvedValue({
2521+
address: '0xdelegate123456789012345678901234567890',
2522+
data: '0xabcdef1234567890abcdef1234567890abcdef12',
2523+
}),
2524+
} as any;
2525+
const mockPublicClient = {
2526+
getCode: jest.fn().mockResolvedValue('0x'),
2527+
getTransactionCount: jest.fn().mockResolvedValue(5),
2528+
} as any;
2529+
const mockWalletClient = {
2530+
signMessage: jest.fn().mockResolvedValue('0x' + '1'.repeat(130)),
2531+
} as any;
2532+
2533+
mockProvider.getOwnerAccount.mockResolvedValue(mockOwner);
2534+
mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient);
2535+
mockProvider.getPublicClient.mockResolvedValue(mockPublicClient);
2536+
mockProvider.getWalletClient.mockResolvedValue(mockWalletClient);
2537+
mockProvider.getChainId.mockReturnValue(1);
2538+
(toRlp as jest.Mock).mockReturnValue('0xdeadbeef1234567890abcdef');
2539+
2540+
await transactionKit.signMessage('Test message');
2541+
2542+
expect(mockProvider.getOwnerAccount).toHaveBeenCalledWith(1);
2543+
expect(mockProvider.getBundlerClient).toHaveBeenCalledWith(1);
2544+
expect(mockProvider.getWalletClient).toHaveBeenCalledWith(1);
2545+
});
2546+
2547+
it('should handle hex string messages', async () => {
2548+
const mockOwner = {
2549+
address: '0xowner123456789012345678901234567890',
2550+
} as any;
2551+
const mockBundlerClient = {
2552+
signAuthorization: jest.fn().mockResolvedValue({
2553+
address: '0xdelegate123456789012345678901234567890',
2554+
data: '0xabcdef1234567890abcdef1234567890abcdef12',
2555+
}),
2556+
} as any;
2557+
const mockPublicClient = {
2558+
getCode: jest.fn().mockResolvedValue('0x'),
2559+
getTransactionCount: jest.fn().mockResolvedValue(5),
2560+
} as any;
2561+
const mockWalletClient = {
2562+
signMessage: jest.fn().mockResolvedValue('0x' + '1'.repeat(130)),
2563+
} as any;
2564+
2565+
mockProvider.getOwnerAccount.mockResolvedValue(mockOwner);
2566+
mockProvider.getBundlerClient.mockResolvedValue(mockBundlerClient);
2567+
mockProvider.getPublicClient.mockResolvedValue(mockPublicClient);
2568+
mockProvider.getWalletClient.mockResolvedValue(mockWalletClient);
2569+
(toRlp as jest.Mock).mockReturnValue('0xdeadbeef1234567890abcdef');
2570+
2571+
const result = await transactionKit.signMessage(
2572+
'0x48656c6c6f' as `0x${string}`,
2573+
1
2574+
);
2575+
2576+
// EIP-6492 format: encodedWrapper || magicBytes (32-byte suffix at end)
2577+
expect(result).toMatch(
2578+
/6492649264926492649264926492649264926492649264926492649264926492$/
2579+
);
2580+
expect(mockWalletClient.signMessage).toHaveBeenCalled();
2581+
});
2582+
});
2583+
23382584
describe('estimate with delegatedEoa mode', () => {
23392585
it('should estimate transaction in delegatedEoa mode when EOA is designated', async () => {
23402586
const mockAccount = {

example/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Environment variables for signMessage example
2+
REACT_APP_DEMO_WALLET_PK=0x0000000000000000000000000000000000000000000000000000000000000000
3+
REACT_APP_BUNDLER_URL=https://api.etherspot.io/v2
4+
REACT_APP_ETHERSPOT_BUNDLER_API_KEY=your-api-key-here
5+
REACT_APP_CHAIN_ID=11155111

example/README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ See the section about [deployment](https://facebook.github.io/create-react-app/d
3636
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.
3737

3838
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.
39-
4039
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.
4140

4241
## Learn More

0 commit comments

Comments
 (0)