Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
246 changes: 246 additions & 0 deletions __tests__/EtherspotTransactionKit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = {
Expand Down
5 changes: 5 additions & 0 deletions example/.env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading