@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 +158 -0
- package/lib/api/index.d.ts +21 -0
- package/lib/api/index.js +21 -0
- package/lib/index.d.ts +32 -0
- package/lib/index.js +16 -0
- package/lib/module/errors.d.ts +4 -0
- package/lib/module/errors.js +6 -0
- package/lib/module/hardware-wallets.d.ts +39 -0
- package/lib/module/hardware-wallets.js +326 -0
- package/lib/module/index.d.ts +2 -0
- package/lib/module/index.js +2 -0
- package/lib/module/interfaces.d.ts +121 -0
- package/lib/module/interfaces.js +1 -0
- package/package.json +43 -0
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;
|
package/lib/api/index.js
ADDED
|
@@ -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,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,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
|
+
}
|