Skip to content

Commit 13cd092

Browse files
authored
fix: improve multichain SDK to use ConnectionRequests as QRCodes (#1347)
* fix: improve install modal to manage links directly not sessionRequests * fix: implementing ConnectionRequest * fix: assign initial QRCode link on install modal web * fix: improve tests for install modal and UI abstraction * fix: add window CustomEvent and global stub * fix: upgrade multichain api client * fix: improve multichain sdk for reconnection * fix: refactor demo to use provider and not have whole dom rendered constantly * fix: improve multichain sdk state reporting * fix: improve the UX * fix: session tests and default transport in multichain sdk * fix: provider improvement * fix: test improvements and session revocation upon session upgrade * fix: correct tests * fix: linter * fix: improve modal types * fix: improve install modal types * fix: linter issues * fix: remove unneded modal close, startOnboarding redirects you no need to unmount * fix: restore formatted expiredIn in node modal * fix: test fix, we no longer unmount modal when starting desktop onboarding
1 parent 10363ac commit 13cd092

File tree

35 files changed

+1410
-1072
lines changed

35 files changed

+1410
-1072
lines changed

packages/sdk-multichain-ui/src/components.d.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
import { HTMLStencilElement, JSXBase } from "@stencil/core/internal";
88
export namespace Components {
99
interface MmInstallModal {
10+
"expiresIn": number;
11+
"link": string;
1012
"preferDesktop": boolean;
1113
"sdkVersion"?: string;
12-
"sessionRequest": any;
1314
}
1415
interface MmOtpModal {
1516
/**
@@ -32,7 +33,8 @@ declare global {
3233
interface HTMLMmInstallModalElementEventMap {
3334
"close": { shouldTerminate?: boolean };
3435
"startDesktopOnboarding": any;
35-
"updateSessionRequest": any;
36+
"updateLink": string;
37+
"updateExpiresIn": number;
3638
}
3739
interface HTMLMmInstallModalElement extends Components.MmInstallModal, HTMLStencilElement {
3840
addEventListener<K extends keyof HTMLMmInstallModalElementEventMap>(type: K, listener: (this: HTMLMmInstallModalElement, ev: MmInstallModalCustomEvent<HTMLMmInstallModalElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
@@ -74,12 +76,14 @@ declare global {
7476
}
7577
declare namespace LocalJSX {
7678
interface MmInstallModal {
79+
"expiresIn"?: number;
80+
"link"?: string;
7781
"onClose"?: (event: MmInstallModalCustomEvent<{ shouldTerminate?: boolean }>) => void;
7882
"onStartDesktopOnboarding"?: (event: MmInstallModalCustomEvent<any>) => void;
79-
"onUpdateSessionRequest"?: (event: MmInstallModalCustomEvent<any>) => void;
83+
"onUpdateExpiresIn"?: (event: MmInstallModalCustomEvent<number>) => void;
84+
"onUpdateLink"?: (event: MmInstallModalCustomEvent<string>) => void;
8085
"preferDesktop"?: boolean;
8186
"sdkVersion"?: string;
82-
"sessionRequest"?: any;
8387
}
8488
interface MmOtpModal {
8589
/**

packages/sdk-multichain-ui/src/components/mm-install-modal/mm-install-modal.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import SVG from '../../assets/fox.svg'
2323
shadow: true,
2424
})
2525
export class InstallModal {
26-
@Prop() sessionRequest: any
26+
@Prop() link: string
27+
28+
@Prop() expiresIn: number;
2729

2830
@Prop() sdkVersion?: string;
2931

@@ -36,7 +38,9 @@ export class InstallModal {
3638

3739
@Event() startDesktopOnboarding: EventEmitter;
3840

39-
@Event() updateSessionRequest: EventEmitter;
41+
@Event() updateLink: EventEmitter<string>;
42+
43+
@Event() updateExpiresIn: EventEmitter<number>;
4044

4145
@Element() el: HTMLElement;
4246

@@ -51,7 +55,7 @@ export class InstallModal {
5155
}
5256

5357
componentDidLoad() {
54-
this.generateQRCode(this.sessionRequest);
58+
this.generateQRCode(this.link);
5559
}
5660

5761
async connectedCallback() {
@@ -61,13 +65,13 @@ export class InstallModal {
6165
this.translationsLoaded = true;
6266
}
6367

64-
private generateQRCode(sessionRequest: {id: string, expiresAt: number}) {
68+
private generateQRCode(data: string) {
6569
if (!this.qrCodeContainer) {
6670
return;
6771
}
6872
const options: Options = {
73+
data,
6974
type: 'svg' as DrawType,
70-
data: JSON.stringify(sessionRequest),
7175
image: SVG,
7276
imageOptions: {
7377
hideBackgroundDots: true,
@@ -110,9 +114,14 @@ export class InstallModal {
110114
this.startDesktopOnboarding.emit();
111115
}
112116

113-
@Watch('sessionRequest')
114-
updateSessionRequestHandler(sessionRequest:any) {
115-
this.generateQRCode(sessionRequest);
117+
@Watch('link')
118+
updateLinkHandler(link:string) {
119+
this.generateQRCode(link);
120+
}
121+
122+
@Watch('expiresIn')
123+
updateExpiresInHandler(expiresIn: number) {
124+
console.debug('QRCode expires in:', expiresIn);
116125
}
117126

118127
render() {

packages/sdk-multichain/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"dependencies": {
5656
"@metamask/mobile-wallet-protocol-core": "^0.1.0",
5757
"@metamask/mobile-wallet-protocol-dapp-client": "^0.1.0",
58-
"@metamask/multichain-api-client": "^0.6.5",
58+
"@metamask/multichain-api-client": "^0.8.0",
5959
"@metamask/onboarding": "^1.0.1",
6060
"@metamask/sdk-analytics": "workspace:^",
6161
"@metamask/sdk-multichain-ui": "workspace:^",

packages/sdk-multichain/src/connect.test.ts

Lines changed: 84 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** biome-ignore-all lint/suspicious/noExplicitAny: Tests require it */
22
/** biome-ignore-all lint/style/noNonNullAssertion: Tests require it */
33
import * as t from 'vitest';
4-
import type { MultiChainFNOptions, MultichainCore, Scope } from './domain';
4+
import type { MultichainOptions, MultichainCore, Scope } from './domain';
55
// Carefull, order of import matters to keep mocks working
66
import { runTestsInNodeEnv, type MockedData, mockSessionData, type TestSuiteOptions, runTestsInRNEnv, runTestsInWebEnv } from './fixtures.test';
77
import { Store } from './store';
@@ -38,7 +38,7 @@ async function expectUIFactoryRenderInstallModal(sdk: MultichainCore) {
3838
t.expect(onRenderInstallModal).toHaveBeenCalled();
3939
}
4040

41-
function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options: sdkOptions, ...options }: TestSuiteOptions<T>) {
41+
function testSuite<T extends MultichainOptions>({ platform, createSDK, options: sdkOptions, ...options }: TestSuiteOptions<T>) {
4242
const { beforeEach, afterEach } = options;
4343
const originalSdkOptions = sdkOptions;
4444
let sdk: MultichainCore;
@@ -85,7 +85,16 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
8585

8686
mockMultichainClient.getSession.mockResolvedValue(undefined);
8787
mockMultichainClient.createSession.mockResolvedValue(mockSessionData);
88-
88+
mockedData.mockTransport.request.mockImplementation((input: any) => {
89+
if (input.method === 'wallet_createSession') {
90+
return Promise.resolve({
91+
id: 1,
92+
jsonrpc: '2.0',
93+
result: mockSessionData,
94+
});
95+
}
96+
return Promise.reject(new Error('Forgot to mock this RPC call?'));
97+
});
8998
// Create a new SDK instance with the mock configured correctly
9099
const sdk = await createSDK(testOptions);
91100
const onConnectionSuccessSpy = t.vi.spyOn(sdk as any, 'onConnectionSuccess');
@@ -120,7 +129,7 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
120129
await showModalPromise;
121130

122131
// Should have unloaded the modal and calling successCallback
123-
t.expect(unloadSpy).toHaveBeenCalledWith(true);
132+
t.expect(unloadSpy).toHaveBeenCalledWith();
124133
t.expect(onConnectionSuccessSpy).toHaveBeenCalled();
125134
}
126135
await connectPromise;
@@ -135,23 +144,22 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
135144
t.expect(onConnectionSuccessSpy).toHaveBeenCalled();
136145
}
137146

138-
t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({
139-
optionalScopes: {
140-
'eip155:1': {
141-
methods: [],
142-
notifications: [],
143-
accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'],
147+
t.expect(mockedData.mockTransport.request).toHaveBeenCalledWith({
148+
method: 'wallet_createSession',
149+
params: {
150+
optionalScopes: {
151+
...mockSessionData.sessionScopes,
144152
},
145153
},
146154
});
147155

148156
mockedData.mockTransport.__triggerNotification({
149-
method: 'session_changed',
157+
method: 'wallet_sessionChanged',
150158
params: {
151159
session: mockSessionData,
152160
},
153161
});
154-
t.expect(mockedData.emitSpy).toHaveBeenCalledWith('session_changed', mockSessionData);
162+
t.expect(mockedData.emitSpy).toHaveBeenCalledWith('wallet_sessionChanged', mockSessionData);
155163
});
156164

157165
t.it(`${platform} should skip transport connection when already connected`, async () => {
@@ -160,7 +168,17 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
160168
const mockMultichainClient = (multichainModule as any).__mockMultichainClient;
161169

162170
mockedData.nativeStorageStub.setItem('multichain-transport', transportString);
163-
171+
mockedData.mockTransport.isConnected.mockReturnValue(true);
172+
mockedData.mockTransport.request.mockImplementation((input: any) => {
173+
if (input.method === 'wallet_getSession') {
174+
return Promise.resolve({
175+
id: 1,
176+
jsonrpc: '2.0',
177+
result: mockSessionData,
178+
});
179+
}
180+
return Promise.reject(new Error('Forgot to mock this RPC call?'));
181+
});
164182
mockMultichainClient.getSession.mockResolvedValue(mockSessionData);
165183

166184
const scopes = ['eip155:1'] as Scope[];
@@ -170,7 +188,7 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
170188
t.expect(sdk.provider).toBeDefined();
171189
t.expect(sdk.transport).toBeDefined();
172190
t.expect(sdk.storage).toBeDefined();
173-
t.expect(mockedData.mockTransport.connect).toHaveBeenCalled();
191+
t.expect(mockedData.mockTransport.connect).not.toHaveBeenCalled();
174192
mockedData.mockTransport.connect.mockReset();
175193

176194
await sdk.connect(scopes, caipAccountIds);
@@ -185,7 +203,16 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
185203
const mockMultichainClient = (multichainModule as any).__mockMultichainClient;
186204

187205
mockMultichainClient.getSession.mockResolvedValue(undefined);
188-
206+
mockedData.mockTransport.request.mockImplementation((input: any) => {
207+
if (input.method === 'wallet_getSession') {
208+
return Promise.resolve({
209+
id: 1,
210+
jsonrpc: '2.0',
211+
result: mockSessionData,
212+
});
213+
}
214+
return Promise.reject(new Error('Forgot to mock this RPC call?'));
215+
});
189216
// Mock console.error to capture invalid account ID errors
190217

191218
const scopes = ['eip155:1'] as Scope[];
@@ -215,7 +242,7 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
215242
await showModalPromise;
216243

217244
// Should have unloaded the modal and calling successCallback
218-
t.expect(unloadSpy).toHaveBeenCalledWith(true);
245+
t.expect(unloadSpy).toHaveBeenCalledWith();
219246
t.expect(onConnectionSuccessSpy).toHaveBeenCalled();
220247
}
221248
await connectPromise;
@@ -227,12 +254,15 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
227254
}
228255

229256
t.expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid CAIP account ID: "invalid-account-id"', t.expect.any(Error));
230-
t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({
231-
optionalScopes: {
232-
'eip155:1': {
233-
methods: [],
234-
notifications: [],
235-
accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'],
257+
t.expect(mockedData.mockTransport.request).toHaveBeenCalledWith({
258+
method: 'wallet_createSession',
259+
params: {
260+
optionalScopes: {
261+
'eip155:1': {
262+
methods: [],
263+
notifications: [],
264+
accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'],
265+
},
236266
},
237267
},
238268
});
@@ -256,10 +286,23 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
256286
const multichainModule = await import('@metamask/multichain-api-client');
257287
const mockMultichainClient = (multichainModule as any).__mockMultichainClient;
258288

259-
mockMultichainClient.getSession.mockResolvedValue(undefined);
260289
const sessionError = new Error('Failed to create session');
290+
291+
mockMultichainClient.getSession.mockResolvedValue(undefined);
261292
mockMultichainClient.createSession.mockRejectedValue(sessionError);
262293

294+
mockedData.mockTransport.request.mockImplementation((input: any) => {
295+
if (input.method === 'wallet_getSession') {
296+
return Promise.resolve({ id: 1, jsonrpc: '2.0', result: undefined });
297+
}
298+
if (input.method === 'wallet_revokeSession') {
299+
return Promise.resolve({ id: 1, jsonrpc: '2.0', result: mockSessionData });
300+
}
301+
if (input.method === 'wallet_createSession') {
302+
return Promise.reject(sessionError);
303+
}
304+
return Promise.reject(new Error('Forgot to mock this RPC call?'));
305+
});
263306
const scopes = ['eip155:1'] as Scope[];
264307
const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any;
265308
sdk = await createSDK(testOptions);
@@ -287,7 +330,7 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
287330
await showModalPromise;
288331

289332
// Should have unloaded the modal and calling successCallback
290-
t.expect(unloadSpy).toHaveBeenCalledWith(true);
333+
t.expect(unloadSpy).toHaveBeenCalledWith();
291334
t.expect(onConnectionSuccessSpy).toHaveBeenCalled();
292335
}
293336
await connectPromise;
@@ -297,10 +340,6 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
297340
});
298341

299342
t.it(`${platform} should handle session revocation errors on session upgrade`, async () => {
300-
// Get mocks from the module mock
301-
const multichainModule = await import('@metamask/multichain-api-client');
302-
const mockMultichainClient = (multichainModule as any).__mockMultichainClient;
303-
304343
const existingSessionData = {
305344
...mockSessionData,
306345
sessionScopes: {
@@ -312,10 +351,22 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
312351
},
313352
};
314353

315-
mockMultichainClient.getSession.mockResolvedValue(existingSessionData);
316-
317354
const revocationError = new Error('Failed to revoke session');
318-
mockMultichainClient.revokeSession.mockRejectedValue(revocationError);
355+
356+
mockedData.mockTransport.request.mockImplementation((input: any) => {
357+
if (input.method === 'wallet_getSession') {
358+
return Promise.resolve({
359+
id: 1,
360+
jsonrpc: '2.0',
361+
result: existingSessionData,
362+
});
363+
}
364+
365+
if (input.method === 'wallet_revokeSession') {
366+
return Promise.reject(revocationError);
367+
}
368+
return Promise.reject(new Error('Forgot to mock this RPC call?'));
369+
});
319370

320371
const scopes = ['eip155:137'] as Scope[]; // Same scope as existing session to trigger revocation
321372
const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any;
@@ -344,7 +395,7 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
344395
await showModalPromise;
345396

346397
// Should have unloaded the modal and calling successCallback
347-
t.expect(unloadSpy).toHaveBeenCalledWith(true);
398+
t.expect(unloadSpy).toHaveBeenCalledWith();
348399
t.expect(onConnectionSuccessSpy).toHaveBeenCalled();
349400
}
350401
await connectPromise;
@@ -399,7 +450,7 @@ function testSuite<T extends MultiChainFNOptions>({ platform, createSDK, options
399450
await showModalPromise;
400451

401452
// Should have unloaded the modal and calling successCallback
402-
t.expect(unloadSpy).toHaveBeenCalledWith(true);
453+
t.expect(unloadSpy).toHaveBeenCalledWith();
403454
t.expect(onConnectionSuccessSpy).toHaveBeenCalled();
404455
}
405456
await connectPromise;

packages/sdk-multichain/src/domain/events/types/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import type { SessionData } from '@metamask/multichain-api-client';
22

33
export type SDKEvents = {
44
display_uri: [evt: string];
5-
session_changed: [evt: SessionData | undefined];
5+
wallet_sessionChanged: [evt: SessionData | undefined];
6+
[key: string]: [evt: unknown];
67
};
78

89
export type EventTypes = SDKEvents;

packages/sdk-multichain/src/domain/multichain/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { StoreClient } from '../store/client';
55
import type { InvokeMethodOptions, RPCAPI, Scope } from './api/types';
66
import type { MultichainOptions } from './types';
77

8-
export type SDKState = 'pending' | 'loaded' | 'disconnected' | 'connected';
8+
export type SDKState = 'pending' | 'loaded' | 'disconnected' | 'connected' | 'connecting';
99

1010
export enum TransportType {
1111
Browser = 'browser',

0 commit comments

Comments
 (0)