Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sub account SDK helper methods #1534

Merged
merged 5 commits into from
Feb 24, 2025
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
7 changes: 1 addition & 6 deletions examples/testapp/src/pages/add-address/index.page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,13 +19,10 @@ export default function SubAccounts() {
const sdk = createCoinbaseWalletSDK({
appName: 'CryptoPlayground',
preference: {
keysUrl: 'http://localhost:3005/connect',
options: 'smartWalletOnly',
},
subaccount: {
getSigner: getCryptoKeyAccount as () => Promise<{
account: OneOf<WebAuthnAccount | LocalAccount> | null;
}>,
getSigner: getCryptoKeyAccount,
},
});

Expand Down
176 changes: 175 additions & 1 deletion packages/wallet-sdk/src/createCoinbaseWalletSDK.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -53,9 +54,182 @@ 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({ key: '0x123', chainId: 1 })).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({ key: '0x123', chainId: 1 })).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({ key: '0x123', chainId: 1 });
expect(mockRequest).toHaveBeenCalledWith({
method: 'wallet_addAddress',
params: [
{
chainId: 1,
capabilities: {
createAccount: {
signer: '0x123',
},
},
},
],
});
});
});

describe('subaccount.get', () => {
afterEach(() => {
subaccounts.setState({ account: undefined, getSigner: null });
});

it('should return existing account if it exists', async () => {
const sdk = createCoinbaseWalletSDK(options);
const mockAccount = { address: '0x123' };
subaccounts.setState({ account: mockAccount as any });
expect(await sdk.subaccount.get(1)).toBe(mockAccount);
subaccounts.setState({ account: undefined });
});

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);

await 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 });
});
});
});
114 changes: 110 additions & 4 deletions packages/wallet-sdk/src/createCoinbaseWalletSDK.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { decodeAbiParameters, encodeFunctionData, toHex } from 'viem';

import { createCoinbaseWalletProvider } from './createCoinbaseWalletProvider.js';
import { VERSION } from './sdk-info.js';
import {
Expand All @@ -6,11 +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<AppMetadata> & {
preference?: Preference;
subaccount?: {
Expand Down Expand Up @@ -60,14 +63,115 @@ 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, chainId }: { key: `0x${string}`; chainId: number }) {
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: [
{
chainId,
capabilities: {
createAccount: {
signer: key,
},
},
},
],
});
},
async get(chainId: number) {
const state = subaccounts.getState();
if (!state.account) {
const response = (await sdk.getProvider()?.request({
method: 'wallet_connect',
params: [
{
version: 1,
capabilities: {
getAppAccounts: {
chainId,
},
},
},
],
})) as WalletConnectResponse;
return response.accounts[0].capabilities?.getAppAccounts?.[0];
}
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({
Expand All @@ -76,4 +180,6 @@ export function createCoinbaseWalletSDK(params: CreateCoinbaseWalletSDKOptions)
},
},
};

return sdk;
}
12 changes: 6 additions & 6 deletions packages/wallet-sdk/src/kms/crypto-key/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<P256KeyPair> {
const keypair = await WebCryptoP256.createKeyPair({ extractable: false });
const publicKey = Hex.slice(PublicKey.toHex(keypair.publicKey), 1);
Expand Down
Loading