Skip to content

Commit

Permalink
[BW-857] Abstract handshake into coinbase_handshake (#1475)
Browse files Browse the repository at this point in the history
* SDK changes

* playground changes

* merge switch w if

* allow wallet_sendCalls in provider

* both methods

* wallet_getCallsStatus

* tests
  • Loading branch information
fan-zhang-sv authored Jan 9, 2025
1 parent 701e2d4 commit 88773cb
Show file tree
Hide file tree
Showing 9 changed files with 185 additions and 12 deletions.
6 changes: 2 additions & 4 deletions examples/testapp/src/components/RpcMethods/RpcMethodCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,15 @@ export function RpcMethodCard({ format, method, params, shortcuts }) {
const dataToSubmit = { ...data };
let values = dataToSubmit;
if (format) {
// fill active address to the request
const addresses = await provider.request({ method: 'eth_accounts' });
const chainId = await provider.request({ method: 'eth_chainId' });

for (const key in dataToSubmit) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
if (dataToSubmit[key] === ADDR_TO_FILL) {
const addresses = await provider.request({ method: 'eth_accounts' });
dataToSubmit[key] = addresses[0];
}

if (dataToSubmit[key] === CHAIN_ID_TO_FILL) {
const chainId = await provider.request({ method: 'eth_chainId' });
dataToSubmit[key] = chainId;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { RpcRequestInput } from './RpcRequestInput';

const walletSendCallsEphemeral: RpcRequestInput = {
method: 'wallet_sendCalls',
params: [
{ key: 'version', required: true },
{ key: 'chainId', required: true },
{ key: 'calls', required: true },
],
format: (data: Record<string, string>) => [
{
chainId: data.chainId,
calls: data.calls,
version: data.version,
},
],
};

const walletSignEphemeral: RpcRequestInput = {
method: 'wallet_sign',
params: [{ key: 'message', required: true }],
format: (data: Record<string, string>) => [
`0x${Buffer.from(data.message, 'utf8').toString('hex')}`,
],
};

export const ephemeralMethods = [walletSendCallsEphemeral, walletSignEphemeral];
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ShortcutType } from './ShortcutType';

const walletSendCallsEphemeralShortcuts: ShortcutType[] = [
{
key: 'wallet_sendCalls',
data: {
chainId: '84532',
calls: [],
version: '1',
},
},
];

const walletSignEphemeralShortcuts: ShortcutType[] = [
{
key: 'wallet_sign',
data: {
message: 'Hello, world!',
},
},
];

export const ephemeralMethodShortcutsMap = {
wallet_sendCalls: walletSendCallsEphemeralShortcuts,
wallet_sign: walletSignEphemeralShortcuts,
};
7 changes: 7 additions & 0 deletions examples/testapp/src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { EventListenersCard } from '../components/EventListeners/EventListenersC
import { WIDTH_2XL } from '../components/Layout';
import { MethodsSection } from '../components/MethodsSection/MethodsSection';
import { connectionMethods } from '../components/RpcMethods/method/connectionMethods';
import { ephemeralMethods } from '../components/RpcMethods/method/ephemeralMethods';
import { multiChainMethods } from '../components/RpcMethods/method/multiChainMethods';
import { readonlyJsonRpcMethods } from '../components/RpcMethods/method/readonlyJsonRpcMethods';
import { sendMethods } from '../components/RpcMethods/method/sendMethods';
import { signMessageMethods } from '../components/RpcMethods/method/signMessageMethods';
import { walletTxMethods } from '../components/RpcMethods/method/walletTxMethods';
import { ephemeralMethodShortcutsMap } from '../components/RpcMethods/shortcut/ephemeralMethodShortcuts';
import { multiChainShortcutsMap } from '../components/RpcMethods/shortcut/multipleChainShortcuts';
import { readonlyJsonRpcShortcutsMap } from '../components/RpcMethods/shortcut/readonlyJsonRpcShortcuts';
import { sendShortcutsMap } from '../components/RpcMethods/shortcut/sendShortcuts';
Expand Down Expand Up @@ -70,6 +72,11 @@ export default function Home() {
</>
)}
<MethodsSection title="Wallet Connection" methods={connectionMethods} />
<MethodsSection
title="Ephemeral Methods"
methods={ephemeralMethods}
shortcutsMap={ephemeralMethodShortcutsMap}
/>
{shouldShowMethodsRequiringConnection && (
<>
<MethodsSection
Expand Down
24 changes: 24 additions & 0 deletions packages/wallet-sdk/src/CoinbaseWalletProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { CoinbaseWalletProvider } from './CoinbaseWalletProvider.js';
import * as util from './sign/util.js';
import * as providerUtil from './util/provider.js';
import { CB_WALLET_RPC_URL } from ':core/constants.js';
import { standardErrorCodes } from ':core/error/constants.js';
import { standardErrors } from ':core/error/errors.js';
import { ProviderEventCallback, RequestArguments } from ':core/provider/interface.js';
Expand All @@ -15,6 +17,7 @@ function createProvider() {
const mockHandshake = vi.fn();
const mockRequest = vi.fn();
const mockCleanup = vi.fn();
const mockFetchRPCRequest = vi.fn();
const mockFetchSignerType = vi.spyOn(util, 'fetchSignerType');
const mockStoreSignerType = vi.spyOn(util, 'storeSignerType');
const mockLoadSignerType = vi.spyOn(util, 'loadSignerType');
Expand All @@ -34,6 +37,7 @@ beforeEach(() => {
cleanup: mockCleanup,
};
});
vi.spyOn(providerUtil, 'fetchRPCRequest').mockImplementation(mockFetchRPCRequest);

provider = createProvider();
});
Expand Down Expand Up @@ -96,6 +100,26 @@ describe('Request Handling', () => {
});
});

describe('Ephemeral methods', () => {
it('should post requests to wallet rpc url for wallet_getCallsStatus', async () => {
const args = { method: 'wallet_getCallsStatus' };
expect(provider['signer']).toBeNull();
await provider.request(args);
expect(mockFetchRPCRequest).toHaveBeenCalledWith(args, CB_WALLET_RPC_URL);
expect(provider['signer']).toBeNull();
});

it('should pass args to SCWSigner', async () => {
const args = { method: 'wallet_sign', params: ['0xdeadbeef'] };
expect(provider['signer']).toBeNull();
await provider.request(args);
expect(mockHandshake).toHaveBeenCalledWith({ method: 'coinbase_handshake' });
expect(mockRequest).toHaveBeenCalledWith(args);
expect(mockCleanup).toHaveBeenCalled();
expect(provider['signer']).toBeNull();
});
});

describe('Signer configuration', () => {
it('should complete signerType selection correctly', async () => {
mockFetchSignerType.mockResolvedValue('scw');
Expand Down
15 changes: 13 additions & 2 deletions packages/wallet-sdk/src/CoinbaseWalletProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Signer } from './sign/interface.js';
import { createSigner, fetchSignerType, loadSignerType, storeSignerType } from './sign/util.js';
import { Communicator } from ':core/communicator/Communicator.js';
import { CB_WALLET_RPC_URL } from ':core/constants.js';
import { standardErrorCodes } from ':core/error/constants.js';
import { standardErrors } from ':core/error/errors.js';
import { serializeError } from ':core/error/serialize.js';
Expand All @@ -15,7 +16,7 @@ import {
} from ':core/provider/interface.js';
import { ScopedLocalStorage } from ':core/storage/ScopedLocalStorage.js';
import { hexStringFromNumber } from ':core/type/util.js';
import { checkErrorForInvalidRequestArgs } from ':util/provider.js';
import { checkErrorForInvalidRequestArgs, fetchRPCRequest } from ':util/provider.js';

export class CoinbaseWalletProvider extends ProviderEventEmitter implements ProviderInterface {
private readonly metadata: AppMetadata;
Expand Down Expand Up @@ -53,6 +54,16 @@ export class CoinbaseWalletProvider extends ProviderEventEmitter implements Prov
storeSignerType(signerType);
break;
}
case 'wallet_sendCalls':
case 'wallet_sign': {
const ephemeralSigner = this.initSigner('scw');
await ephemeralSigner.handshake({ method: 'coinbase_handshake' }); // exchange session keys
const result = await ephemeralSigner.request(args); // send diffie-hellman encrypted request
await ephemeralSigner.cleanup(); // clean up (rotate) the ephemeral session keys
return result as T;
}
case 'wallet_getCallsStatus':
return fetchRPCRequest(args, CB_WALLET_RPC_URL);
case 'net_version':
return 1 as T; // default value
case 'eth_chainId':
Expand All @@ -64,7 +75,7 @@ export class CoinbaseWalletProvider extends ProviderEventEmitter implements Prov
}
}
}
return this.signer.request(args);
return await this.signer.request(args);
} catch (error) {
const { code } = error as { code?: number };
if (code === standardErrorCodes.provider.unauthorized) this.disconnect();
Expand Down
1 change: 1 addition & 0 deletions packages/wallet-sdk/src/core/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const CB_KEYS_URL = 'https://keys.coinbase.com/connect';
export const CB_WALLET_RPC_URL = 'http://rpc.wallet.coinbase.com';
export const WALLETLINK_URL = 'https://www.walletlink.org';
export const CBW_MOBILE_DEEPLINK_URL = 'https://go.cb-w.com/walletlink';
65 changes: 64 additions & 1 deletion packages/wallet-sdk/src/sign/scw/SCWSigner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('SCWSigner', () => {
});

describe('handshake', () => {
it('should perform a successful handshake', async () => {
it('should perform a successful handshake for eth_requestAccounts', async () => {
(decryptContent as Mock).mockResolvedValueOnce({
result: {
value: ['0xAddress'],
Expand Down Expand Up @@ -124,6 +124,32 @@ describe('SCWSigner', () => {
expect(mockCallback).toHaveBeenCalledWith('connect', { chainId: '0x1' });
});

it('should perform a successful handshake for coinbase_handshake', async () => {
(decryptContent as Mock).mockResolvedValueOnce({
result: {
value: null,
},
});

await signer.handshake({ method: 'coinbase_handshake' });

expect(importKeyFromHexString).toHaveBeenCalledWith('public', '0xPublicKey');
expect(mockCommunicator.postRequestAndWaitForResponse).toHaveBeenCalledWith(
expect.objectContaining({
sender: '0xPublicKey',
content: {
handshake: expect.objectContaining({
method: 'coinbase_handshake',
}),
},
})
);
expect(mockKeyManager.setPeerPublicKey).toHaveBeenCalledWith(mockCryptoKey);
expect(decryptContent).toHaveBeenCalledWith(encryptedData, mockCryptoKey);

expect(storageStoreSpy).not.toHaveBeenCalled();
});

it('should throw an error if failure in response.content', async () => {
const mockResponse: RPCResponseMessage = {
id: '1-2-3-4-5',
Expand All @@ -140,6 +166,42 @@ describe('SCWSigner', () => {
});
});

describe('request - using ephemeral SCWSigner', () => {
it.each(['wallet_sign', 'wallet_sendCalls'])(
'should perform a successful request after coinbase_handshake',
async (method) => {
const mockRequest: RequestArguments = { method };

(decryptContent as Mock).mockResolvedValueOnce({
result: {
value: null,
},
});
await signer.handshake({ method: 'coinbase_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', () => {
beforeAll(() => {
vi.spyOn(ScopedLocalStorage.prototype, 'loadObject').mockImplementation((key) => {
Expand Down Expand Up @@ -185,6 +247,7 @@ describe('SCWSigner', () => {
it.each([
'eth_ecRecover',
'personal_sign',
'wallet_sign',
'personal_ecRecover',
'eth_signTransaction',
'eth_sendTransaction',
Expand Down
26 changes: 21 additions & 5 deletions packages/wallet-sdk/src/sign/scw/SCWSigner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export class SCWSigner implements Signer {
}

async handshake(args: RequestArguments) {
// Open the popup before constructing the request message.
// This is to ensure that the popup is not blocked by some browsers (i.e. Safari)
await this.communicator.waitForPopupLoaded?.();

const handshakeMessage = await this.createRequestMessage({
handshake: {
method: args.method,
Expand All @@ -80,15 +84,26 @@ export class SCWSigner implements Signer {
const result = decrypted.result;
if ('error' in result) throw result.error;

const accounts = result.value as AddressString[];
this.accounts = accounts;
this.storage.storeObject(ACCOUNTS_KEY, accounts);
this.callback?.('accountsChanged', accounts);
switch (args.method) {
case 'eth_requestAccounts': {
const accounts = result.value as AddressString[];
this.accounts = accounts;
this.storage.storeObject(ACCOUNTS_KEY, accounts);
this.callback?.('accountsChanged', accounts);
break;
}
}
}

async request(request: RequestArguments) {
if (this.accounts.length === 0) {
throw standardErrors.provider.unauthorized();
switch (request.method) {
case 'wallet_sendCalls':
case 'wallet_sign':
return this.sendRequestToPopup(request);
default:
throw standardErrors.provider.unauthorized();
}
}

switch (request.method) {
Expand All @@ -109,6 +124,7 @@ export class SCWSigner implements Signer {
return this.handleSwitchChainRequest(request);
case 'eth_ecRecover':
case 'personal_sign':
case 'wallet_sign':
case 'personal_ecRecover':
case 'eth_signTransaction':
case 'eth_sendTransaction':
Expand Down

0 comments on commit 88773cb

Please sign in to comment.