@dfinity/hardware-wallet-cli 0.1.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/index.ts ADDED
@@ -0,0 +1,535 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * A CLI tool for testing the Ledger hardware wallet integration.
5
+ */
6
+ import { Command, Option, InvalidArgumentError } from "commander";
7
+ import { LedgerIdentity } from "./src/ledger/identity";
8
+ import {
9
+ AccountIdentifier,
10
+ LedgerCanister,
11
+ GenesisTokenCanister,
12
+ GovernanceCanister,
13
+ GovernanceError,
14
+ ICP,
15
+ InsufficientAmountError,
16
+ InsufficientFundsError,
17
+ } from "@dfinity/nns";
18
+ import { Agent, AnonymousIdentity, HttpAgent, Identity } from "@dfinity/agent";
19
+ import chalk from "chalk";
20
+
21
+ // Add polyfill for `window` for `TransportWebHID` checks to work.
22
+ import "node-window-polyfill/register";
23
+
24
+ // Add polyfill for `window.fetch` for agent-js to work.
25
+ // @ts-ignore (no types are available)
26
+ import fetch from "node-fetch";
27
+ import { Principal } from "@dfinity/principal";
28
+ global.fetch = fetch;
29
+ window.fetch = fetch;
30
+
31
+ const program = new Command();
32
+ const log = console.log;
33
+
34
+ const SECONDS_PER_MINUTE = 60;
35
+ const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
36
+ const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
37
+ const SECONDS_PER_YEAR = 365 * SECONDS_PER_DAY + 6 * SECONDS_PER_HOUR;
38
+
39
+ async function getAgent(identity: Identity): Promise<Agent> {
40
+ const network = program.opts().network;
41
+
42
+ // Only fetch the rootkey if the network isn't mainnet.
43
+ const fetchRootKey = new URL(network).host == "ic0.app" ? false : true;
44
+
45
+ const agent = new HttpAgent({
46
+ host: program.opts().network,
47
+ identity: identity,
48
+ });
49
+
50
+ if (fetchRootKey) {
51
+ await agent.fetchRootKey();
52
+ }
53
+
54
+ return agent;
55
+ }
56
+
57
+ /**
58
+ * Fetches the balance of the main account on the wallet.
59
+ */
60
+ async function getBalance() {
61
+ const identity = await LedgerIdentity.create();
62
+ const accountIdentifier = AccountIdentifier.fromPrincipal({
63
+ principal: identity.getPrincipal(),
64
+ });
65
+
66
+ const ledger = LedgerCanister.create({
67
+ agent: await getAgent(new AnonymousIdentity()),
68
+ });
69
+
70
+ const balance = await ledger.accountBalance({
71
+ accountIdentifier: accountIdentifier,
72
+ });
73
+
74
+ ok(`Account ${accountIdentifier.toHex()} has balance ${balance.toE8s()} e8s`);
75
+ }
76
+
77
+ /**
78
+ * Send ICP to another address.
79
+ *
80
+ * @param to The account identifier in hex.
81
+ * @param amount Amount to send in e8s.
82
+ */
83
+ async function sendICP(to: AccountIdentifier, amount: ICP) {
84
+ const identity = await LedgerIdentity.create();
85
+ const ledger = LedgerCanister.create({
86
+ agent: await getAgent(identity),
87
+ });
88
+
89
+ const blockHeight = await ledger.transfer({
90
+ to: to,
91
+ amount: amount,
92
+ memo: BigInt(0),
93
+ });
94
+
95
+ ok(`Transaction completed at block height ${blockHeight}.`);
96
+ }
97
+
98
+ /**
99
+ * Shows the principal and account idenifier on the terminal and on the wallet's screen.
100
+ */
101
+ async function showInfo(showOnDevice?: boolean) {
102
+ const identity = await LedgerIdentity.create();
103
+ const accountIdentifier = AccountIdentifier.fromPrincipal({
104
+ principal: identity.getPrincipal(),
105
+ });
106
+
107
+ log(chalk.bold(`Principal: `) + identity.getPrincipal());
108
+ log(
109
+ chalk.bold(`Address (${identity.derivePath}): `) + accountIdentifier.toHex()
110
+ );
111
+
112
+ if (showOnDevice) {
113
+ log("Displaying the principal and the address on the device...");
114
+ await identity.showAddressAndPubKeyOnDevice();
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Stakes a new neuron.
120
+ *
121
+ * @param amount Amount to stake in e8s.
122
+ */
123
+ async function stakeNeuron(stake: ICP) {
124
+ const identity = await LedgerIdentity.create();
125
+ const ledger = LedgerCanister.create({
126
+ agent: await getAgent(identity),
127
+ });
128
+ const governance = GovernanceCanister.create({
129
+ agent: await getAgent(new AnonymousIdentity()),
130
+ hardwareWallet: true,
131
+ });
132
+
133
+ // Flag that an upcoming stake neuron transaction is coming to distinguish
134
+ // it from a "send ICP" transaction on the device.
135
+ identity.flagUpcomingStakeNeuron();
136
+
137
+ try {
138
+ const stakedNeuronId = await governance.stakeNeuron({
139
+ stake: stake,
140
+ principal: identity.getPrincipal(),
141
+ ledgerCanister: ledger,
142
+ });
143
+
144
+ ok(`Staked neuron with ID: ${stakedNeuronId}`);
145
+ } catch (error) {
146
+ if (error instanceof InsufficientAmountError) {
147
+ err(`Cannot stake less than ${error.minimumAmount.toE8s()} e8s`);
148
+ } else if (error instanceof InsufficientFundsError) {
149
+ err(`Your account has insufficient funds (${error.balance.toE8s()} e8s)`);
150
+ } else {
151
+ console.log(error);
152
+ }
153
+ }
154
+ }
155
+
156
+ async function increaseDissolveDelay(
157
+ neuronId: bigint,
158
+ years: number,
159
+ days: number,
160
+ minutes: number,
161
+ seconds: number
162
+ ) {
163
+ const identity = await LedgerIdentity.create();
164
+ const governance = GovernanceCanister.create({
165
+ agent: await getAgent(identity),
166
+ hardwareWallet: true,
167
+ });
168
+
169
+ const additionalDissolveDelaySeconds =
170
+ years * SECONDS_PER_YEAR +
171
+ days * SECONDS_PER_DAY +
172
+ minutes * SECONDS_PER_MINUTE +
173
+ seconds;
174
+
175
+ await governance.increaseDissolveDelay({
176
+ neuronId: neuronId,
177
+ additionalDissolveDelaySeconds: additionalDissolveDelaySeconds,
178
+ });
179
+
180
+ ok();
181
+ }
182
+
183
+ async function disburseNeuron(neuronId: bigint, to?: string, amount?: bigint) {
184
+ const identity = await LedgerIdentity.create();
185
+ const governance = GovernanceCanister.create({
186
+ agent: await getAgent(identity),
187
+ hardwareWallet: false,
188
+ });
189
+
190
+ await governance.disburse({
191
+ neuronId: BigInt(neuronId),
192
+ toAccountId: to,
193
+ amount: amount,
194
+ });
195
+
196
+ ok();
197
+ }
198
+
199
+ async function spawnNeuron(neuronId: string, controller?: Principal) {
200
+ const identity = await LedgerIdentity.create();
201
+ const governance = GovernanceCanister.create({
202
+ agent: await getAgent(identity),
203
+ hardwareWallet: true,
204
+ });
205
+
206
+ const spawnedNeuronId = await governance.spawnNeuron({
207
+ neuronId: BigInt(neuronId),
208
+ newController: controller,
209
+ });
210
+ ok(`Spawned neuron with ID ${spawnedNeuronId}`);
211
+ }
212
+
213
+ async function startDissolving(neuronId: bigint) {
214
+ const identity = await LedgerIdentity.create();
215
+ const governance = GovernanceCanister.create({
216
+ agent: await getAgent(identity),
217
+ hardwareWallet: true,
218
+ });
219
+
220
+ await governance.startDissolving(neuronId);
221
+
222
+ ok();
223
+ }
224
+
225
+ async function stopDissolving(neuronId: bigint) {
226
+ const identity = await LedgerIdentity.create();
227
+ const governance = GovernanceCanister.create({
228
+ agent: await getAgent(identity),
229
+ hardwareWallet: true,
230
+ });
231
+
232
+ await governance.stopDissolving(neuronId);
233
+
234
+ ok();
235
+ }
236
+
237
+ async function addHotkey(neuronId: bigint, principal: Principal) {
238
+ const identity = await LedgerIdentity.create();
239
+ const governance = GovernanceCanister.create({
240
+ agent: await getAgent(identity),
241
+ hardwareWallet: true,
242
+ });
243
+
244
+ await governance.addHotkey({
245
+ neuronId: BigInt(neuronId),
246
+ principal: principal,
247
+ });
248
+
249
+ ok();
250
+ }
251
+
252
+ async function removeHotkey(neuronId: bigint, principal: Principal) {
253
+ const identity = await LedgerIdentity.create();
254
+ const governance = GovernanceCanister.create({
255
+ agent: await getAgent(identity),
256
+ hardwareWallet: true,
257
+ });
258
+
259
+ await governance.removeHotkey({
260
+ neuronId: BigInt(neuronId),
261
+ principal: principal,
262
+ });
263
+
264
+ ok();
265
+ }
266
+
267
+ async function listNeurons() {
268
+ const identity = await LedgerIdentity.create();
269
+ const governance = GovernanceCanister.create({
270
+ agent: await getAgent(identity),
271
+ hardwareWallet: true,
272
+ });
273
+
274
+ // We filter neurons with no ICP, as they'll be garbage collected by the governance canister.
275
+ const neurons = await governance.listNeurons({
276
+ certified: true,
277
+ });
278
+
279
+ if (neurons.length > 0) {
280
+ neurons.forEach((n) => {
281
+ log(`Neuron ID: ${n.neuronId}`);
282
+ });
283
+ } else {
284
+ ok("No neurons found.");
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Fetches the balance of the main account on the wallet.
290
+ */
291
+ async function claimNeurons(hexPubKey: string) {
292
+ const isHex = hexPubKey.match("^[0-9a-fA-F]+$");
293
+ if (!isHex) {
294
+ throw new Error(`${hexPubKey} is not a hex string.`);
295
+ }
296
+
297
+ if (hexPubKey.length < 130 || hexPubKey.length > 150) {
298
+ throw new Error(`The key must be >= 130 characters and <= 150 characters.`);
299
+ }
300
+
301
+ const identity = await LedgerIdentity.create();
302
+ const governance = await GenesisTokenCanister.create({
303
+ agent: await getAgent(identity),
304
+ });
305
+
306
+ const claimedNeuronIds = await governance.claimNeurons({
307
+ hexPubKey: hexPubKey,
308
+ });
309
+
310
+ ok(`Successfully claimed the following neurons: ${claimedNeuronIds}`);
311
+ }
312
+
313
+ /**
314
+ * Runs a function with a try/catch block.
315
+ */
316
+ async function run(f: () => void) {
317
+ try {
318
+ await f();
319
+ } catch (error: any) {
320
+ err(error);
321
+ }
322
+ }
323
+
324
+ function ok(message?: string) {
325
+ if (message) {
326
+ log(`${chalk.green(chalk.bold("OK"))}: ${message}`);
327
+ } else {
328
+ log(`${chalk.green(chalk.bold("OK"))}`);
329
+ }
330
+ }
331
+
332
+ function err(error: any) {
333
+ const message =
334
+ error instanceof GovernanceError
335
+ ? error.detail.error_message
336
+ : error instanceof Error
337
+ ? error.message
338
+ : error;
339
+ log(`${chalk.bold(chalk.red("Error:"))} ${message}`);
340
+ }
341
+
342
+ function tryParseInt(value: string): number {
343
+ const parsedValue = parseInt(value, 10);
344
+ if (isNaN(parsedValue)) {
345
+ throw new InvalidArgumentError("Not a number.");
346
+ }
347
+ return parsedValue;
348
+ }
349
+
350
+ function tryParseBigInt(value: string): bigint {
351
+ try {
352
+ return BigInt(value);
353
+ } catch (err: any) {
354
+ throw new InvalidArgumentError(err.toString());
355
+ }
356
+ }
357
+
358
+ function tryParsePrincipal(value: string): Principal {
359
+ try {
360
+ return Principal.fromText(value);
361
+ } catch (err: any) {
362
+ throw new InvalidArgumentError(err.toString());
363
+ }
364
+ }
365
+
366
+ function tryParseE8s(e8s: string): ICP {
367
+ try {
368
+ return ICP.fromE8s(tryParseBigInt(e8s));
369
+ } catch (err: any) {
370
+ throw new InvalidArgumentError(err.toString());
371
+ }
372
+ }
373
+
374
+ function tryParseAccountIdentifier(
375
+ accountIdentifier: string
376
+ ): AccountIdentifier {
377
+ try {
378
+ return AccountIdentifier.fromHex(accountIdentifier);
379
+ } catch (err: any) {
380
+ throw new InvalidArgumentError(err.toString());
381
+ }
382
+ }
383
+
384
+ async function main() {
385
+ const neuron = new Command("neuron")
386
+ .description("Commands for managing neurons.")
387
+ .showSuggestionAfterError()
388
+ .addCommand(
389
+ new Command("stake")
390
+ .requiredOption(
391
+ "--amount <amount>",
392
+ "Amount to stake in e8s.",
393
+ tryParseE8s
394
+ )
395
+ .action((args) => run(() => stakeNeuron(args.amount)))
396
+ )
397
+ .addCommand(
398
+ new Command("increase-dissolve-delay")
399
+ .requiredOption("--neuron-id <neuron-id>", "Neuron ID", tryParseBigInt)
400
+ .option("--years <years>", "Number of years", tryParseInt)
401
+ .option("--days <days>", "Number of days", tryParseInt)
402
+ .option("--minutes <minutes>", "Number of minutes", tryParseInt)
403
+ .option("--seconds <seconds>", "Number of seconds", tryParseInt)
404
+ .action((args) =>
405
+ run(() =>
406
+ increaseDissolveDelay(
407
+ args.neuronId,
408
+ args.years || 0,
409
+ args.days || 0,
410
+ args.minutes || 0,
411
+ args.seconds || 0
412
+ )
413
+ )
414
+ )
415
+ )
416
+ .addCommand(
417
+ new Command("disburse")
418
+ .requiredOption("--neuron-id <neuron-id>", "Neuron ID", tryParseBigInt)
419
+ .option("--to <account-identifier>")
420
+ .option(
421
+ "--amount <amount>",
422
+ "Amount to disburse (empty to disburse all)",
423
+ tryParseBigInt
424
+ )
425
+ .action((args) => {
426
+ run(() => disburseNeuron(args.neuronId, args.to, args.amount));
427
+ })
428
+ )
429
+ .addCommand(
430
+ new Command("spawn")
431
+ .requiredOption("--neuron-id <neuron-id>", "Neuron ID", tryParseBigInt)
432
+ .option(
433
+ "--controller <new-controller>",
434
+ "Controller",
435
+ tryParsePrincipal
436
+ )
437
+ .action((args) => {
438
+ run(() => spawnNeuron(args.neuronId, args.controller));
439
+ })
440
+ )
441
+ .addCommand(
442
+ new Command("start-dissolving")
443
+ .requiredOption("--neuron-id <neuron-id>", "Neuron ID", tryParseBigInt)
444
+ .action((args) => {
445
+ run(() => startDissolving(args.neuronId));
446
+ })
447
+ )
448
+ .addCommand(
449
+ new Command("stop-dissolving")
450
+ .requiredOption("--neuron-id <neuron-id>", "Neuron ID", tryParseBigInt)
451
+ .action((args) => {
452
+ run(() => stopDissolving(args.neuronId));
453
+ })
454
+ )
455
+ .addCommand(new Command("list").action(() => run(listNeurons)))
456
+ .addCommand(
457
+ new Command("add-hotkey")
458
+ .requiredOption("--neuron-id <neuron-id>", "Neuron ID", tryParseBigInt)
459
+ .requiredOption(
460
+ "--principal <principal>",
461
+ "Principal",
462
+ tryParsePrincipal
463
+ )
464
+ .action((args) => run(() => addHotkey(args.neuronId, args.principal)))
465
+ )
466
+ .addCommand(
467
+ new Command("remove-hotkey")
468
+ .requiredOption("--neuron-id <neuron-id>", "Neuron ID", tryParseBigInt)
469
+ .requiredOption(
470
+ "--principal <principal>",
471
+ "Principal",
472
+ tryParsePrincipal
473
+ )
474
+ .action((args) =>
475
+ run(() => removeHotkey(args.neuronId, args.principal))
476
+ )
477
+ )
478
+ .addCommand(
479
+ new Command("claim")
480
+ .requiredOption(
481
+ "--hex-public-key <public-key>",
482
+ "Claim the caller's GTC neurons."
483
+ )
484
+ .action((args) => run(() => claimNeurons(args.hexPublicKey)))
485
+ );
486
+
487
+ const icp = new Command("icp")
488
+ .description("Commands for managing ICP.")
489
+ .showSuggestionAfterError()
490
+ .addCommand(
491
+ new Command("balance")
492
+ .description("Fetch current balance.")
493
+ .action(() => {
494
+ run(getBalance);
495
+ })
496
+ )
497
+ .addCommand(
498
+ new Command("transfer")
499
+ .requiredOption(
500
+ "--to <account-identifier>",
501
+ "Account identifier to transfer to.",
502
+ tryParseAccountIdentifier
503
+ )
504
+ .requiredOption(
505
+ "--amount <amount>",
506
+ "Amount to transfer in e8s.",
507
+ tryParseE8s
508
+ )
509
+ .action((args) => run(() => sendICP(args.to, args.amount)))
510
+ );
511
+
512
+ program
513
+ .description("A CLI for the Ledger hardware wallet.")
514
+ .enablePositionalOptions()
515
+ .showSuggestionAfterError()
516
+ .addOption(
517
+ new Option("--network <network>", "The IC network to talk to.")
518
+ .default("https://ic0.app")
519
+ .env("IC_NETWORK")
520
+ )
521
+ .addCommand(
522
+ new Command("info")
523
+ .option("-n --no-show-on-device")
524
+ .description("Show the wallet's principal, address, and balance.")
525
+ .action((args) => {
526
+ run(() => showInfo(args.showOnDevice));
527
+ })
528
+ )
529
+ .addCommand(icp)
530
+ .addCommand(neuron);
531
+
532
+ await program.parseAsync(process.argv);
533
+ }
534
+
535
+ main();
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@dfinity/hardware-wallet-cli",
3
+ "version": "0.1.0",
4
+ "description": "A CLI to interact with the Internet Computer App on Ledger Nano S/X devices.",
5
+ "main": "./build/index.js",
6
+ "scripts": {
7
+ "format": "prettier --write .",
8
+ "test": "echo \"Error: no test specified\" && exit 1",
9
+ "build": "tsc --build",
10
+ "clean": "tsc --build --clean",
11
+ "refresh": "rm -rf ./node_modules ./package-lock.json && npm install"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/dfinity/hardware-wallet-cli.git"
16
+ },
17
+ "author": "",
18
+ "license": "ISC",
19
+ "bugs": {
20
+ "url": "https://github.com/dfinity/hardware-wallet-cli/issues"
21
+ },
22
+ "homepage": "https://github.com/dfinity/hardware-wallet-cli#readme",
23
+ "dependencies": {
24
+ "@dfinity/agent": "^0.11.2",
25
+ "@dfinity/nns": "^0.4.2",
26
+ "@ledgerhq/hw-transport-node-hid-noevents": "^6.3.0",
27
+ "@ledgerhq/hw-transport-webhid": "^6.27.1",
28
+ "@zondax/ledger-icp": "^0.6.0",
29
+ "chalk": "^4.1.2",
30
+ "commander": "^9.0.0",
31
+ "node-fetch": "^2.6.1",
32
+ "node-window-polyfill": "^1.0.2"
33
+ },
34
+ "devDependencies": {
35
+ "@types/google-protobuf": "^3.15.6",
36
+ "@types/node": "^17.0.16",
37
+ "@types/node-hid": "^1.3.1",
38
+ "prettier": "^2.6.2"
39
+ },
40
+ "bin": {
41
+ "ic-hardware-wallet": "./build/index.js"
42
+ }
43
+ }