@exodus/hardware-wallets 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # @exodus/hardware-wallets
2
+
3
+ This feature is a high level abstraction for interacting with hardware wallet devices.
4
+
5
+ | Feature | Supported |
6
+ | :------------------: | :-------: |
7
+ | Syncing Public Keys | ✅ |
8
+ | Signing Transactions | ✅ |
9
+ | Signing Messages | ✅ |
10
+ | Firmware Management | ❌ |
11
+
12
+ ## Device Support Matrix
13
+
14
+ | Manufacturer | Device Model | Supported |
15
+ | :----------: | :----------: | :-------: |
16
+ | Ledger | Nano S | ✅ |
17
+ | Ledger | Nano S+ | ✅ |
18
+ | Ledger | Nano X | ✅ |
19
+ | Ledger | Stax | ✅ |
20
+ | Trezor | One | ❌ |
21
+ | Trezor | T | ❌ |
22
+ | Trezor | Safe 3 | ❌ |
23
+ | Trezor | Safe 5 | ❌ |
24
+
25
+ ## Usage
26
+
27
+ ### With IoC
28
+
29
+ ```typescript
30
+ import hardwareWallets from '@exodus/hardware-wallets'
31
+
32
+ ioc.use(hardwareWallets())
33
+ ```
34
+
35
+ ## Architecture
36
+
37
+ ### Terminology
38
+
39
+ A bit of terminology might be required to properly understand the distinctions between the various "states" of an asset.
40
+
41
+ - **supported** assets: All assets supported by our implementation for the device.
42
+ - **installed** assets: All assets supported by our implementation AND installed on the device by the user.
43
+ - **useable** assets: All assets supported by our implementation, installed on the device AND opened on the device (ledger).
44
+ - **synced** assets: All assets for which the public keys have been synced to the wallet account fusion channel.
45
+
46
+ ### `scan({ assetName, accountIndexes, addressLimit, addressOffset })`
47
+
48
+ The `scan` functions is used during onboarding to retrieve the addresses (n=2) starting at `addressOffset` for each account index in `accountIndexes` from the hardware wallet device. It retrieves the corresponding balance using an network API call. Calling this function does not make any permanent state changes and all its state is ephemeral. This function serves to allow the user to "scan" the accounts, their addresses and balances without having to commit to an account index & syncing it completely yet.
49
+
50
+ > [!CAUTION]
51
+ > This function must only be used during onboarding of a hardware wallet device.
52
+
53
+ ```mermaid
54
+ sequenceDiagram
55
+ Consumer->>+HardwareWallets: scan({ assetName, accountIndexes, addressOffset })
56
+ HardwareWallets->>+HardwareWalletDevice: getAddress({ assetName, derivationPath })
57
+ HardwareWalletDevice-->>-HardwareWallets: address0
58
+ HardwareWallets->>+HardwareWalletDevice: getAddress({ assetName, derivationPath })
59
+ HardwareWalletDevice-->>-HardwareWallets: address1
60
+ HardwareWallets->>+Asset: api.getBalanceForAddress(address0)
61
+ Asset-->>-HardwareWallets: balance0
62
+ HardwareWallets->>+Asset: api.getBalanceForAddress(address1)
63
+ Asset-->>-HardwareWallets: balance1
64
+ HardwareWallets-->>-Consumer: First N addresses with balance <br> for given account indexes
65
+
66
+ ```
67
+
68
+ ### `sync({ accountIndex })`
69
+
70
+ Synchronizes public keys (XPUBs / public keys) for all useable assets for a given `accountIndex` from a hardware wallet device and stores them temporarily for future use. The hardware wallet device may disconnect after this method has been called and onboarding would still be possible.
71
+
72
+ > [!CAUTION]
73
+ > Only a subset of the supported assets may be synced. Some hardware wallet devices like Ledger do not support multiple assets at the same time because the asset-specific application must be installed and opened on the device.
74
+
75
+ ```mermaid
76
+ sequenceDiagram
77
+ participant Consumer
78
+ participant HardwareWallets
79
+ participant Device
80
+ participant BaseAsset
81
+
82
+ Consumer->>HardwareWallets: sync({ accountIndex })
83
+ activate HardwareWallets
84
+
85
+ HardwareWallets->>Device: listUseableAssetNames()
86
+ activate Device
87
+ Device-->>HardwareWallets: [assetName0, assetName1, ...]
88
+ deactivate Device
89
+
90
+ loop for each assetName
91
+ HardwareWallets->>BaseAsset: getSupportedPurposes({ compatibilityMode: 'ledger' })
92
+ activate BaseAsset
93
+ BaseAsset-->>HardwareWallets: [purpose0, purpose1, ...]
94
+ deactivate BaseAsset
95
+
96
+ loop for each purpose
97
+ alt getXPub is supported
98
+ HardwareWallets->>Device: getXPub({ assetName, derivationPath })
99
+ activate Device
100
+ Device-->>HardwareWallets: xpub
101
+ deactivate Device
102
+ HardwareWallets->>HardwareWallets: Store keyIdentifier and xpub
103
+ else getPublicKey instead
104
+ HardwareWallets->>Device: getPublicKey({ assetName, derivationPath })
105
+ activate Device
106
+ Device-->>HardwareWallets: publicKey
107
+ deactivate Device
108
+ HardwareWallets->>HardwareWallets: Store keyIdentifier and publicKey
109
+ end
110
+ end
111
+ end
112
+
113
+ HardwareWallets->>HardwareWallets: Generate random ID
114
+ HardwareWallets->>HardwareWallets: Store keys in syncedKeysMap with ID
115
+
116
+ HardwareWallets-->>Consumer: SyncedKeysId
117
+ deactivate HardwareWallets
118
+
119
+ ```
120
+
121
+ ### `addPublicKeysToWalletAccount({ walletAccountName, syncedKeysId })`
122
+
123
+ Adds public keys (XPUBS / public keys) to an existing wallet account.
124
+
125
+ - Moves the synchronized public keys (XPUBs / public keys) for `syncedKeysId` identifier from the temporary in-memory objectmap to a `walletAccounts`'s public key store which will store it in fusion.
126
+ - Start a restore procedure for the assets & trigger a refresh of the `txLogMonitor`
127
+
128
+ ```mermaid
129
+ sequenceDiagram
130
+ participant Consumer
131
+ participant HardwareWallets
132
+ participant PublicKeyStore
133
+ participant RestoreProgressTracker
134
+ participant TxLogMonitors
135
+
136
+ Consumer->>HardwareWallets: addPublicKeysToWalletAccount({ walletAccountName, syncedKeysId })
137
+ activate HardwareWallets
138
+
139
+ HardwareWallets->>HardwareWallets: Retrieve keys from syncedKeysMap using syncedKeysId
140
+ Note right of HardwareWallets: Retrieves assetNames and keysToSync
141
+
142
+ loop for each key in keysToSync
143
+ HardwareWallets->>PublicKeyStore: add({ walletAccountName, keyIdentifier, xpub, publicKey })
144
+
145
+ end
146
+
147
+ loop for each assetName in assetNames
148
+ HardwareWallets->>RestoreProgressTracker: restoreAsset(assetName)
149
+ HardwareWallets->>TxLogMonitors: update({ assetName, refresh: true })
150
+ end
151
+
152
+ deactivate HardwareWallets
153
+ HardwareWallets-->>Consumer: void
154
+ ```
155
+
156
+ ### `create({ syncedKeysId })`
157
+
158
+ Creates a new hardware wallet account and calls `addPublicKeysToWalletAccount()` to synchronize public keys to the newly created wallet account.
@@ -0,0 +1,21 @@
1
+ import type { HardwareWallets } from '../module/hardware-wallets.js';
2
+ declare const hardwareWalletsApiDefinition: {
3
+ readonly id: "hardwareWalletsApi";
4
+ readonly type: "api";
5
+ readonly factory: ({ hardwareWallets }: {
6
+ hardwareWallets: HardwareWallets;
7
+ }) => {
8
+ hardwareWallets: {
9
+ isDeviceConnected: () => Promise<boolean>;
10
+ listUseableAssetNames: () => Promise<string[]>;
11
+ canAccessAsset: ({ assetName }: import("../module/interfaces.js").CanAccessAssetParams) => Promise<boolean>;
12
+ ensureApplicationIsOpened: ({ assetName }: import("../module/interfaces.js").EnsureApplicationIsOpenedParams) => Promise<void>;
13
+ scan: ({ assetName, accountIndexes, addressLimit, addressOffset, }: import("../module/interfaces.js").ScanParams) => Promise<import("../module/interfaces.js").ScanResult>;
14
+ sync: ({ accountIndex }: import("../module/interfaces.js").SyncParams) => Promise<import("../module/interfaces.js").SyncedKeysId>;
15
+ addPublicKeysToWalletAccount: ({ walletAccountName, syncedKeysId, }: import("../module/interfaces.js").StoreSyncedKeysParams) => Promise<void>;
16
+ create: ({ syncedKeysId }: import("../module/interfaces.js").CreateParams) => Promise<void>;
17
+ };
18
+ };
19
+ readonly dependencies: readonly ["hardwareWallets"];
20
+ };
21
+ export default hardwareWalletsApiDefinition;
@@ -0,0 +1,21 @@
1
+ const createHardwareWalletsApi = ({ hardwareWallets }) => {
2
+ return {
3
+ hardwareWallets: {
4
+ isDeviceConnected: hardwareWallets.isDeviceConnected,
5
+ listUseableAssetNames: hardwareWallets.listUseableAssetNames,
6
+ canAccessAsset: hardwareWallets.canAccessAsset,
7
+ ensureApplicationIsOpened: hardwareWallets.ensureApplicationIsOpened,
8
+ scan: hardwareWallets.scan,
9
+ sync: hardwareWallets.sync,
10
+ addPublicKeysToWalletAccount: hardwareWallets.addPublicKeysToWalletAccount,
11
+ create: hardwareWallets.create,
12
+ },
13
+ };
14
+ };
15
+ const hardwareWalletsApiDefinition = {
16
+ id: 'hardwareWalletsApi',
17
+ type: 'api',
18
+ factory: createHardwareWalletsApi,
19
+ dependencies: ['hardwareWallets'],
20
+ };
21
+ export default hardwareWalletsApiDefinition;
package/lib/index.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ declare const hardwareWallets: () => {
2
+ readonly id: "hardwareWallets";
3
+ readonly definitions: readonly [{
4
+ readonly definition: {
5
+ readonly id: "hardwareWalletsApi";
6
+ readonly type: "api";
7
+ readonly factory: ({ hardwareWallets }: {
8
+ hardwareWallets: import("./module/hardware-wallets.js").HardwareWallets;
9
+ }) => {
10
+ hardwareWallets: {
11
+ isDeviceConnected: () => Promise<boolean>;
12
+ listUseableAssetNames: () => Promise<string[]>;
13
+ canAccessAsset: ({ assetName }: import("./module/interfaces.js").CanAccessAssetParams) => Promise<boolean>;
14
+ ensureApplicationIsOpened: ({ assetName }: import("./module/interfaces.js").EnsureApplicationIsOpenedParams) => Promise<void>;
15
+ scan: ({ assetName, accountIndexes, addressLimit, addressOffset, }: import("./module/interfaces.js").ScanParams) => Promise<import("./module/interfaces.js").ScanResult>;
16
+ sync: ({ accountIndex }: import("./module/interfaces.js").SyncParams) => Promise<import("./module/interfaces.js").SyncedKeysId>;
17
+ addPublicKeysToWalletAccount: ({ walletAccountName, syncedKeysId, }: import("./module/interfaces.js").StoreSyncedKeysParams) => Promise<void>;
18
+ create: ({ syncedKeysId }: import("./module/interfaces.js").CreateParams) => Promise<void>;
19
+ };
20
+ };
21
+ readonly dependencies: readonly ["hardwareWallets"];
22
+ };
23
+ }, {
24
+ readonly definition: {
25
+ readonly id: "hardwareWallets";
26
+ readonly type: "module";
27
+ readonly factory: (opts: import("./module/hardware-wallets.js").Dependencies) => import("./module/hardware-wallets.js").HardwareWallets;
28
+ readonly dependencies: readonly ["assetsModule", "logger", "ledgerDiscovery", "userInterface", "publicKeyStore", "wallet", "walletAccountsAtom", "walletAccounts", "txLogMonitors", "restoreProgressTracker"];
29
+ };
30
+ }];
31
+ };
32
+ export default hardwareWallets;
package/lib/index.js ADDED
@@ -0,0 +1,16 @@
1
+ import hardwareWalletsApiDefinition from './api/index.js';
2
+ import hardwareWalletsModuleDefinition from './module/hardware-wallets.js';
3
+ const hardwareWallets = () => {
4
+ return {
5
+ id: 'hardwareWallets',
6
+ definitions: [
7
+ {
8
+ definition: hardwareWalletsApiDefinition,
9
+ },
10
+ {
11
+ definition: hardwareWalletsModuleDefinition,
12
+ },
13
+ ],
14
+ };
15
+ };
16
+ export default hardwareWallets;
@@ -0,0 +1,4 @@
1
+ export declare class UserCancelledError extends Error {
2
+ name: string;
3
+ constructor();
4
+ }
@@ -0,0 +1,6 @@
1
+ export class UserCancelledError extends Error {
2
+ name = 'UserCancelledError';
3
+ constructor() {
4
+ super('User cancelled action');
5
+ }
6
+ }
@@ -0,0 +1,39 @@
1
+ import { WalletAccount } from '@exodus/models';
2
+ import type { HardwareSignerProvider, CanAccessAssetParams, CreateParams, StoreSyncedKeysParams, ScanParams, SyncParams, EnsureApplicationIsOpenedParams, SignTransactionParams, ScanResult, SyncedKeysId } from './interfaces.js';
3
+ import type { HardwareWalletDiscovery, SignMessageParams } from '@exodus/hw-common';
4
+ import type { Atom } from '@exodus/atoms';
5
+ import type { IPublicKeyStore } from '@exodus/public-key-store';
6
+ import type { Logger } from '@exodus/logger';
7
+ export type Dependencies = {
8
+ assetsModule: any;
9
+ ledgerDiscovery: HardwareWalletDiscovery;
10
+ logger: Logger;
11
+ userInterface: any;
12
+ publicKeyStore: IPublicKeyStore;
13
+ wallet: any;
14
+ walletAccountsAtom: Atom<WalletAccount>;
15
+ walletAccounts: any;
16
+ txLogMonitors: any;
17
+ restoreProgressTracker: any;
18
+ };
19
+ export declare class HardwareWallets implements HardwareSignerProvider {
20
+ #private;
21
+ constructor({ assetsModule, ledgerDiscovery, logger, userInterface, publicKeyStore, wallet, walletAccountsAtom, walletAccounts, txLogMonitors, restoreProgressTracker, }: Dependencies);
22
+ signTransaction: ({ baseAssetName, unsignedTx, walletAccount }: SignTransactionParams) => Promise<any>;
23
+ signMessage: ({ assetName, derivationPath, message }: SignMessageParams) => Promise<any>;
24
+ isDeviceConnected: () => Promise<boolean>;
25
+ canAccessAsset: ({ assetName }: CanAccessAssetParams) => Promise<boolean>;
26
+ listUseableAssetNames: () => Promise<string[]>;
27
+ ensureApplicationIsOpened: ({ assetName }: EnsureApplicationIsOpenedParams) => Promise<void>;
28
+ scan: ({ assetName, accountIndexes, addressLimit, addressOffset, }: ScanParams) => Promise<ScanResult>;
29
+ sync: ({ accountIndex }: SyncParams) => Promise<SyncedKeysId>;
30
+ addPublicKeysToWalletAccount: ({ walletAccountName, syncedKeysId, }: StoreSyncedKeysParams) => Promise<void>;
31
+ create: ({ syncedKeysId }: CreateParams) => Promise<void>;
32
+ }
33
+ declare const hardwareWalletsModuleDefinition: {
34
+ readonly id: "hardwareWallets";
35
+ readonly type: "module";
36
+ readonly factory: (opts: Dependencies) => HardwareWallets;
37
+ readonly dependencies: readonly ["assetsModule", "logger", "ledgerDiscovery", "userInterface", "publicKeyStore", "wallet", "walletAccountsAtom", "walletAccounts", "txLogMonitors", "restoreProgressTracker"];
38
+ };
39
+ export default hardwareWalletsModuleDefinition;
@@ -0,0 +1,326 @@
1
+ import assert from 'minimalistic-assert';
2
+ import { WalletAccount } from '@exodus/models';
3
+ import randomBytes from 'randombytes';
4
+ import delay from 'delay';
5
+ import { UserCancelledError } from './errors.js';
6
+ export class HardwareWallets {
7
+ #assetsModule;
8
+ #ledgerDiscovery;
9
+ #logger;
10
+ #userInterface;
11
+ #publicKeyStore;
12
+ #wallet;
13
+ #walletAccountsAtom;
14
+ #walletAccounts;
15
+ #txLogMonitors;
16
+ #restoreProgressTracker;
17
+ #syncedKeysMap = new Map();
18
+ constructor({ assetsModule, ledgerDiscovery, logger, userInterface, publicKeyStore, wallet, walletAccountsAtom, walletAccounts, txLogMonitors, restoreProgressTracker, }) {
19
+ this.#assetsModule = assetsModule;
20
+ this.#ledgerDiscovery = ledgerDiscovery;
21
+ this.#logger = logger;
22
+ this.#userInterface = userInterface;
23
+ this.#publicKeyStore = publicKeyStore;
24
+ this.#wallet = wallet;
25
+ this.#walletAccountsAtom = walletAccountsAtom;
26
+ this.#walletAccounts = walletAccounts;
27
+ this.#txLogMonitors = txLogMonitors;
28
+ this.#restoreProgressTracker = restoreProgressTracker;
29
+ }
30
+ #requestUserAction = async (params) => {
31
+ const rpc = this.#userInterface.getRPC();
32
+ return rpc.callMethod('handle-hardware-wallet', params);
33
+ };
34
+ #signGeneric = async ({ baseAssetName, scenario, sign }) => {
35
+ let needUserApproval = true;
36
+ let attempts = 0;
37
+ const MAX_ATTEMPTS = 50;
38
+ while (attempts < MAX_ATTEMPTS) {
39
+ try {
40
+ if (needUserApproval) {
41
+ this.#requestUserAction({
42
+ scenario,
43
+ baseAssetName,
44
+ });
45
+ needUserApproval = false;
46
+ }
47
+ const hardwareDevice = await this.#ledgerDiscovery.getFirstDevice();
48
+ await hardwareDevice.ensureApplicationIsOpened(baseAssetName);
49
+ const result = await sign({ hardwareDevice });
50
+ this.#requestUserAction({ scenario: 'completed' });
51
+ return result;
52
+ }
53
+ catch (error) {
54
+ if (error.message.includes('timeout')) {
55
+ break;
56
+ }
57
+ if (error.name === 'DisconnectedDeviceDuringOperation') {
58
+ await this.#requestUserAction({
59
+ scenario,
60
+ baseAssetName,
61
+ });
62
+ continue;
63
+ }
64
+ try {
65
+ const { tryAgain } = await this.#requestUserAction({
66
+ error,
67
+ baseAssetName,
68
+ });
69
+ if (!tryAgain) {
70
+ this.#logger.warn(error);
71
+ break;
72
+ }
73
+ }
74
+ catch (_error) {
75
+ this.#logger.error(_error);
76
+ break;
77
+ }
78
+ }
79
+ finally {
80
+ attempts++;
81
+ await delay(200);
82
+ }
83
+ }
84
+ this.#requestUserAction({ scenario: 'completed' });
85
+ throw new UserCancelledError();
86
+ };
87
+ signTransaction = async ({ baseAssetName, unsignedTx, walletAccount }) => {
88
+ const baseAsset = this.#assetsModule.getAsset(baseAssetName);
89
+ const accountIndex = walletAccount.index;
90
+ const sign = async ({ hardwareDevice }) => {
91
+ return baseAsset.api.signHardware({
92
+ unsignedTx,
93
+ hardwareDevice,
94
+ accountIndex,
95
+ });
96
+ };
97
+ return this.#signGeneric({ baseAssetName, scenario: 'signTransaction', sign });
98
+ };
99
+ signMessage = async ({ assetName, derivationPath, message }) => {
100
+ const baseAssetName = this.#assetsModule.getAsset(assetName).baseAsset.name;
101
+ const sign = async ({ hardwareDevice }) => {
102
+ return hardwareDevice.signMessage({
103
+ assetName: baseAssetName,
104
+ derivationPath,
105
+ message,
106
+ });
107
+ };
108
+ return this.#signGeneric({ baseAssetName, scenario: 'signMessage', sign });
109
+ };
110
+ isDeviceConnected = async () => {
111
+ try {
112
+ const devices = await this.#ledgerDiscovery.list();
113
+ return devices.length > 0;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ };
119
+ canAccessAsset = async ({ assetName }) => {
120
+ const asset = this.#assetsModule.getAsset(assetName);
121
+ const device = await this.#ledgerDiscovery.getFirstDevice();
122
+ const useableAssetNames = new Set(await device.listUseableAssetNames());
123
+ return useableAssetNames.has(asset.baseAsset.name);
124
+ };
125
+ listUseableAssetNames = async () => {
126
+ const device = await this.#ledgerDiscovery.getFirstDevice();
127
+ return device.listUseableAssetNames();
128
+ };
129
+ ensureApplicationIsOpened = async ({ assetName }) => {
130
+ const asset = this.#assetsModule.getAsset(assetName);
131
+ let i = 0;
132
+ while (i < 3) {
133
+ try {
134
+ const hardwareDevice = await this.#ledgerDiscovery.getFirstDevice();
135
+ await hardwareDevice.ensureApplicationIsOpened(asset.baseAsset.name);
136
+ }
137
+ catch (error) {
138
+ this.#logger.log(error);
139
+ }
140
+ finally {
141
+ await delay(1000);
142
+ i++;
143
+ }
144
+ }
145
+ };
146
+ #getAddress = async ({ assetName, accountIndex, addressIndex }) => {
147
+ const asset = this.#assetsModule.getAsset(assetName);
148
+ const supportedPurposes = asset.baseAsset.api.getSupportedPurposes({
149
+ compatibilityMode: 'ledger',
150
+ });
151
+ const { derivationPath } = asset.baseAsset.api.getKeyIdentifier({
152
+ compatibilityMode: 'ledger',
153
+ purpose: supportedPurposes[0],
154
+ accountIndex,
155
+ chainIndex: 0,
156
+ addressIndex,
157
+ });
158
+ const device = await this.#ledgerDiscovery.getFirstDevice();
159
+ return device.getAddress({
160
+ assetName,
161
+ derivationPath,
162
+ });
163
+ };
164
+ #getXPUB = async ({ device, baseAsset, purpose, accountIndex, }) => {
165
+ const keyIdentifier = baseAsset.api.getKeyIdentifier({
166
+ compatibilityMode: 'ledger',
167
+ purpose,
168
+ accountIndex,
169
+ chainIndex: undefined,
170
+ addressIndex: undefined,
171
+ });
172
+ const { derivationPath } = keyIdentifier;
173
+ const xpub = await device
174
+ .getXPub({
175
+ assetName: baseAsset.name,
176
+ derivationPath,
177
+ })
178
+ .catch((error) => {
179
+ if (error.name === 'XPubUnsupportedError') {
180
+ this.#logger.warn(`Retrieving XPUBs for ${baseAsset.name} is not supported`, error);
181
+ }
182
+ else {
183
+ throw error;
184
+ }
185
+ });
186
+ return xpub ? { keyIdentifier, xpub } : null;
187
+ };
188
+ #getPublicKey = async ({ device, baseAsset, purpose, accountIndex, }) => {
189
+ const keyIdentifier = baseAsset.api.getKeyIdentifier({
190
+ compatibilityMode: 'ledger',
191
+ purpose,
192
+ accountIndex,
193
+ });
194
+ const { derivationPath } = keyIdentifier;
195
+ const publicKey = await device.getPublicKey({
196
+ assetName: baseAsset.name,
197
+ derivationPath,
198
+ });
199
+ return { keyIdentifier, publicKey };
200
+ };
201
+ #fetchBalance = async ({ asset, address }) => {
202
+ try {
203
+ return await asset.api.getBalanceForAddress(address);
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ };
209
+ #getFirstNAddresses = async ({ assetName, accountIndex, n, offsetBy, }) => {
210
+ const asset = this.#assetsModule.getAsset(assetName);
211
+ const startIndex = offsetBy;
212
+ const addresses = [];
213
+ for (let idx = startIndex; idx < startIndex + n; idx++) {
214
+ addresses.push(await this.#getAddress({ assetName, accountIndex, addressIndex: idx }));
215
+ }
216
+ const fetchedBalances = await Promise.all(addresses.map(async (address) => ({
217
+ address,
218
+ balance: await this.#fetchBalance({ asset, address }),
219
+ })));
220
+ const addressToBalanceMap = Object.create(null);
221
+ fetchedBalances.forEach(({ address, balance }) => {
222
+ addressToBalanceMap[address] = balance;
223
+ });
224
+ return addressToBalanceMap;
225
+ };
226
+ scan = async ({ assetName, accountIndexes, addressLimit = 2, addressOffset = 0, }) => {
227
+ let usedAccountIndexes = [];
228
+ if (await this.#wallet.exists()) {
229
+ const walletAccounts = Object.values(await this.#walletAccountsAtom.get());
230
+ usedAccountIndexes = walletAccounts
231
+ .filter(({ source }) => source === WalletAccount.LEDGER_SRC)
232
+ .map(({ index }) => index);
233
+ }
234
+ const accounts = accountIndexes.map((accountIndex) => ({ accountIndex }));
235
+ for (const account of accounts) {
236
+ const addressToBalanceMap = await this.#getFirstNAddresses({
237
+ assetName,
238
+ accountIndex: account.accountIndex,
239
+ n: addressLimit,
240
+ offsetBy: addressOffset,
241
+ });
242
+ account.addressToBalanceMap = addressToBalanceMap;
243
+ account.mayAlreadyBeSynced = usedAccountIndexes.includes(account.accountIndex);
244
+ }
245
+ return accounts;
246
+ };
247
+ sync = async ({ accountIndex }) => {
248
+ const keysToSync = [];
249
+ const device = await this.#ledgerDiscovery.getFirstDevice();
250
+ const useableAssetNames = new Set(await device.listUseableAssetNames());
251
+ for (const assetName of useableAssetNames) {
252
+ const asset = this.#assetsModule.getAsset(assetName);
253
+ assert(asset, `asset with ${assetName} was not found`);
254
+ const baseAsset = asset.baseAsset;
255
+ const supportedPurposes = baseAsset.api.getSupportedPurposes({
256
+ compatibilityMode: 'ledger',
257
+ });
258
+ for (const purpose of supportedPurposes) {
259
+ const { keyIdentifier, xpub, publicKey, } = (await this.#getXPUB({ device, baseAsset, purpose, accountIndex })) ||
260
+ (await this.#getPublicKey({ device, baseAsset, purpose, accountIndex }));
261
+ keysToSync.push([keyIdentifier, { xpub, publicKey }]);
262
+ }
263
+ }
264
+ const id = randomBytes(16).toString('hex');
265
+ this.#syncedKeysMap.set(id, {
266
+ accountIndex,
267
+ assetNames: useableAssetNames,
268
+ keysToSync,
269
+ });
270
+ return id;
271
+ };
272
+ addPublicKeysToWalletAccount = async ({ walletAccountName, syncedKeysId, }) => {
273
+ assert(this.#syncedKeysMap.has(syncedKeysId), `no synchronized keys found for id ${syncedKeysId}`);
274
+ const { assetNames, keysToSync } = this.#syncedKeysMap.get(syncedKeysId);
275
+ for (const [keyIdentifier, keys] of keysToSync) {
276
+ const { xpub, publicKey } = keys;
277
+ await this.#publicKeyStore.add({ walletAccountName, keyIdentifier, xpub, publicKey });
278
+ }
279
+ for (const assetName of assetNames) {
280
+ if (assetName === 'ethereum' || assetName === 'matic') {
281
+ this.#restoreProgressTracker.restoreAsset('ethereum');
282
+ this.#restoreProgressTracker.restoreAsset('matic');
283
+ this.#txLogMonitors.update({ assetName: 'ethereum', refresh: true });
284
+ this.#txLogMonitors.update({ assetName: 'matic', refresh: true });
285
+ }
286
+ else {
287
+ this.#restoreProgressTracker.restoreAsset(assetName);
288
+ this.#txLogMonitors.update({ assetName, refresh: true });
289
+ }
290
+ }
291
+ };
292
+ create = async ({ syncedKeysId }) => {
293
+ assert(this.#syncedKeysMap.has(syncedKeysId), `no synchronized keys found for id ${syncedKeysId}`);
294
+ const { accountIndex } = this.#syncedKeysMap.get(syncedKeysId);
295
+ const walletAccount = new WalletAccount({
296
+ label: `Ledger${accountIndex === 0 ? '' : ' ' + accountIndex}`,
297
+ icon: 'ledger',
298
+ source: WalletAccount.LEDGER_SRC,
299
+ index: accountIndex,
300
+ id: randomBytes(32).toString('hex'),
301
+ });
302
+ const walletAccountName = walletAccount.toString();
303
+ await this.#walletAccounts.create(walletAccount);
304
+ await this.#walletAccounts.setActive(walletAccountName);
305
+ await this.addPublicKeysToWalletAccount({ walletAccountName, syncedKeysId });
306
+ };
307
+ }
308
+ const createHardwareWalletsModule = (opts) => new HardwareWallets(opts);
309
+ const hardwareWalletsModuleDefinition = {
310
+ id: 'hardwareWallets',
311
+ type: 'module',
312
+ factory: createHardwareWalletsModule,
313
+ dependencies: [
314
+ 'assetsModule',
315
+ 'logger',
316
+ 'ledgerDiscovery',
317
+ 'userInterface',
318
+ 'publicKeyStore',
319
+ 'wallet',
320
+ 'walletAccountsAtom',
321
+ 'walletAccounts',
322
+ 'txLogMonitors',
323
+ 'restoreProgressTracker',
324
+ ],
325
+ };
326
+ export default hardwareWalletsModuleDefinition;
@@ -0,0 +1,2 @@
1
+ export * from './interfaces.js';
2
+ export { default } from './hardware-wallets.js';
@@ -0,0 +1,2 @@
1
+ export * from './interfaces.js';
2
+ export { default } from './hardware-wallets.js';
@@ -0,0 +1,121 @@
1
+ import type { WalletAccount } from '@exodus/models';
2
+ import type { HardwareWalletDevice, SignMessageParams } from '@exodus/hw-common';
3
+ import type KeyIdentifier from '@exodus/key-identifier';
4
+ export interface HardwareSignerProvider {
5
+ isDeviceConnected: () => Promise<boolean>;
6
+ canAccessAsset: ({ assetName }: CanAccessAssetParams) => Promise<boolean>;
7
+ listUseableAssetNames: () => Promise<string[]>;
8
+ ensureApplicationIsOpened: ({ assetName }: EnsureApplicationIsOpenedParams) => Promise<void>;
9
+ scan: ({ assetName, accountIndexes, addressOffset }: ScanParams) => Promise<ScanResult>;
10
+ sync: ({ accountIndex }: SyncParams) => Promise<SyncedKeysId>;
11
+ addPublicKeysToWalletAccount: ({ walletAccountName, syncedKeysId, }: StoreSyncedKeysParams) => Promise<void>;
12
+ create: ({ syncedKeysId }: CreateParams) => Promise<void>;
13
+ signTransaction: ({ baseAssetName, unsignedTx, walletAccount, }: SignTransactionParams) => Promise<any>;
14
+ signMessage: ({ assetName, derivationPath, message }: SignMessageParams) => Promise<any>;
15
+ }
16
+ type Asset = {
17
+ name: string;
18
+ baseAsset: Asset;
19
+ api: {
20
+ getKeyIdentifier(params: {
21
+ purpose: number;
22
+ accountIndex: number;
23
+ chainIndex?: number;
24
+ addressIndex?: number;
25
+ compatibilityMode?: string;
26
+ }): KeyIdentifier;
27
+ };
28
+ };
29
+ export interface CanAccessAssetParams {
30
+ assetName: string;
31
+ }
32
+ export interface EnsureApplicationIsOpenedParams {
33
+ assetName: string;
34
+ }
35
+ export interface GetFirstNAddressesParams {
36
+ assetName: string;
37
+ accountIndex: number;
38
+ n: number;
39
+ offsetBy: number;
40
+ }
41
+ export type GetFirstNAddressesResult = Record<string, any>;
42
+ export interface ScanParams {
43
+ assetName: string;
44
+ accountIndexes: number[];
45
+ addressLimit?: number;
46
+ addressOffset: number;
47
+ }
48
+ export type ScannedAccount = {
49
+ accountIndex: number;
50
+ addressToBalanceMap: GetFirstNAddressesResult;
51
+ mayAlreadyBeSynced: boolean;
52
+ };
53
+ export type ScanResult = ScannedAccount[];
54
+ export interface SyncParams {
55
+ accountIndex: number;
56
+ }
57
+ export type SyncedKeysId = string;
58
+ export interface SyncedKeysData {
59
+ accountIndex: number;
60
+ assetNames: Set<string>;
61
+ keysToSync: KeyToSyncData[];
62
+ }
63
+ export type KeyToSyncData = [KeyIdentifier, {
64
+ xpub?: string;
65
+ publicKey?: Readonly<Uint8Array>;
66
+ }];
67
+ export interface StoreSyncedKeysParams {
68
+ walletAccountName: string;
69
+ syncedKeysId: SyncedKeysId;
70
+ }
71
+ export interface CreateParams {
72
+ syncedKeysId: SyncedKeysId;
73
+ }
74
+ export type GenericSignCallback = ({ hardwareDevice, }: {
75
+ hardwareDevice: HardwareWalletDevice;
76
+ }) => Promise<any>;
77
+ export interface GenericSignParams {
78
+ baseAssetName: string;
79
+ scenario: string;
80
+ sign: GenericSignCallback;
81
+ }
82
+ export interface SignTransactionParams {
83
+ baseAssetName: string;
84
+ unsignedTx: UnsignedTransaction;
85
+ walletAccount: WalletAccount;
86
+ }
87
+ export interface UnsignedTransaction {
88
+ txData: any;
89
+ txMeta: any;
90
+ }
91
+ export interface GetAddressParams {
92
+ assetName: string;
93
+ accountIndex: number;
94
+ addressIndex: number;
95
+ }
96
+ export interface GetXPUBParams {
97
+ device: HardwareWalletDevice;
98
+ baseAsset: Asset;
99
+ purpose: number;
100
+ accountIndex: number;
101
+ }
102
+ export type GetXPUBResult = {
103
+ keyIdentifier: KeyIdentifier;
104
+ xpub: string;
105
+ };
106
+ export interface GetPublicKeyParams {
107
+ device: HardwareWalletDevice;
108
+ baseAsset: Asset;
109
+ purpose: number;
110
+ accountIndex: number;
111
+ }
112
+ export type GetPublicKeyResult = {
113
+ keyIdentifier: KeyIdentifier;
114
+ publicKey: Readonly<Uint8Array>;
115
+ };
116
+ export interface FetchBalanceParams {
117
+ asset: any;
118
+ address: string;
119
+ }
120
+ export type FetchBalanceResult = any | null;
121
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@exodus/hardware-wallets",
3
+ "version": "1.0.0",
4
+ "description": "An Exodus SDK feature that provides a high level abstraction for interacting with hardware wallet devices",
5
+ "author": "Exodus Movement, Inc.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ExodusMovement/exodus-hydra.git"
9
+ },
10
+ "homepage": "https://github.com/ExodusMovement/exodus-hydra/tree/master/features/hardware-wallets",
11
+ "license": "UNLICENSED",
12
+ "bugs": {
13
+ "url": "https://github.com/ExodusMovement/exodus-hydra/issues?q=is%3Aissue+is%3Aopen+label%3Ahardware-wallets"
14
+ },
15
+ "main": "lib/index.js",
16
+ "type": "module",
17
+ "files": [
18
+ "lib/",
19
+ "CHANGELOG.md",
20
+ "!**/__tests__/**"
21
+ ],
22
+ "scripts": {
23
+ "build": "run -T tsc --build tsconfig.build.json",
24
+ "clean": "run -T tsc --build --clean",
25
+ "lint": "run -T eslint . --ignore-path ../../.gitignore",
26
+ "lint:fix": "yarn lint --fix",
27
+ "test": "run -T jest",
28
+ "prepublishOnly": "yarn run -T build --scope @exodus/hardware-wallets"
29
+ },
30
+ "dependencies": {
31
+ "@exodus/models": "^11.7.0",
32
+ "delay": "^5.0.0",
33
+ "minimalistic-assert": "^1.0.1",
34
+ "randombytes": "^2.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@exodus/atoms": "^8.0.0",
38
+ "@exodus/hw-common": "^2.2.1",
39
+ "@exodus/logger": "^1.2.2",
40
+ "@exodus/public-key-store": "^1.2.2"
41
+ },
42
+ "gitHead": "73cd1e81c69bd252bf1bdef0862223b9660a7fdf"
43
+ }