From 5dc30f6381bdc86fe9252de5695c1412c4cf9241 Mon Sep 17 00:00:00 2001 From: Jake <95890768+cb-jake@users.noreply.github.com> Date: Sun, 23 Feb 2025 20:30:09 -0700 Subject: [PATCH 1/5] add helper methods --- .../src/pages/add-address/index.page.tsx | 2 +- .../src/createCoinbaseWalletSDK.test.ts | 165 +++++++++++++++++- .../wallet-sdk/src/createCoinbaseWalletSDK.ts | 110 +++++++++++- .../wallet-sdk/src/kms/crypto-key/index.ts | 12 +- 4 files changed, 278 insertions(+), 11 deletions(-) diff --git a/examples/testapp/src/pages/add-address/index.page.tsx b/examples/testapp/src/pages/add-address/index.page.tsx index 0530ad71a0..1ebfe9ab85 100644 --- a/examples/testapp/src/pages/add-address/index.page.tsx +++ b/examples/testapp/src/pages/add-address/index.page.tsx @@ -21,7 +21,7 @@ export default function SubAccounts() { const sdk = createCoinbaseWalletSDK({ appName: 'CryptoPlayground', preference: { - keysUrl: 'http://localhost:3005/connect', + keysUrl: 'https://keys.coinbase.com/connect', options: 'smartWalletOnly', }, subaccount: { diff --git a/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts b/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts index 5d6b3a8f5c..3285ea91b9 100644 --- a/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts +++ b/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts @@ -3,6 +3,7 @@ import { CreateCoinbaseWalletSDKOptions, } from './createCoinbaseWalletSDK.js'; import { subaccounts } from ':stores/sub-accounts/store.js'; + const options: CreateCoinbaseWalletSDKOptions = { appName: 'Dapp', appLogoUrl: 'https://example.com/favicon.ico', @@ -53,9 +54,171 @@ describe('createCoinbaseWalletSDK', () => { expect(subaccounts.getState().getSigner).toBe(null); const sdk = createCoinbaseWalletSDK(options); const getSigner = () => Promise.resolve({} as any); - sdk.accounts.setSigner(getSigner); + sdk.subaccount.setSigner(getSigner); expect(subaccounts.getState().getSigner).toBe(getSigner); // reset the state subaccounts.setState({ getSigner: null }); }); + + describe('subaccount.create', () => { + afterEach(() => { + subaccounts.setState({ account: undefined, getSigner: null }); + }); + + it('should throw if no signer is set', async () => { + const sdk = createCoinbaseWalletSDK(options); + await expect(sdk.subaccount.create('0x123')).rejects.toThrow('no signer found'); + }); + + it('should throw if subaccount already exists', async () => { + const sdk = createCoinbaseWalletSDK({ + ...options, + subaccount: { getSigner: () => Promise.resolve({} as any) }, + }); + subaccounts.setState({ account: { address: '0x123' } as any }); + await expect(sdk.subaccount.create('0x123')).rejects.toThrow('subaccount already exists'); + subaccounts.setState({ account: undefined }); + }); + + it('should call wallet_addAddress with correct params', async () => { + const mockRequest = vi.fn(); + const sdk = createCoinbaseWalletSDK({ + ...options, + subaccount: { getSigner: () => Promise.resolve({} as any) }, + }); + vi.spyOn(sdk, 'getProvider').mockImplementation(() => ({ request: mockRequest }) as any); + + await sdk.subaccount.create('0x123'); + expect(mockRequest).toHaveBeenCalledWith({ + method: 'wallet_addAddress', + params: [ + { + capabilities: { + createAccount: { + signer: '0x123', + }, + }, + }, + ], + }); + }); + }); + + describe('subaccount.get', () => { + afterEach(() => { + subaccounts.setState({ account: undefined, getSigner: null }); + }); + + it('should return existing account if it exists', () => { + const sdk = createCoinbaseWalletSDK(options); + const mockAccount = { address: '0x123' }; + subaccounts.setState({ account: mockAccount as any }); + expect(sdk.subaccount.get(1)).toBe(mockAccount); + subaccounts.setState({ account: undefined }); + }); + + it('should call wallet_connect if no account exists', () => { + const mockRequest = vi.fn(); + const sdk = createCoinbaseWalletSDK(options); + vi.spyOn(sdk, 'getProvider').mockImplementation(() => ({ request: mockRequest }) as any); + + sdk.subaccount.get(1); + expect(mockRequest).toHaveBeenCalledWith({ + method: 'wallet_connect', + params: [ + { + version: 1, + capabilities: { + getAppAccounts: { + chainId: 1, + }, + }, + }, + ], + }); + }); + }); + + describe('subaccount.addOwner', () => { + afterEach(() => { + subaccounts.setState({ account: undefined, getSigner: null }); + }); + it('should throw if no signer is set', async () => { + const sdk = createCoinbaseWalletSDK(options); + await expect( + sdk.subaccount.addOwner({ address: '0xE3cA9Cc9378143a26b9d4692Ca3722dc45910a15' }) + ).rejects.toThrow('no signer found'); + }); + + it('should throw if no subaccount exists', async () => { + const sdk = createCoinbaseWalletSDK({ + ...options, + subaccount: { getSigner: () => Promise.resolve({} as any) }, + }); + await expect( + sdk.subaccount.addOwner({ address: '0xE3cA9Cc9378143a26b9d4692Ca3722dc45910a15' }) + ).rejects.toThrow('subaccount does not exist'); + }); + + it('should call wallet_sendCalls with address param', async () => { + const mockRequest = vi.fn(); + const sdk = createCoinbaseWalletSDK({ + ...options, + subaccount: { getSigner: () => Promise.resolve({} as any) }, + }); + subaccounts.setState({ account: { address: '0x456', root: '0x789' } as any }); + vi.spyOn(sdk, 'getProvider').mockImplementation(() => ({ request: mockRequest }) as any); + + await sdk.subaccount.addOwner({ address: '0xE3cA9Cc9378143a26b9d4692Ca3722dc45910a15' }); + expect(mockRequest).toHaveBeenCalledWith({ + method: 'wallet_sendCalls', + params: [ + { + version: 1, + calls: [ + { + to: '0x456', + data: expect.any(String), + value: '0x0', + }, + ], + from: '0x789', + }, + ], + }); + subaccounts.setState({ account: undefined }); + }); + + it('should call wallet_sendCalls with publicKey param', async () => { + const mockRequest = vi.fn(); + const sdk = createCoinbaseWalletSDK({ + ...options, + subaccount: { getSigner: () => Promise.resolve({} as any) }, + }); + subaccounts.setState({ account: { address: '0x456', root: '0x789' } as any }); + vi.spyOn(sdk, 'getProvider').mockImplementation(() => ({ request: mockRequest }) as any); + + await sdk.subaccount.addOwner({ + publicKey: + '0x7da44d4bc972affd138c619a211ef0afe0926b813fec67d15587cf8625b2bf185f5044ae96640a63b32aa1eb6f8f993006bbd26292b81cb07a0672302c69a866', + }); + expect(mockRequest).toHaveBeenCalledWith({ + method: 'wallet_sendCalls', + params: [ + { + version: 1, + calls: [ + { + to: '0x456', + data: expect.any(String), + value: '0x0', + }, + ], + from: '0x789', + }, + ], + }); + subaccounts.setState({ account: undefined }); + }); + }); }); diff --git a/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts b/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts index 6f140d429e..d5693a8240 100644 --- a/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts +++ b/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts @@ -1,3 +1,5 @@ +import { decodeAbiParameters, encodeFunctionData, toHex } from 'viem'; + import { createCoinbaseWalletProvider } from './createCoinbaseWalletProvider.js'; import { VERSION } from './sdk-info.js'; import { @@ -7,6 +9,7 @@ import { ProviderInterface, } from ':core/provider/interface.js'; import { ScopedLocalStorage } from ':core/storage/ScopedLocalStorage.js'; +import { abi } from ':sign/scw/utils/constants.js'; import { subaccounts, SubAccountState } from ':stores/sub-accounts/store.js'; import { checkCrossOriginOpenerPolicy } from ':util/checkCrossOriginOpenerPolicy.js'; import { validatePreferences, validateSubAccount } from ':util/validatePreferences.js'; @@ -60,14 +63,113 @@ export function createCoinbaseWalletSDK(params: CreateCoinbaseWalletSDKOptions) let provider: ProviderInterface | null = null; - return { - getProvider: () => { + const sdk = { + getProvider() { if (!provider) { provider = createCoinbaseWalletProvider(options); } return provider; }, - accounts: { + subaccount: { + async create(key: `0x${string}`) { + const state = subaccounts.getState(); + if (!state.getSigner) { + throw new Error('no signer found'); + } + + if (state.account) { + throw new Error('subaccount already exists'); + } + return sdk.getProvider()?.request({ + method: 'wallet_addAddress', + params: [ + { + capabilities: { + createAccount: { + signer: key, + }, + }, + }, + ], + }); + }, + get(chainId: number) { + const state = subaccounts.getState(); + if (!state.account) { + return sdk.getProvider()?.request({ + method: 'wallet_connect', + params: [ + { + version: 1, + capabilities: { + getAppAccounts: { + chainId, + }, + }, + }, + ], + }); + } + return state.account; + }, + async addOwner({ + address, + publicKey, + }: + | { + address: `0x${string}`; + publicKey?: never; + } + | { + address?: never; + publicKey: `0x${string}`; + }) { + const state = subaccounts.getState(); + if (!state.getSigner) { + throw new Error('no signer found'); + } + + if (!state.account) { + throw new Error('subaccount does not exist'); + } + + const calls = []; + if (publicKey) { + const [x, y] = decodeAbiParameters([{ type: 'bytes32' }, { type: 'bytes32' }], publicKey); + calls.push({ + to: state.account.address, + data: encodeFunctionData({ + abi, + functionName: 'addOwnerPublicKey', + args: [x, y] as const, + }), + value: toHex(0), + }); + } + + if (address) { + calls.push({ + to: state.account.address, + data: encodeFunctionData({ + abi, + functionName: 'addOwnerAddress', + args: [address] as const, + }), + value: toHex(0), + }); + } + + return sdk.getProvider()?.request({ + method: 'wallet_sendCalls', + params: [ + { + version: 1, + calls, + from: state.account.root, + }, + ], + }); + }, setSigner(params: SubAccountState['getSigner']) { validateSubAccount(params); subaccounts.setState({ @@ -76,4 +178,6 @@ export function createCoinbaseWalletSDK(params: CreateCoinbaseWalletSDKOptions) }, }, }; + + return sdk; } diff --git a/packages/wallet-sdk/src/kms/crypto-key/index.ts b/packages/wallet-sdk/src/kms/crypto-key/index.ts index 19c1e4dd65..2df0efe7be 100644 --- a/packages/wallet-sdk/src/kms/crypto-key/index.ts +++ b/packages/wallet-sdk/src/kms/crypto-key/index.ts @@ -9,22 +9,22 @@ export type P256KeyPair = { publicKey: PublicKey.PublicKey; }; -///////////////////////////////////////////////////////////////////////////////////////////// +// ***************************************************************** // Constants -///////////////////////////////////////////////////////////////////////////////////////////// +// ***************************************************************** export const STORAGE_SCOPE = 'cbwsdk'; export const STORAGE_NAME = 'keys'; export const ACTIVE_ID_KEY = 'activeId'; -///////////////////////////////////////////////////////////////////////////////////////////// +// ***************************************************************** // Storage -///////////////////////////////////////////////////////////////////////////////////////////// +// ***************************************************************** export const storage = createStorage(STORAGE_SCOPE, STORAGE_NAME); -///////////////////////////////////////////////////////////////////////////////////////////// +// ***************************************************************** // Functions -///////////////////////////////////////////////////////////////////////////////////////////// +// ***************************************************************** export async function generateKeyPair(): Promise { const keypair = await WebCryptoP256.createKeyPair({ extractable: false }); const publicKey = Hex.slice(PublicKey.toHex(keypair.publicKey), 1); From d66576989bc7906fd93757a19a6bb8490eaca667 Mon Sep 17 00:00:00 2001 From: Jake <95890768+cb-jake@users.noreply.github.com> Date: Sun, 23 Feb 2025 21:34:37 -0700 Subject: [PATCH 2/5] fixup example --- examples/testapp/src/pages/add-address/index.page.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/examples/testapp/src/pages/add-address/index.page.tsx b/examples/testapp/src/pages/add-address/index.page.tsx index 1ebfe9ab85..e347766d86 100644 --- a/examples/testapp/src/pages/add-address/index.page.tsx +++ b/examples/testapp/src/pages/add-address/index.page.tsx @@ -1,8 +1,6 @@ import { Container, VStack } from '@chakra-ui/react'; import { createCoinbaseWalletSDK, getCryptoKeyAccount } from '@coinbase/wallet-sdk'; import React, { useEffect, useState } from 'react'; -import { LocalAccount, OneOf } from 'viem'; -import { WebAuthnAccount } from 'viem/account-abstraction'; import { AddAddress } from './components/AddAddress'; import { AddOwner } from './components/AddOwner'; @@ -21,13 +19,10 @@ export default function SubAccounts() { const sdk = createCoinbaseWalletSDK({ appName: 'CryptoPlayground', preference: { - keysUrl: 'https://keys.coinbase.com/connect', options: 'smartWalletOnly', }, subaccount: { - getSigner: getCryptoKeyAccount as () => Promise<{ - account: OneOf | null; - }>, + getSigner: getCryptoKeyAccount, }, }); From be497fc1506ffce41507f464c8e2406ee5cf4cb4 Mon Sep 17 00:00:00 2001 From: Jake <95890768+cb-jake@users.noreply.github.com> Date: Sun, 23 Feb 2025 21:50:38 -0700 Subject: [PATCH 3/5] add chain id --- .../wallet-sdk/src/createCoinbaseWalletSDK.test.ts | 10 +++++++--- packages/wallet-sdk/src/createCoinbaseWalletSDK.ts | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts b/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts index 3285ea91b9..8618cead00 100644 --- a/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts +++ b/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts @@ -67,7 +67,9 @@ describe('createCoinbaseWalletSDK', () => { it('should throw if no signer is set', async () => { const sdk = createCoinbaseWalletSDK(options); - await expect(sdk.subaccount.create('0x123')).rejects.toThrow('no signer found'); + await expect(sdk.subaccount.create({ key: '0x123', chainId: 1 })).rejects.toThrow( + 'no signer found' + ); }); it('should throw if subaccount already exists', async () => { @@ -76,7 +78,9 @@ describe('createCoinbaseWalletSDK', () => { subaccount: { getSigner: () => Promise.resolve({} as any) }, }); subaccounts.setState({ account: { address: '0x123' } as any }); - await expect(sdk.subaccount.create('0x123')).rejects.toThrow('subaccount already exists'); + await expect(sdk.subaccount.create({ key: '0x123', chainId: 1 })).rejects.toThrow( + 'subaccount already exists' + ); subaccounts.setState({ account: undefined }); }); @@ -88,7 +92,7 @@ describe('createCoinbaseWalletSDK', () => { }); vi.spyOn(sdk, 'getProvider').mockImplementation(() => ({ request: mockRequest }) as any); - await sdk.subaccount.create('0x123'); + await sdk.subaccount.create({ key: '0x123', chainId: 1 }); expect(mockRequest).toHaveBeenCalledWith({ method: 'wallet_addAddress', params: [ diff --git a/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts b/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts index d5693a8240..a662b48926 100644 --- a/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts +++ b/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts @@ -71,7 +71,7 @@ export function createCoinbaseWalletSDK(params: CreateCoinbaseWalletSDKOptions) return provider; }, subaccount: { - async create(key: `0x${string}`) { + async create({ key, chainId }: { key: `0x${string}`; chainId: number }) { const state = subaccounts.getState(); if (!state.getSigner) { throw new Error('no signer found'); @@ -84,6 +84,7 @@ export function createCoinbaseWalletSDK(params: CreateCoinbaseWalletSDKOptions) method: 'wallet_addAddress', params: [ { + chainId, capabilities: { createAccount: { signer: key, From 32a7e56d3fee5ad9e1e8d7891a05595b1b1f145f Mon Sep 17 00:00:00 2001 From: Jake <95890768+cb-jake@users.noreply.github.com> Date: Sun, 23 Feb 2025 22:07:12 -0700 Subject: [PATCH 4/5] fix response --- packages/wallet-sdk/src/createCoinbaseWalletSDK.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts b/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts index a662b48926..e6e4d6d5a9 100644 --- a/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts +++ b/packages/wallet-sdk/src/createCoinbaseWalletSDK.ts @@ -8,12 +8,12 @@ import { Preference, ProviderInterface, } from ':core/provider/interface.js'; +import { WalletConnectResponse } from ':core/rpc/wallet_connect.js'; import { ScopedLocalStorage } from ':core/storage/ScopedLocalStorage.js'; import { abi } from ':sign/scw/utils/constants.js'; import { subaccounts, SubAccountState } from ':stores/sub-accounts/store.js'; import { checkCrossOriginOpenerPolicy } from ':util/checkCrossOriginOpenerPolicy.js'; import { validatePreferences, validateSubAccount } from ':util/validatePreferences.js'; - export type CreateCoinbaseWalletSDKOptions = Partial & { preference?: Preference; subaccount?: { @@ -94,10 +94,10 @@ export function createCoinbaseWalletSDK(params: CreateCoinbaseWalletSDKOptions) ], }); }, - get(chainId: number) { + async get(chainId: number) { const state = subaccounts.getState(); if (!state.account) { - return sdk.getProvider()?.request({ + const response = (await sdk.getProvider()?.request({ method: 'wallet_connect', params: [ { @@ -109,7 +109,8 @@ export function createCoinbaseWalletSDK(params: CreateCoinbaseWalletSDKOptions) }, }, ], - }); + })) as WalletConnectResponse; + return response.accounts[0].capabilities?.getAppAccounts?.[0]; } return state.account; }, From 1008c84eb2bc5e1262bf268136cdbcf4a982f82a Mon Sep 17 00:00:00 2001 From: Jake <95890768+cb-jake@users.noreply.github.com> Date: Sun, 23 Feb 2025 22:40:05 -0700 Subject: [PATCH 5/5] tests --- .../src/createCoinbaseWalletSDK.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts b/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts index 8618cead00..cc30f4d4f0 100644 --- a/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts +++ b/packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts @@ -97,6 +97,7 @@ describe('createCoinbaseWalletSDK', () => { method: 'wallet_addAddress', params: [ { + chainId: 1, capabilities: { createAccount: { signer: '0x123', @@ -113,20 +114,26 @@ describe('createCoinbaseWalletSDK', () => { subaccounts.setState({ account: undefined, getSigner: null }); }); - it('should return existing account if it exists', () => { + it('should return existing account if it exists', async () => { const sdk = createCoinbaseWalletSDK(options); const mockAccount = { address: '0x123' }; subaccounts.setState({ account: mockAccount as any }); - expect(sdk.subaccount.get(1)).toBe(mockAccount); + expect(await sdk.subaccount.get(1)).toBe(mockAccount); subaccounts.setState({ account: undefined }); }); - it('should call wallet_connect if no account exists', () => { - const mockRequest = vi.fn(); + it('should call wallet_connect if no account exists', async () => { + const mockRequest = vi.fn().mockResolvedValue({ + accounts: [ + { + address: '0x123', + }, + ], + }); const sdk = createCoinbaseWalletSDK(options); vi.spyOn(sdk, 'getProvider').mockImplementation(() => ({ request: mockRequest }) as any); - sdk.subaccount.get(1); + await sdk.subaccount.get(1); expect(mockRequest).toHaveBeenCalledWith({ method: 'wallet_connect', params: [