Skip to content

Commit 2078f0d

Browse files
committed
refactor: move Device initialization to constructor
1 parent 08b6fa0 commit 2078f0d

4 files changed

Lines changed: 138 additions & 99 deletions

File tree

packages/keyring-eth-qr/jest.config.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ module.exports = merge(baseConfig, {
2020
// An object that configures minimum threshold enforcement for coverage results
2121
coverageThreshold: {
2222
global: {
23-
branches: 85.71,
23+
branches: 91.8,
2424
functions: 100,
25-
lines: 93.26,
26-
statements: 93.39,
25+
lines: 95.04,
26+
statements: 95.14,
2727
},
2828
},
2929
});

packages/keyring-eth-qr/src/device.ts

Lines changed: 47 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@ export type IndexedAddress = {
121121
export type DeviceDetails = CommonDeviceDetails &
122122
(HDModeDeviceDetails | AccountModeDeviceDetails);
123123

124+
export type DeviceOptions = {
125+
/**
126+
* The requestScan function to scan the QR code
127+
*/
128+
requestScan: QrKeyringBridge['requestScan'];
129+
/**
130+
* The source of the device, which can be of type `DeviceDetails`,
131+
* `string`, or `SerializedUR`.
132+
*
133+
* When a `string` or `SerializedUR` is provided, the Device will
134+
* initialize itself from the UR.
135+
*/
136+
source: DeviceDetails | string | SerializedUR;
137+
};
138+
124139
/**
125140
* Get the fingerprint of the source CryptoAccount or CryptoHDKey
126141
*
@@ -189,32 +204,23 @@ function readCryptoAccountOutputDescriptors(source: CryptoAccount): {
189204
export class Device {
190205
readonly #requestScan: QrKeyringBridge['requestScan'];
191206

192-
#pairedDevice?: DeviceDetails | undefined;
193-
194-
constructor(requestScan: QrKeyringBridge['requestScan']) {
195-
this.#requestScan = requestScan;
196-
}
207+
readonly #pairedDevice: DeviceDetails;
197208

198209
/**
199-
* Initialize the Device with a source.
210+
* Create a new Device instance.
200211
*
201-
* @param source - The account source, which can be a CryptoAccount, CryptoHDKey, or a UR string.
212+
* @param options - The options for the Device, including the requestScan function
213+
* and the source of the device details.
214+
* @param options.requestScan - The function to request a scan of the QR code.
215+
* @param options.source - The source of the device details, which can be a
216+
* UR string, a `SerializedUR`, or a `DeviceDetails` object.
202217
*/
203-
init(source: DeviceDetails | string | SerializedUR): void {
204-
if (typeof source === 'string' || 'cbor' in source) {
205-
this.#initFromUR(source);
206-
} else {
207-
this.#pairedDevice = source;
208-
}
209-
}
210-
211-
/**
212-
* Check if this class instance is paired with a device
213-
*
214-
* @returns True if the Device is initialized, false otherwise
215-
*/
216-
isInitialized(): boolean {
217-
return this.#pairedDevice !== undefined;
218+
constructor({ requestScan, source }: DeviceOptions) {
219+
this.#requestScan = requestScan;
220+
this.#pairedDevice =
221+
typeof source === 'string' || 'cbor' in source
222+
? this.#deviceDetailsFromUR(source)
223+
: source;
218224
}
219225

220226
/**
@@ -225,10 +231,6 @@ export class Device {
225231
* @throws Will throw an error if the source is not initialized
226232
*/
227233
addressFromIndex(index: number): Hex {
228-
if (!this.#pairedDevice) {
229-
throw new Error('Device not paired');
230-
}
231-
232234
if (this.#pairedDevice.keyringMode === DeviceMode.ACCOUNT) {
233235
const address = Object.keys(this.#pairedDevice.paths)[index];
234236
if (!address) {
@@ -262,10 +264,6 @@ export class Device {
262264
* @returns The path of the address
263265
*/
264266
pathFromAddress(address: Hex): string {
265-
if (!this.#pairedDevice) {
266-
throw new Error('UR not initialized');
267-
}
268-
269267
const normalizedAddress = getChecksumAddress(add0x(address));
270268

271269
if (this.#pairedDevice.keyringMode === DeviceMode.ACCOUNT) {
@@ -289,10 +287,6 @@ export class Device {
289287
* @returns The index of the address
290288
*/
291289
indexFromAddress(address: Hex): number {
292-
if (!this.#pairedDevice) {
293-
throw new Error('UR not initialized');
294-
}
295-
296290
const cachedIndex = this.#pairedDevice.indexes[address];
297291
if (cachedIndex !== undefined) {
298292
return Number(cachedIndex);
@@ -344,21 +338,14 @@ export class Device {
344338
}
345339

346340
/**
347-
* Retrieve the details of the paired device, if any.
341+
* Retrieve the details of the paired device.
348342
*
349-
* @returns Thea paired device details, or undefined if not initialized
343+
* @returns Thea paired device details
350344
*/
351-
getDeviceDetails(): DeviceDetails | undefined {
345+
getDeviceDetails(): DeviceDetails {
352346
return this.#pairedDevice;
353347
}
354348

355-
/**
356-
* Clear the paired device details.
357-
*/
358-
clear(): void {
359-
this.#pairedDevice = undefined;
360-
}
361-
362349
/**
363350
* Sign a transaction. This is equivalent to the `eth_signTransaction`
364351
* Ethereum JSON-RPC method. See the Ethereum JSON-RPC API documentation for
@@ -372,10 +359,6 @@ export class Device {
372359
address: Hex,
373360
transaction: TypedTransaction,
374361
): Promise<TypedTxData> {
375-
if (!this.#pairedDevice?.xfp) {
376-
throw new Error('No device paired.');
377-
}
378-
379362
const dataType =
380363
transaction.type === TransactionType.Legacy
381364
? DataType.transaction
@@ -433,10 +416,6 @@ export class Device {
433416
address: Hex,
434417
data: TypedMessage<Types>,
435418
): Promise<string> {
436-
if (!this.#pairedDevice?.xfp) {
437-
throw new Error('No device paired.');
438-
}
439-
440419
const requestId = uuidv4();
441420
const ethSignRequestUR = EthSignRequest.constructETHRequest(
442421
Buffer.from(JSON.stringify(data), 'utf8'),
@@ -478,10 +457,6 @@ export class Device {
478457
* @returns The signed message.
479458
*/
480459
async signPersonalMessage(address: Hex, message: Hex): Promise<string> {
481-
if (!this.#pairedDevice?.xfp) {
482-
throw new Error('No device paired.');
483-
}
484-
485460
const requestId = uuidv4();
486461
const ethSignRequestUR = EthSignRequest.constructETHRequest(
487462
Buffer.from(remove0x(message), 'hex'),
@@ -511,38 +486,39 @@ export class Device {
511486
}
512487

513488
/**
514-
* Set the paired device from a UR string
489+
* Derive the device details from a UR string
515490
*
516491
* @param ur - The UR string to set the root account from
492+
* @returns The device details derived from the UR
517493
*/
518-
#initFromUR(ur: string | SerializedUR): void {
494+
#deviceDetailsFromUR(ur: string | SerializedUR): DeviceDetails {
519495
const source = this.#decodeUR(ur);
520496
const fingerprint = getFingerprintFromSource(source);
521497

522498
if (source instanceof CryptoAccount) {
523499
const { name, xfp, paths, keyringAccount } =
524500
readCryptoAccountOutputDescriptors(source);
525-
this.#pairedDevice = {
501+
return {
526502
keyringMode: DeviceMode.ACCOUNT,
527503
keyringAccount,
528504
name,
529505
xfp,
530506
paths,
531507
indexes: {},
532508
};
533-
} else {
534-
const { getBip32Key, getOrigin, getChildren, getName, getNote } = source;
535-
this.#pairedDevice = {
536-
keyringMode: DeviceMode.HD,
537-
keyringAccount: getNote(),
538-
name: getName(),
539-
xfp: fingerprint,
540-
hdPath: `m/${getOrigin().getPath()}`,
541-
childrenPath: getChildren()?.getPath() || DEFAULT_CHILDREN_PATH,
542-
xpub: getBip32Key(),
543-
indexes: {},
544-
};
545509
}
510+
511+
const { getBip32Key, getOrigin, getChildren, getName, getNote } = source;
512+
return {
513+
keyringMode: DeviceMode.HD,
514+
keyringAccount: getNote(),
515+
name: getName(),
516+
xfp: fingerprint,
517+
hdPath: `m/${getOrigin().getPath()}`,
518+
childrenPath: getChildren()?.getPath() || DEFAULT_CHILDREN_PATH,
519+
xpub: getBip32Key(),
520+
indexes: {},
521+
};
546522
}
547523

548524
/**

packages/keyring-eth-qr/src/qr-keyring.test.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,23 @@ function getMockBridge(): QrKeyringBridge {
6363
*
6464
* @param bridge - The QrKeyringBridge instance to mock.
6565
* @param signature - The signature to return in the mocked scan resolution.
66+
* @param requestId - Optional request ID to use in the signature UR.
6667
*/
6768
function mockBridgeScanResolution(
6869
bridge: QrKeyringBridge,
6970
signature: string,
71+
requestId?: string | null,
7072
): void {
7173
jest.spyOn(bridge, 'requestScan').mockImplementationOnce(async (request) => {
7274
const signatureUR = new ETHSignature(
7375
Buffer.from(signature, 'hex'),
74-
Buffer.from(
75-
Uint8Array.from(uuid.parse(request.request?.requestId ?? '')),
76-
),
76+
requestId === null
77+
? undefined
78+
: Buffer.from(
79+
Uint8Array.from(
80+
uuid.parse(requestId ?? request.request?.requestId ?? ''),
81+
),
82+
),
7783
).toUR();
7884
return {
7985
type: signatureUR.type,
@@ -332,7 +338,7 @@ describe('QrKeyring', () => {
332338
});
333339

334340
await expect(keyring.addAccounts(1)).rejects.toThrow(
335-
'Device not paired',
341+
'No device paired.',
336342
);
337343
});
338344
});
@@ -440,13 +446,13 @@ describe('QrKeyring', () => {
440446
});
441447
});
442448

443-
describe('submitUR', () => {
444-
it('initializes the QrKeyring with a UR', async () => {
449+
describe('pairDevice', () => {
450+
it('pairs a device to the QrKeyring with a UR', async () => {
445451
const keyring = new QrKeyring({
446452
bridge: getMockBridge(),
447453
});
448454

449-
keyring.submitUR(KNOWN_HDKEY_UR);
455+
keyring.pairDevice(KNOWN_HDKEY_UR);
450456

451457
expect(await getXPUBFromKeyring(keyring)).toStrictEqual(
452458
SERIALIZED_KEYSTONE_KEYRING.xpub,
@@ -458,7 +464,7 @@ describe('QrKeyring', () => {
458464
bridge: getMockBridge(),
459465
});
460466

461-
expect(() => keyring.submitUR('invalid-ur')).toThrow('Invalid Scheme');
467+
expect(() => keyring.pairDevice('invalid-ur')).toThrow('Invalid Scheme');
462468
});
463469

464470
it('throws an error if the UR is not of type `crypto-hdkey` or `crypto-account`', () => {
@@ -467,7 +473,7 @@ describe('QrKeyring', () => {
467473
});
468474

469475
expect(() =>
470-
keyring.submitUR({
476+
keyring.pairDevice({
471477
type: 'invalid-type',
472478
cbor: 'a0',
473479
}),
@@ -711,6 +717,38 @@ describe('QrKeyring', () => {
711717
expect(signedTx.to).toStrictEqual(LEGACY_TRANSACTION.to);
712718
expect(signedTx.nonce).toBe(LEGACY_TRANSACTION.nonce);
713719
});
720+
721+
it('throws an error if the signature scan `requestId` is wrong', async () => {
722+
const keyring = new QrKeyring({
723+
bridge: getMockBridge(),
724+
ur,
725+
});
726+
mockBridgeScanResolution(
727+
keyring.bridge,
728+
'33ea4c1dc4b201ad1b1feaf172aadf60dcf2f8bd76d941396bfaebfc3b2868b0340d5689341925c99cdea39e3c5daf7fe2776f220e5b018e85d3b1df19c7bc4701',
729+
'92fdd736-e03e-4650-9992-ff3c70f16f4e',
730+
);
731+
732+
await expect(
733+
keyring.signTransaction(from, TRANSACTION),
734+
).rejects.toThrow(/Signature request ID mismatch./u);
735+
});
736+
737+
it('throws an error if the signature scan `requestId` is missing', async () => {
738+
const keyring = new QrKeyring({
739+
bridge: getMockBridge(),
740+
ur,
741+
});
742+
mockBridgeScanResolution(
743+
keyring.bridge,
744+
'33ea4c1dc4b201ad1b1feaf172aadf60dcf2f8bd76d941396bfaebfc3b2868b0340d5689341925c99cdea39e3c5daf7fe2776f220e5b018e85d3b1df19c7bc4701',
745+
null,
746+
);
747+
748+
await expect(
749+
keyring.signTransaction(from, TRANSACTION),
750+
).rejects.toThrow('Signature request ID is missing.');
751+
});
714752
},
715753
);
716754
});

0 commit comments

Comments
 (0)