UNPKG

@lukasrosario/account

Version:

Base Account SDK

1,593 lines (1,365 loc) 46.5 kB
import { Mock, MockInstance, Mocked, vi } from 'vitest'; import { Communicator } from ':core/communicator/Communicator.js'; import { CB_KEYS_URL } from ':core/constants.js'; import { standardErrors } from ':core/error/errors.js'; import { EncryptedData, RPCResponseMessage } from ':core/message/RPCMessage.js'; import { AppMetadata, ProviderEventCallback, RequestArguments } from ':core/provider/interface.js'; import { SpendPermission } from ':core/rpc/coinbase_fetchSpendPermissions.js'; import { getClient } from ':store/chain-clients/utils.js'; import { correlationIds } from ':store/correlation-ids/store.js'; import { store } from ':store/store.js'; import { decryptContent, encryptContent, exportKeyToHexString, importKeyFromHexString, } from ':util/cipher.js'; import { fetchRPCRequest } from ':util/provider.js'; import { HttpRequestError, numberToHex } from 'viem'; import { SCWKeyManager } from './SCWKeyManager.js'; import { Signer } from './Signer.js'; import { createSubAccountSigner } from './utils/createSubAccountSigner.js'; import { findOwnerIndex } from './utils/findOwnerIndex.js'; import { handleAddSubAccountOwner } from './utils/handleAddSubAccountOwner.js'; import { handleInsufficientBalanceError } from './utils/handleInsufficientBalance.js'; vi.mock(':store/chain-clients/utils.js', () => ({ getBundlerClient: vi.fn().mockReturnValue({}), getClient: vi.fn().mockReturnValue({ request: vi.fn(), chain: { id: 84532, }, waitForTransaction: vi.fn().mockResolvedValue({ status: 'success', }), }), createClients: vi.fn(), })); vi.mock('./utils/handleInsufficientBalance.js', () => ({ handleInsufficientBalanceError: vi.fn(), })); vi.mock(':util/provider'); vi.mock(':store/chain-clients/utils'); vi.mock('./SCWKeyManager'); vi.mock(':core/communicator/Communicator', () => ({ Communicator: vi.fn(() => ({ postRequestAndWaitForResponse: vi.fn(), waitForPopupLoaded: vi.fn(), })), })); vi.mock(':util/cipher', () => ({ decryptContent: vi.fn(), encryptContent: vi.fn(), exportKeyToHexString: vi.fn(), importKeyFromHexString: vi.fn(), })); vi.mock('./utils/handleAddSubAccountOwner.js', () => ({ handleAddSubAccountOwner: vi.fn(), })); vi.mock('./utils/findOwnerIndex.js', () => ({ findOwnerIndex: vi.fn().mockResolvedValue(1), })); vi.mock('./utils/createSubAccountSigner.js', () => ({ createSubAccountSigner: vi.fn().mockResolvedValue({ request: vi.fn().mockResolvedValue('0xSignature'), }), })); const mockCryptoKey = {} as CryptoKey; const encryptedData = {} as EncryptedData; const mockChains = { '1': 'https://eth-rpc.example.com/1', '2': 'https://eth-rpc.example.com/2', }; const mockCapabilities = {}; const mockError = standardErrors.provider.unauthorized(); const mockCorrelationId = '2-2-3-4-5'; const mockSuccessResponse: RPCResponseMessage = { id: '1-2-3-4-5', correlationId: mockCorrelationId, requestId: '1-2-3-4-5', sender: '0xPublicKey', content: { encrypted: encryptedData }, timestamp: new Date(), }; const subAccountAddress = '0x7838d2724FC686813CAf81d4429beff1110c739a'; const globalAccountAddress = '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54'; describe('Signer', () => { let signer: Signer; let mockMetadata: AppMetadata; let mockCommunicator: Mocked<Communicator>; let mockCallback: ProviderEventCallback; let mockKeyManager: Mocked<SCWKeyManager>; beforeEach(async () => { mockMetadata = { appName: 'test', appLogoUrl: null, appChainIds: [1], }; mockCommunicator = new Communicator({ url: CB_KEYS_URL, metadata: mockMetadata, preference: { walletUrl: CB_KEYS_URL, options: 'all' }, }) as Mocked<Communicator>; mockCommunicator.waitForPopupLoaded.mockResolvedValue({} as Window); mockCommunicator.postRequestAndWaitForResponse.mockResolvedValue(mockSuccessResponse); mockCallback = vi.fn(); mockKeyManager = new SCWKeyManager() as Mocked<SCWKeyManager>; (SCWKeyManager as Mock).mockImplementation(() => mockKeyManager); (importKeyFromHexString as Mock).mockResolvedValue(mockCryptoKey); (exportKeyToHexString as Mock).mockResolvedValueOnce('0xPublicKey'); mockKeyManager.getSharedSecret.mockResolvedValue(mockCryptoKey); (encryptContent as Mock).mockResolvedValueOnce(encryptedData); vi.spyOn(correlationIds, 'get').mockReturnValue(mockCorrelationId); signer = new Signer({ metadata: mockMetadata, communicator: mockCommunicator, callback: mockCallback, }); }); afterEach(async () => { vi.clearAllMocks(); store.account.clear(); store.chains.clear(); store.keys.clear(); store.spendPermissions.clear(); store.subAccounts.clear(); store.subAccountsConfig.clear(); store.setState({}); }); describe('handshake', () => { it('should perform a successful handshake for eth_requestAccounts', async () => { (decryptContent as Mock).mockResolvedValueOnce({ result: { value: ['0xAddress'], }, data: { chains: mockChains, capabilities: mockCapabilities, }, }); const mockSetChains = vi.spyOn(store.chains, 'set'); const mockSetAccount = vi.spyOn(store.account, 'set'); await signer.handshake({ method: 'eth_requestAccounts' }); expect(importKeyFromHexString).toHaveBeenCalledWith('public', '0xPublicKey'); expect(mockKeyManager.setPeerPublicKey).toHaveBeenCalledWith(mockCryptoKey); expect(decryptContent).toHaveBeenCalledWith(encryptedData, mockCryptoKey); expect(mockSetChains).toHaveBeenCalledWith([ { id: 1, rpcUrl: 'https://eth-rpc.example.com/1' }, { id: 2, rpcUrl: 'https://eth-rpc.example.com/2' }, ]); expect(mockSetAccount).toHaveBeenNthCalledWith(1, { chain: { id: 1, rpcUrl: 'https://eth-rpc.example.com/1', }, }); expect(mockSetAccount).toHaveBeenNthCalledWith(2, { capabilities: mockCapabilities, }); // Mock the wallet_connect response that eth_requestAccounts now calls internally (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: '0xAddress', capabilities: {}, }, ], }, }, }); await expect(signer.request({ method: 'eth_requestAccounts' })).resolves.toEqual([ '0xAddress', ]); expect(mockCallback).toHaveBeenCalledWith('chainChanged', '0x1'); expect(mockCallback).toHaveBeenCalledWith('accountsChanged', ['0xAddress']); }); it('should perform a successful handshake for handshake', async () => { (decryptContent as Mock).mockResolvedValueOnce({ result: { value: null, }, }); const mockSetAccount = vi.spyOn(store.account, 'set'); await signer.handshake({ method: 'handshake' }); expect(importKeyFromHexString).toHaveBeenCalledWith('public', '0xPublicKey'); expect(mockCommunicator.postRequestAndWaitForResponse).toHaveBeenCalledWith( expect.objectContaining({ sender: '0xPublicKey', content: { handshake: expect.objectContaining({ method: 'handshake', }), }, }) ); expect(mockKeyManager.setPeerPublicKey).toHaveBeenCalledWith(mockCryptoKey); expect(decryptContent).toHaveBeenCalledWith(encryptedData, mockCryptoKey); expect(mockSetAccount).not.toHaveBeenCalled(); }); it('should throw an error if failure in response.content', async () => { const mockResponse: RPCResponseMessage = { id: '1-2-3-4-5', correlationId: mockCorrelationId, requestId: '1-2-3-4-5', sender: '0xPublicKey', content: { failure: mockError }, timestamp: new Date(), }; mockCommunicator.postRequestAndWaitForResponse.mockResolvedValue(mockResponse); await expect(signer.handshake({ method: 'eth_requestAccounts' })).rejects.toThrowError( mockError ); }); }); describe('request - ephemeral signer', () => { it.each(['wallet_sendCalls', 'wallet_sign'])( 'should perform a successful request after handshake', async (method) => { const mockRequest: RequestArguments = { method }; // Reset and setup mocks for handshake (decryptContent as Mock).mockReset(); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: null, }, }); await signer.handshake({ method: 'handshake' }); expect(signer['accounts']).toEqual([]); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: '0xSignature', }, }); (exportKeyToHexString as Mock).mockResolvedValueOnce('0xPublicKey'); const result = await signer.request(mockRequest); expect(encryptContent).toHaveBeenCalled(); expect(mockCommunicator.postRequestAndWaitForResponse).toHaveBeenNthCalledWith( 2, expect.objectContaining({ sender: '0xPublicKey', content: { encrypted: encryptedData }, }) ); expect(result).toEqual('0xSignature'); } ); }); describe('request', () => { let stateSpy: MockInstance; beforeAll(() => { signer['accounts'] = ['0xAddress']; signer['chain'] = { id: 1, rpcUrl: 'https://eth-rpc.example.com/1' }; stateSpy = vi.spyOn(store, 'getState').mockImplementation(() => ({ account: { accounts: ['0xAddress'], chain: { id: 1, rpcUrl: 'https://eth-rpc.example.com/1' }, }, chains: [], keys: {}, spendPermissions: [], config: { metadata: mockMetadata, preference: { walletUrl: CB_KEYS_URL, options: 'all' }, version: '1.0.0', }, subAccountConfig: undefined, })); }); afterAll(() => { // For some reason vi.restoreAllMocks() doesn't work for this spy stateSpy.mockRestore(); }); it('should perform a successful request', async () => { const mockRequest: RequestArguments = { method: 'personal_sign', params: ['0xMessage', '0xAddress'], }; (decryptContent as Mock).mockResolvedValueOnce({ result: { value: '0xSignature', }, }); const result = await signer.request(mockRequest); expect(encryptContent).toHaveBeenCalled(); expect(mockCommunicator.postRequestAndWaitForResponse).toHaveBeenCalledWith( expect.objectContaining({ sender: '0xPublicKey', content: { encrypted: encryptedData }, }) ); expect(result).toEqual('0xSignature'); }); it.each([ 'eth_ecRecover', 'personal_sign', 'wallet_sign', 'personal_ecRecover', 'eth_signTransaction', 'eth_sendTransaction', 'eth_signTypedData_v1', 'eth_signTypedData_v3', 'eth_signTypedData_v4', 'eth_signTypedData', 'wallet_addEthereumChain', 'wallet_watchAsset', 'wallet_sendCalls', 'wallet_showCallsStatus', 'wallet_grantPermissions', ])('should send request to popup for %s', async (method) => { const mockRequest: RequestArguments = { method, params: [], }; (decryptContent as Mock).mockResolvedValueOnce({ result: { value: '0xSignature', }, }); await signer.request(mockRequest); expect(mockCommunicator.postRequestAndWaitForResponse).toHaveBeenCalledWith( expect.objectContaining({ sender: '0xPublicKey', content: { encrypted: encryptedData }, }) ); }); it.each([ 'wallet_prepareCalls', 'wallet_sendPreparedCalls', 'eth_getBalance', 'eth_getTransactionCount', ])('should fetch rpc request for %s', async (method) => { const mockRequest: RequestArguments = { method, params: [], }; await signer.request(mockRequest); expect(fetchRPCRequest).toHaveBeenCalledWith(mockRequest, 'https://eth-rpc.example.com/1'); }); it('should throw an error if error in decrypted response', async () => { const mockRequest: RequestArguments = { method: 'personal_sign', params: ['0xMessage', '0xAddress'], }; (decryptContent as Mock).mockResolvedValueOnce({ result: { error: mockError, }, }); await expect(signer.request(mockRequest)).rejects.toThrowError(mockError); }); it('should update internal state for successful wallet_switchEthereumChain', async () => { const mockRequest: RequestArguments = { method: 'wallet_switchEthereumChain', params: [{ chainId: '0x1' }], }; (decryptContent as Mock).mockResolvedValueOnce({ result: { value: null, }, data: { chains: mockChains, capabilities: mockCapabilities, }, }); const mockSetChains = vi.spyOn(store.chains, 'set'); const mockSetAccount = vi.spyOn(store.account, 'set'); await signer.request(mockRequest); expect(mockSetChains).toHaveBeenCalledWith([ { id: 1, rpcUrl: 'https://eth-rpc.example.com/1' }, { id: 2, rpcUrl: 'https://eth-rpc.example.com/2' }, ]); expect(mockSetAccount).toHaveBeenNthCalledWith(1, { chain: { id: 1, rpcUrl: 'https://eth-rpc.example.com/1' }, }); expect(mockSetAccount).toHaveBeenNthCalledWith(2, { capabilities: mockCapabilities, }); expect(mockCallback).toHaveBeenCalledWith('chainChanged', '0x1'); }); }); describe('disconnect', () => { it('should disconnect successfully', async () => { const mockClear = vi.spyOn(store.account, 'clear'); await signer.cleanup(); expect(mockClear).toHaveBeenCalled(); expect(mockKeyManager.clear).toHaveBeenCalled(); expect(signer['accounts']).toEqual([]); expect(signer['chain']).toEqual({ id: 1 }); }); }); describe('eth_accounts', () => { afterEach(() => { vi.restoreAllMocks(); }); it('should return accounts in correct order based on enableAutoSubAccounts', async () => { // Set up the signer with a global account signer['accounts'] = [globalAccountAddress]; signer['chain'] = { id: 1, rpcUrl: 'https://eth-rpc.example.com/1' }; // Set a sub account in the store const subAccountsSpy = vi.spyOn(store.subAccounts, 'get').mockReturnValue({ address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }); // Test with enableAutoSubAccounts = false const configSpy = vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ enableAutoSubAccounts: false, }); let accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([globalAccountAddress, subAccountAddress]); // Test with enableAutoSubAccounts = true configSpy.mockReturnValue({ enableAutoSubAccounts: true, }); accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([subAccountAddress, globalAccountAddress]); // Test when enableAutoSubAccounts is undefined (should default to false behavior) configSpy.mockReturnValue(undefined); accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([globalAccountAddress, subAccountAddress]); subAccountsSpy.mockRestore(); configSpy.mockRestore(); }); it('should return only global account when no sub account exists', async () => { // Set up the signer with only a global account signer['accounts'] = [globalAccountAddress]; signer['chain'] = { id: 1, rpcUrl: 'https://eth-rpc.example.com/1' }; // No sub account in the store const subAccountsSpy = vi.spyOn(store.subAccounts, 'get').mockReturnValue(undefined); const accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([globalAccountAddress]); subAccountsSpy.mockRestore(); }); }); describe('wallet_connect', () => { beforeEach(async () => { await signer.cleanup(); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: null, }, }); await signer.handshake({ method: 'handshake' }); }); afterEach(() => { vi.restoreAllMocks(); }); it('should handle wallet_connect with no capabilities', async () => { expect(signer['accounts']).toEqual([]); const mockRequest: RequestArguments = { method: 'wallet_connect', params: [], }; const mockSetAccount = vi.spyOn(store.account, 'set'); const mockSetSubAccounts = vi.spyOn(store.subAccounts, 'set'); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: globalAccountAddress, capabilities: { subAccounts: [ { address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }, ], }, }, ], }, }, }); await signer.request(mockRequest); // Should only persist global account to accounts store expect(mockSetAccount).toHaveBeenCalledWith({ accounts: [globalAccountAddress], }); // Should persist sub account to subAccounts store expect(mockSetSubAccounts).toHaveBeenCalledWith({ address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }); // eth_accounts should return both accounts with global account first const accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([globalAccountAddress, subAccountAddress]); }); it('should handle wallet_connect with addSubAccount capability', async () => { expect(signer['accounts']).toEqual([]); const mockRequest: RequestArguments = { method: 'wallet_connect', params: [ { capabilities: { addSubAccount: { account: { type: 'create', keys: [{ type: 'p256', publicKey: '0x123' }], }, }, }, }, ], }; const mockSetAccount = vi.spyOn(store.account, 'set'); const mockSetSubAccounts = vi.spyOn(store.subAccounts, 'set'); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: globalAccountAddress, capabilities: { subAccounts: [ { address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }, ], }, }, ], }, }, }); await signer.request(mockRequest); // Should persist global account to accounts store expect(mockSetAccount).toHaveBeenCalledWith({ accounts: [globalAccountAddress], }); // Should persist sub account to subAccounts store expect(mockSetSubAccounts).toHaveBeenCalledWith({ address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }); // eth_accounts should return [globalAccount, subAccount] when enableAutoSubAccounts is not true const accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([globalAccountAddress, subAccountAddress]); }); it('should handle wallet_addSubAccount creating new sub account', async () => { expect(signer['accounts']).toEqual([]); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: globalAccountAddress, capabilities: {}, }, ], }, }, }); // First connect without sub account await signer.request({ method: 'wallet_connect', params: [], }); const mockSetSubAccounts = vi.spyOn(store.subAccounts, 'set'); // Then add sub account (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }, }, }); await signer.request({ method: 'wallet_addSubAccount', params: [ { version: '1', account: { type: 'create', keys: [ { publicKey: '0x123', type: 'p256', }, ], }, }, ], }); // Should persist sub account to subAccounts store expect(mockSetSubAccounts).toHaveBeenCalledWith({ address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }); // eth_accounts should return [globalAccount, subAccount] when enableAutoSubAccounts is not true const accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([globalAccountAddress, subAccountAddress]); }); it('should use cached response for subsequent wallet_connect calls', async () => { // First wallet_connect call const mockRequest: RequestArguments = { method: 'wallet_connect', params: [], }; const mockSpendPermissions = [ { permissionHash: '0xPermissionHash', signature: '0xSignature', chainId: 1, permission: { account: globalAccountAddress, spender: subAccountAddress, }, }, ]; (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: globalAccountAddress, capabilities: { subAccounts: [ { address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }, ], spendPermissions: { permissions: mockSpendPermissions, }, }, }, ], }, }, }); // First wallet_connect call await signer.request(mockRequest); // Reset decryptContent mock to verify it's not called again (decryptContent as Mock).mockReset(); // Second wallet_connect call const cachedResponse = await signer.request(mockRequest); // Verify decryptContent was not called for the second request expect(decryptContent).not.toHaveBeenCalled(); // Verify cached response matches expected format expect(cachedResponse).toEqual({ accounts: [ { address: globalAccountAddress, capabilities: { subAccounts: [ { address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }, ], spendPermissions: { permissions: mockSpendPermissions, }, }, }, ], }); }); it('should always return sub account first when enableAutoSubAccounts is true', async () => { expect(signer['accounts']).toEqual([]); // Enable auto sub accounts vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ enableAutoSubAccounts: true, }); const mockRequest: RequestArguments = { method: 'wallet_connect', params: [], }; const mockSetAccount = vi.spyOn(store.account, 'set'); const mockSetSubAccounts = vi.spyOn(store.subAccounts, 'set'); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: globalAccountAddress, capabilities: { subAccounts: [ { address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }, ], }, }, ], }, }, }); await signer.request(mockRequest); // Should persist accounts correctly expect(mockSetAccount).toHaveBeenCalledWith({ accounts: [globalAccountAddress], }); expect(mockSetSubAccounts).toHaveBeenCalledWith({ address: subAccountAddress, factory: globalAccountAddress, factoryData: '0x', }); // When enableAutoSubAccounts is true, sub account should be first const accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([subAccountAddress, globalAccountAddress]); // Test with eth_requestAccounts as well const requestedAccounts = await signer.request({ method: 'eth_requestAccounts' }); expect(requestedAccounts).toEqual([subAccountAddress, globalAccountAddress]); }); }); describe('wallet_addSubAccount', () => { afterEach(() => { vi.restoreAllMocks(); }); it('should update internal state for successful wallet_addSubAccount', async () => { await signer.cleanup(); const mockRequest: RequestArguments = { method: 'wallet_connect', params: [], }; (decryptContent as Mock).mockResolvedValueOnce({ result: { value: null, }, }); const mockSetAccount = vi.spyOn(store.account, 'set'); await signer.handshake({ method: 'handshake' }); expect(signer['accounts']).toEqual([]); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: globalAccountAddress, capabilities: {}, }, ], }, }, }); await signer.request(mockRequest); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { address: subAccountAddress, factory: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', factoryData: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', }, }, }); await signer.request({ method: 'wallet_addSubAccount', params: [ { version: '1', account: { type: 'create', keys: [ { publicKey: '0x123', type: 'p256', }, ], }, }, ], }); const accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([globalAccountAddress, subAccountAddress]); expect(mockSetAccount).toHaveBeenCalledWith({ accounts: [globalAccountAddress], }); }); it('should fall back to local account if no keys are provided', async () => { await signer.cleanup(); const mockRequest: RequestArguments = { method: 'wallet_connect', params: [], }; (decryptContent as Mock).mockResolvedValueOnce({ result: { value: null, }, }); await signer.handshake({ method: 'handshake' }); expect(signer['accounts']).toEqual([]); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: globalAccountAddress, capabilities: {}, }, ], }, }, }); await signer.request(mockRequest); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { address: subAccountAddress, factory: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', factoryData: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', }, }, }); // Clear previous mock calls to isolate the wallet_addSubAccount call mockCommunicator.postRequestAndWaitForResponse.mockClear(); (encryptContent as Mock).mockClear(); await signer.request({ method: 'wallet_addSubAccount', params: [ { version: '1', account: { type: 'create', }, }, ], }); // Verify that encryptContent was called with a request containing populated keys expect(encryptContent).toHaveBeenCalledWith( expect.objectContaining({ action: expect.objectContaining({ method: 'wallet_addSubAccount', params: [ { version: '1', account: { type: 'create', keys: expect.arrayContaining([ expect.objectContaining({ type: expect.any(String), publicKey: expect.any(String), }), ]), }, }, ], }), chainId: expect.any(Number), }), mockCryptoKey ); const accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([globalAccountAddress, subAccountAddress]); }); it('should always return sub account first when enableAutoSubAccounts is true', async () => { await signer.cleanup(); // Enable auto sub accounts vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ enableAutoSubAccounts: true, }); const mockRequest: RequestArguments = { method: 'wallet_connect', params: [], }; (decryptContent as Mock).mockResolvedValueOnce({ result: { value: null, }, }); await signer.handshake({ method: 'handshake' }); expect(signer['accounts']).toEqual([]); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: globalAccountAddress, capabilities: {}, }, ], }, }, }); await signer.request(mockRequest); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { address: subAccountAddress, factory: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', factoryData: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', }, }, }); await signer.request({ method: 'wallet_addSubAccount', params: [ { version: '1', account: { type: 'create', keys: [ { publicKey: '0x123', type: 'p256', }, ], }, }, ], }); // wallet_addSubAccount now respects enableAutoSubAccounts, so sub account should be first const accounts = await signer.request({ method: 'eth_accounts' }); expect(accounts).toEqual([subAccountAddress, globalAccountAddress]); // However, eth_requestAccounts will reorder based on enableAutoSubAccounts const requestedAccounts = await signer.request({ method: 'eth_requestAccounts' }); expect(requestedAccounts).toEqual([subAccountAddress, globalAccountAddress]); }); }); describe('auto sub account', () => { beforeEach(async () => { await signer.cleanup(); (getClient as Mock).mockReturnValue({ getChainId: vi.fn().mockReturnValue(84532), waitForTransaction: vi.fn().mockResolvedValue({ status: 'success', }), }); vi.spyOn(store.subAccountsConfig, 'get').mockReturnValue({ enableAutoSubAccounts: true, }); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: null, }, }); await signer.handshake({ method: 'handshake' }); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: globalAccountAddress, capabilities: { subAccounts: [ { address: subAccountAddress, factory: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', factoryData: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', }, ], }, }, ], }, }, }); }); afterEach(() => { vi.restoreAllMocks(); }); it('update the owner index for the sub account', async () => { await signer.cleanup(); store.subAccounts.set({ address: '0x7838d2724FC686813CAf81d4429beff1110c739a', }); (findOwnerIndex as Mock).mockResolvedValueOnce(-1); (handleAddSubAccountOwner as Mock).mockResolvedValueOnce(0); // Ensure createSubAccountSigner returns the expected shape (createSubAccountSigner as Mock).mockResolvedValueOnce({ request: vi.fn().mockResolvedValue('0xResult'), }); (decryptContent as Mock).mockResolvedValueOnce({ result: { value: null, }, }); await signer.handshake({ method: 'handshake' }); expect(signer['accounts']).toEqual([]); signer['accounts'] = [ '0x7838d2724FC686813CAf81d4429beff1110c739a', '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', ]; const mockRequest: RequestArguments = { method: 'wallet_sendCalls', params: [ { to: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', version: '1', calls: [], from: '0x7838d2724FC686813CAf81d4429beff1110c739a', }, ], }; (decryptContent as Mock).mockResolvedValueOnce({ result: { value: { accounts: [ { address: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', capabilities: { subAccounts: [ { address: '0x7838d2724FC686813CAf81d4429beff1110c739a', factory: '0xe6c7D51b0d5ECC217BE74019447aeac4580Afb54', factoryData: '0x', }, ], }, }, ], }, }, }); await signer.request(mockRequest); expect(handleAddSubAccountOwner).toHaveBeenCalled(); }); it('should not handle insufficient balance error if external funding source data is not provided', async () => { (createSubAccountSigner as Mock).mockImplementation(async () => { const request = vi.fn((args) => { throw new HttpRequestError({ body: args, url: 'https://eth-rpc.example.com/1', details: JSON.stringify({ code: -32090, message: 'transfer amount exceeds balance', data: undefined, }), }); }); return { request, }; }); await signer.request({ method: 'wallet_connect', params: [], }); const mockRequest: RequestArguments = { method: 'wallet_sendCalls', params: [ { calls: [ { to: '0x', value: '0x0', data: '0x', }, ], chainId: numberToHex(84532), from: subAccountAddress, version: '1.0', }, ], }; signer = new Signer({ metadata: mockMetadata, communicator: mockCommunicator, callback: mockCallback, }); await expect(signer.request(mockRequest)).rejects.toThrow(); expect(handleInsufficientBalanceError).not.toHaveBeenCalled(); (createSubAccountSigner as Mock).mockRestore(); }); it('should handle insufficient balance error if external funding source is present', async () => { (createSubAccountSigner as Mock).mockImplementation(async () => { const request = vi.fn((args) => { throw new HttpRequestError({ body: args, url: 'https://eth-rpc.example.com/1', details: JSON.stringify({ code: -32090, message: 'transfer amount exceeds balance', data: { type: 'INSUFFICIENT_FUNDS', reason: 'NO_SUITABLE_SPEND_PERMISSION_FOUND', account: { address: subAccountAddress, }, required: { '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee': { amount: '0x38d7ea4c68000', sources: [ { address: globalAccountAddress, balance: '0x1d73b609302000', }, ], }, }, }, }), }); }); return { request, }; }); await signer.request({ method: 'wallet_connect', params: [], }); const mockRequest: RequestArguments = { method: 'wallet_sendCalls', params: [ { calls: [ { to: '0x', value: '0x0', data: '0x', }, ], chainId: numberToHex(84532), from: subAccountAddress, version: '1.0', }, ], }; signer = new Signer({ metadata: mockMetadata, communicator: mockCommunicator, callback: mockCallback, }); await signer.request(mockRequest); expect(handleInsufficientBalanceError).toHaveBeenCalled(); (createSubAccountSigner as Mock).mockRestore(); }); }); describe('wallet_getCapabilities', () => { let stateSpy: MockInstance; beforeEach(() => { stateSpy = vi.spyOn(store, 'getState').mockImplementation(() => ({ account: { accounts: [globalAccountAddress], capabilities: { '0x1': { atomicBatch: { supported: true }, paymasterService: { supported: true }, }, '0x5': { atomicBatch: { supported: false }, }, '0xa': { paymasterService: { supported: true }, }, }, }, chains: [], keys: {}, spendPermissions: [], config: { metadata: mockMetadata, preference: { walletUrl: CB_KEYS_URL, options: 'all' }, version: '1.0.0', }, })); signer['accounts'] = [globalAccountAddress]; }); afterEach(() => { stateSpy.mockRestore(); }); it('should return all capabilities when no filter is provided', async () => { const request = { method: 'wallet_getCapabilities', params: [globalAccountAddress], }; const result = await signer.request(request); expect(result).toEqual({ '0x1': { atomicBatch: { supported: true }, paymasterService: { supported: true }, }, '0x5': { atomicBatch: { supported: false }, }, '0xa': { paymasterService: { supported: true }, }, }); }); it('should return filtered capabilities when chain filter is provided', async () => { const request = { method: 'wallet_getCapabilities', params: [globalAccountAddress, ['0x1', '0xa']], }; const result = await signer.request(request); expect(result).toEqual({ '0x1': { atomicBatch: { supported: true }, paymasterService: { supported: true }, }, '0xa': { paymasterService: { supported: true }, }, }); }); it('should handle different hex formatting in filters', async () => { // Test that '0x01' matches '0x1' capability const request = { method: 'wallet_getCapabilities', params: [globalAccountAddress, ['0x01', '0x05']], }; const result = await signer.request(request); expect(result).toEqual({ '0x1': { atomicBatch: { supported: true }, paymasterService: { supported: true }, }, '0x5': { atomicBatch: { supported: false }, }, }); }); it('should return empty object when filter matches no capabilities', async () => { const request = { method: 'wallet_getCapabilities', params: [globalAccountAddress, ['0x99', '0x100']], }; const result = await signer.request(request); expect(result).toEqual({}); }); it('should return empty object when capabilities is undefined', async () => { stateSpy.mockImplementation(() => ({ account: { accounts: [globalAccountAddress], capabilities: undefined, }, chains: [], keys: {}, spendPermissions: [], config: { metadata: mockMetadata, preference: { walletUrl: CB_KEYS_URL, options: 'all' }, version: '1.0.0', }, })); const request = { method: 'wallet_getCapabilities', params: [globalAccountAddress], }; const result = await signer.request(request); expect(result).toEqual({}); }); it('should return empty object when empty filter array is provided', async () => { const request = { method: 'wallet_getCapabilities', params: [globalAccountAddress, []], }; const result = await signer.request(request); expect(result).toEqual({ '0x1': { atomicBatch: { supported: true }, paymasterService: { supported: true }, }, '0x5': { atomicBatch: { supported: false }, }, '0xa': { paymasterService: { supported: true }, }, }); }); it('should handle capabilities with non-hex keys gracefully', async () => { stateSpy.mockImplementation(() => ({ account: { accounts: [globalAccountAddress], capabilities: { '0x1': { atomicBatch: { supported: true } }, 'invalid-key': { someFeature: true }, '0x5': { paymasterService: { supported: true } }, }, }, chains: [], keys: {}, spendPermissions: [], config: { metadata: mockMetadata, preference: { walletUrl: CB_KEYS_URL, options: 'all' }, version: '1.0.0', }, })); const request = { method: 'wallet_getCapabilities', params: [globalAccountAddress, ['0x1']], }; const result = await signer.request(request); expect(result).toEqual({ '0x1': { atomicBatch: { supported: true } }, }); }); it('should throw error when account is not in accounts list', async () => { const request = { method: 'wallet_getCapabilities', params: [subAccountAddress], }; await expect(signer.request(request)).rejects.toThrow('no active account found'); }); it('should throw error when account parameter is invalid', async () => { const request = { method: 'wallet_getCapabilities', params: ['invalid-address'], }; await expect(signer.request(request)).rejects.toThrow(); }); it('should throw error when filter contains invalid hex strings', async () => { const request = { method: 'wallet_getCapabilities', params: [globalAccountAddress, ['0x1', 'invalid-hex']], }; await expect(signer.request(request)).rejects.toThrow(); }); }); describe('coinbase_fetchPermissions', () => { const mockSpendPermissions = [ { permissionHash: '0xPermissionHash', signature: '0xSignature', permission: { account: '0xAddress', spender: '0xSubAccount', }, chainId: 10, }, ] as [SpendPermission]; beforeEach(() => { vi.spyOn(store, 'getState').mockImplementation(() => ({ account: { accounts: ['0xAddress'], chain: { id: 10, rpcUrl: 'https://eth-rpc.example.com/10' }, }, subAccount: { address: '0xSubAccount', }, chains: [], keys: {}, spendPermissions: [], config: { metadata: mockMetadata, preference: { walletUrl: CB_KEYS_URL, options: 'all' }, version: '1.0.0', }, })); (fetchRPCRequest as Mock).mockResolvedValue({ permissions: mockSpendPermissions, }); }); it('should update internal state for successful coinbase_fetchPermissions', async () => { await signer.cleanup(); const mockRequest: RequestArguments = { method: 'coinbase_fetchPermissions', }; signer['accounts'] = ['0xAddress']; // mock the logged in state const mockSetSpendPermissions = vi.spyOn(store.spendPermissions, 'set'); await signer.request(mockRequest); expect(mockSetSpendPermissions).toHaveBeenCalledWith(mockSpendPermissions); }); }); });