hodl-wallet 2.1.0 → 2.2.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/dist/index.js CHANGED
@@ -1,23 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'fs';
3
- import crypto from 'node:crypto';
4
- import { AgentError } from './agent-errors.js';
5
- import { TransferService } from './transfer-service.js';
6
3
  import path from 'path';
7
- import inquirer from 'inquirer';
8
- import Persist from './persist.js';
9
- import { normalizeDecimal } from './amounts.js';
10
- import { NetworkRegistry, networkStorageName } from './network-registry.js';
11
4
  import { ProfileLock, ProfileLockedError } from './profile-lock.js';
12
- import { SwapService } from './swap/service.js';
13
- import { routeForNetwork } from './swap/routes.js';
14
- import { startSwapMenu } from './swap/ui.js';
15
5
  import { fileURLToPath } from 'url';
16
- import Table from 'cli-table3';
17
6
  import os from 'os';
18
- import ora from 'ora';
19
- const AnyTable = Table;
7
+ import { NodeSession } from './node-session.js';
8
+ import { InquirerUi } from './ui-inquirer.js';
9
+ import { errorMessage } from './app/format.js';
10
+ import { farewell, showError, welcome } from './app/output.js';
11
+ import { runWallet } from './app/menu.js';
20
12
  const __filename = fileURLToPath(import.meta.url);
13
+ const ui = new InquirerUi();
21
14
  function suppressPunycodeDeprecationWarning() {
22
15
  const warningListeners = process.rawListeners('warning');
23
16
  process.removeAllListeners('warning');
@@ -29,1085 +22,22 @@ function suppressPunycodeDeprecationWarning() {
29
22
  });
30
23
  }
31
24
  suppressPunycodeDeprecationWarning();
32
- function isNetworkUsageEntry(value) {
33
- return (typeof value === 'object' &&
34
- value !== null &&
35
- !Array.isArray(value) &&
36
- typeof value.count === 'number');
37
- }
38
- function errorMessage(error) {
39
- return error instanceof Error ? error.message : String(error);
40
- }
41
- import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
42
- inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
43
- class Wallet {
44
- db;
45
- network;
46
- selectedNetwork;
47
- networkUsage;
48
- databaseExisted;
49
- profileLock;
50
- hodlDir;
51
- constructor(encryptionKey, profileLock) {
52
- const hodlDir = path.join(os.homedir(), '.HODL');
53
- this.hodlDir = hodlDir;
54
- if (!fs.existsSync(hodlDir)) {
55
- fs.mkdirSync(hodlDir, { recursive: true, mode: 0o700 });
56
- }
57
- fs.chmodSync(hodlDir, 0o700);
58
- this.databaseExisted = fs.existsSync(path.join(hodlDir, 'persist.json'));
59
- this.profileLock = profileLock;
60
- try {
61
- this.db = new Persist({ path: hodlDir, encryptionKey });
62
- }
63
- catch (error) {
64
- this.profileLock.release();
65
- throw error;
66
- }
67
- this.network = null;
68
- this.selectedNetwork = null;
69
- this.networkUsage = {};
70
- }
71
- async connect() {
72
- await this.db.connect();
73
- this.securePersistFile();
74
- }
75
- hasStoredDatabase() {
76
- return this.databaseExisted;
77
- }
78
- /**
79
- * @param {number | string} num
80
- * @returns {string}
81
- */
82
- formatAmount(num) {
83
- num = parseFloat(num.toString());
84
- // Handle integers - add .00
85
- if (num === Math.floor(num)) {
86
- return num.toString() + '.00';
87
- }
88
- // For decimals, format to max 3 decimal places, then remove trailing zeros
89
- let formatted = num.toFixed(3);
90
- // Remove trailing zeros, but keep at least 2 decimal places
91
- while (formatted.endsWith('0') && formatted.split('.')[1].length > 2) {
92
- formatted = formatted.slice(0, -1);
93
- }
94
- return formatted;
95
- }
96
- async initialize() {
97
- try {
98
- const rawNetworkUsage = await this.db.get('networkUsage') || {};
99
- // Validate and clean networkUsage data
100
- this.networkUsage = {};
101
- for (const [networkName, usage] of Object.entries(rawNetworkUsage)) {
102
- if (typeof usage === 'number') {
103
- // Old format - convert to new format
104
- this.networkUsage[networkName] = {
105
- count: usage,
106
- lastUsed: 0
107
- };
108
- }
109
- else if (isNetworkUsageEntry(usage)) {
110
- // Valid new format
111
- this.networkUsage[networkName] = {
112
- count: usage.count,
113
- lastUsed: usage.lastUsed || 0
114
- };
115
- }
116
- // Skip corrupted entries (they'll be recreated when needed)
117
- }
118
- const networkPlugins = await this.loadNetworkPlugins();
119
- if (networkPlugins.length === 0) {
120
- throw new Error('No valid network plugins found.');
121
- }
122
- await this.selectNetwork(networkPlugins, { autoSelect: true });
123
- this.network = new this.selectedNetwork.NetworkClass(this.selectedNetwork);
124
- this.network.name = this.selectedNetwork.name;
125
- await this.loadAccount();
126
- if (!await this.hasAccount()) {
127
- throw new Error('Failed to initialize account.');
128
- }
129
- }
130
- catch (error) {
131
- throw new Error(`Initialization failed: ${errorMessage(error)}`);
132
- }
133
- }
134
- /**
135
- * @param {WalletAccount | any} account
136
- * @returns {void}
137
- */
138
- async setAccount(account) {
139
- if (!account) {
140
- return;
141
- }
142
- await this.db.set('account', this.network.constructor.name, account);
143
- if (account.mnemonic) {
144
- await this.db.set('mnemonic', account.mnemonic);
145
- }
146
- }
147
- async getAccount() {
148
- const account = await this.db.get('account', this.network.constructor.name);
149
- return account ?? null;
150
- }
151
- async getAddress() {
152
- return this.db.get('account', this.network.constructor.name, 'address');
153
- }
154
- async getMnemonic() {
155
- return await this.db.get('mnemonic') ?? null;
156
- }
157
- async hasAccount() {
158
- return typeof await this.db.get('account', this.network.constructor.name, 'address') === 'string';
159
- }
160
- async displayAccountAddress() {
161
- const table = new AnyTable({
162
- head: [`${this.selectedNetwork.name} Address`],
163
- style: {
164
- head: ['green']
165
- }
166
- });
167
- table.push([await this.getAddress()]);
168
- console.log(table.toString());
169
- }
170
- /**
171
- * @returns {Promise<NetworkPlugin[]>}
172
- */
173
- async loadNetworkPlugins() {
174
- return new NetworkRegistry().list();
175
- }
176
- /**
177
- * @param {NetworkPlugin[]} networkPlugins
178
- * @param {{ autoSelect?: boolean }} [options]
179
- * @returns {Promise<void>}
180
- */
181
- async selectNetwork(networkPlugins, { autoSelect = false } = {}) {
182
- // Sort networks by last used timestamp (most recent first)
183
- const sortedNetworks = networkPlugins.sort((a, b) => {
184
- const aUsage = this.networkUsage[networkStorageName(a)];
185
- const bUsage = this.networkUsage[networkStorageName(b)];
186
- // Handle old format (number) vs new format (object)
187
- const aLastUsed = typeof aUsage === 'object' ? aUsage.lastUsed || 0 : 0;
188
- const bLastUsed = typeof bUsage === 'object' ? bUsage.lastUsed || 0 : 0;
189
- // If neither has lastUsed timestamp, sort by old count format
190
- if (aLastUsed === 0 && bLastUsed === 0) {
191
- const aCount = typeof aUsage === 'number' ? aUsage : (aUsage?.count || 0);
192
- const bCount = typeof bUsage === 'number' ? bUsage : (bUsage?.count || 0);
193
- return bCount - aCount;
194
- }
195
- return bLastUsed - aLastUsed;
196
- });
197
- let selectedNetwork;
198
- if (autoSelect) {
199
- // Automatically select the first network (most recently used)
200
- selectedNetwork = sortedNetworks[0];
201
- }
202
- else {
203
- // Let user choose the network
204
- const { network } = await inquirer.prompt({
205
- type: 'list',
206
- name: 'network',
207
- message: 'Select the network:',
208
- choices: sortedNetworks.map(plugin => plugin.name),
209
- });
210
- selectedNetwork = sortedNetworks.find(plugin => plugin.name === network);
211
- }
212
- if (!selectedNetwork) {
213
- throw new Error('Selected network was not found.');
214
- }
215
- // Update usage info for the selected network
216
- // Handle migration from old format (number) to new format (object)
217
- const currentUsage = this.networkUsage[networkStorageName(selectedNetwork)];
218
- if (!currentUsage || typeof currentUsage === 'number' || typeof currentUsage !== 'object' || currentUsage === null || Array.isArray(currentUsage)) {
219
- // Old format (number), doesn't exist, or corrupted data - create new object
220
- this.networkUsage[networkStorageName(selectedNetwork)] = {
221
- count: typeof currentUsage === 'number' ? currentUsage + 1 : 1,
222
- lastUsed: Date.now()
223
- };
224
- }
225
- else if (isNetworkUsageEntry(currentUsage)) {
226
- // New format (object) - update values
227
- currentUsage.count = (currentUsage.count || 0) + 1;
228
- currentUsage.lastUsed = Date.now();
229
- }
230
- else {
231
- this.networkUsage[networkStorageName(selectedNetwork)] = {
232
- count: 1,
233
- lastUsed: Date.now()
234
- };
235
- }
236
- await this.db.set('networkUsage', this.networkUsage);
237
- this.selectedNetwork = selectedNetwork;
238
- }
239
- /**
240
- * @param {boolean} [loggedIn]
241
- * @returns {Promise<void>}
242
- */
243
- async loadAccount(loggedIn = false) {
244
- let accountExists = await this.hasAccount();
245
- const mainChoices = ['Create New Account'];
246
- if (loggedIn) {
247
- mainChoices.push('Import Options');
248
- mainChoices.push('Export Options');
249
- mainChoices.push('Switch Network');
250
- mainChoices.push('Manage Address Book');
251
- mainChoices.push('Go Back');
252
- }
253
- else {
254
- mainChoices.push('Import HODL File');
255
- mainChoices.push('Import Mnemonic (12 or 24 words)');
256
- mainChoices.push('Import Private-key');
257
- }
258
- if (!accountExists || loggedIn) {
259
- let { accountAction } = await inquirer.prompt({
260
- type: 'list',
261
- name: 'accountAction',
262
- message: 'Select an account option:',
263
- choices: mainChoices,
264
- });
265
- if (accountAction === 'Go Back') {
266
- return;
267
- }
268
- if (accountAction === 'Manage Address Book') {
269
- const addressBookChoices = ['Delete Address', 'Go Back'];
270
- const { addressBookAction } = await inquirer.prompt({
271
- type: 'list',
272
- name: 'addressBookAction',
273
- message: 'Select an address book option:',
274
- choices: addressBookChoices,
275
- });
276
- if (addressBookAction === 'Go Back') {
277
- return this.loadAccount(loggedIn);
278
- }
279
- switch (addressBookAction) {
280
- case 'Delete Address':
281
- await this.deleteFromAddressBook();
282
- break;
283
- }
284
- return;
285
- }
286
- if (accountAction === 'Import Options') {
287
- const importChoices = ['Import HODL File', 'Import Mnemonic (12 or 24 words)', 'Import Private-key', 'Go Back'];
288
- const { importAction } = await inquirer.prompt({
289
- type: 'list',
290
- name: 'importAction',
291
- message: 'Select an import option:',
292
- choices: importChoices,
293
- });
294
- if (importAction === 'Go Back') {
295
- return this.loadAccount(loggedIn);
296
- }
297
- const confirmOverwrite = await this.confirmOverwrite();
298
- if (!confirmOverwrite)
299
- return;
300
- accountAction = importAction;
301
- }
302
- switch (accountAction) {
303
- case 'Import Mnemonic (12 or 24 words)':
304
- const account = await this.importFromMnemonic();
305
- if (!account) {
306
- // Wallet.displayError('Invalid mnemonic.');
307
- return;
308
- }
309
- await this.setAccount(account);
310
- Persist.clearSensitiveData(account);
311
- await this.displayAccountAddress();
312
- accountExists = true;
313
- break;
314
- case 'Import Private-key':
315
- await this.importPrivateKey();
316
- break;
317
- case 'Import HODL File':
318
- const importedAccount = await this.importHODLFile();
319
- if (importedAccount) {
320
- Persist.clearSensitiveData(importedAccount);
321
- await this.displayAccountAddress();
322
- accountExists = true;
323
- }
324
- break;
325
- }
326
- if (accountAction === 'Export Options') {
327
- const exportChoices = ['Export HODL File', 'Export Private-key', 'Go Back'];
328
- const { exportAction } = await inquirer.prompt({
329
- type: 'list',
330
- name: 'exportAction',
331
- message: 'Select an export option:',
332
- choices: exportChoices,
333
- });
334
- if (exportAction === 'Go Back') {
335
- return this.loadAccount(loggedIn);
336
- }
337
- switch (exportAction) {
338
- case 'Export Private-key':
339
- await this.displayAccountDetails();
340
- break;
341
- case 'Export HODL File':
342
- await this.exportHODLFile();
343
- break;
344
- }
345
- return;
346
- }
347
- if (accountAction === 'Create New Account') {
348
- const confirmOverwrite = await this.confirmOverwrite();
349
- if (!confirmOverwrite)
350
- return;
351
- await this.createNewAccount();
352
- }
353
- if (accountAction === 'Switch Network') {
354
- await this.switchNetwork();
355
- }
356
- return;
357
- }
358
- if (!accountExists) {
359
- throw new Error('Account was not initialized.');
360
- }
361
- }
362
- /**
363
- * @returns {Promise<boolean>}
364
- */
365
- async confirmOverwrite() {
366
- if (await this.hasAccount()) {
367
- const { confirmOverwrite } = await inquirer.prompt({
368
- type: 'confirm',
369
- name: 'confirmOverwrite',
370
- message: 'This action will overwrite the existing account. Are you sure you want to continue?',
371
- default: false,
372
- });
373
- return confirmOverwrite;
374
- }
375
- return true;
376
- }
377
- /**
378
- * @returns {Promise<WalletAccount | null>}
379
- */
380
- async importFromMnemonic() {
381
- const { mnemonic } = await inquirer.prompt({
382
- type: 'password',
383
- name: 'mnemonic',
384
- message: 'Enter your mnemonic phrase (12 or 24 words):',
385
- mask: '*',
386
- validate: (input) => {
387
- if (input.trim() === '')
388
- return true;
389
- return this.network.validateMnemonic(input) || 'Please enter a valid mnemonic phrase or leave empty to cancel.';
390
- }
391
- });
392
- if (!mnemonic.trim())
393
- return null;
394
- return this.network.accountFromMnemonic(mnemonic);
395
- }
396
- async showBalance() {
397
- if (!await this.hasAccount()) {
398
- Wallet.displayError('Account not initialized.');
399
- return;
400
- }
401
- const balances = await this.network.getTokenBalances(await this.getAddress());
402
- const table = new AnyTable({
403
- head: ['Token', 'Balance'],
404
- style: { head: ['blue'] },
405
- colWidths: [21, 22]
406
- });
407
- balances.forEach(([token, balance]) => {
408
- table.push([token, token === 'USDT' ? Number(balance).toFixed(2) : this.formatAmount(balance)]);
409
- });
410
- console.log(table.toString());
411
- }
412
- async transferFunds() {
413
- const service = new TransferService(this.db, this.selectedNetwork, this.network);
414
- const pending = (await service.list()).filter(transfer => !['confirmed', 'failed'].includes(transfer.status));
415
- if (pending.length) {
416
- const { requestId } = await inquirer.prompt({
417
- type: 'list', name: 'requestId', message: 'A previous transfer is still pending:',
418
- choices: [
419
- ...pending.map(transfer => ({
420
- name: `Check / resume ${transfer.amount} ${transfer.asset} to ${transfer.to} (${transfer.status})`,
421
- value: transfer.requestId
422
- })),
423
- { name: 'New transfer', value: 'new' },
424
- { name: 'Go back', value: 'back' }
425
- ]
426
- });
427
- if (requestId === 'back')
428
- return;
429
- if (requestId !== 'new') {
430
- const transfer = pending.find(item => item.requestId === requestId);
431
- await this.submitTransfer(service, transfer.to, transfer.asset, transfer.amount, requestId);
432
- return;
433
- }
434
- }
435
- const contacts = await this.db.entries('contact', this.storageNetworkName) || [];
436
- const addressBook = contacts.map(([address, data]) => ({
437
- address,
438
- name: data.name
439
- }));
440
- addressBook.push({ name: 'Go Back', address: '' });
441
- // Implement autocomplete for address book
442
- const { recipient } = await inquirer.prompt({
443
- type: 'autocomplete',
444
- name: 'recipient',
445
- message: 'Recipient address:',
446
- source: (_answersSoFar, input = '') => {
447
- input = input || '';
448
- return addressBook
449
- .filter(entry => entry.name.toLowerCase().includes(input.toLowerCase()) || entry.address.toLowerCase().includes(input.toLowerCase()))
450
- .map(entry => ({
451
- name: entry.address ? `${entry.address} (${entry.name})` : entry.name,
452
- value: entry.address
453
- }))
454
- .concat([{ name: input, value: input }]); // Add the input as a possible choice
455
- },
456
- });
457
- if (!recipient)
458
- return;
459
- let address = recipient;
460
- const choices = Object.keys(this.selectedNetwork.tokens);
461
- choices.push(this.selectedNetwork.nativeToken);
462
- let token = this.selectedNetwork.nativeToken;
463
- if (choices.length > 1) {
464
- token = (await inquirer.prompt({
465
- type: 'list',
466
- name: 'token',
467
- message: 'Token to transfer:',
468
- choices,
469
- })).token;
470
- }
471
- const { amount } = await inquirer.prompt({
472
- type: 'input',
473
- name: 'amount',
474
- message: `Amount to transfer (or max):`,
475
- validate: (value) => {
476
- if (value.trim() === '' || value.trim().toLowerCase() === 'max')
477
- return true;
478
- try {
479
- normalizeDecimal(value.trim());
480
- return true;
481
- }
482
- catch {
483
- return 'Please enter a valid decimal amount, max, or leave empty to cancel.';
484
- }
485
- },
486
- });
487
- if (amount.trim() === '') {
488
- return;
489
- }
490
- let transferAmount = amount.trim();
491
- if (transferAmount.toLowerCase() === 'max') {
492
- try {
493
- const preview = await service.send({ wallet: 'default', to: address, asset: token, amount: 'max', dryRun: true });
494
- transferAmount = preview.amount;
495
- console.log(`Maximum: ${preview.amount} ${token} | Estimated fee: ${preview.fee.amount} ${preview.fee.asset}`);
496
- }
497
- catch (error) {
498
- this.displayTransactionError(error);
499
- return;
500
- }
501
- }
502
- // Add confirmation step
503
- const { confirmTransaction } = await inquirer.prompt({
504
- type: 'confirm',
505
- name: 'confirmTransaction',
506
- message: amount.trim().toLowerCase() === 'max'
507
- ? `Confirm transfer of maximum available ${token} (estimated ${transferAmount})?`
508
- : `Confirm transfer of ${transferAmount} ${token}?`,
509
- default: true
510
- });
511
- if (!confirmTransaction) {
512
- return;
513
- }
514
- await this.submitTransfer(service, address, token, amount.trim().toLowerCase() === 'max' ? 'max' : transferAmount, crypto.randomUUID());
515
- }
516
- async submitTransfer(service, address, token, amount, requestId) {
517
- const spinner = ora({ text: 'Checking / sending transaction...', spinner: 'dots' }).start();
518
- let result;
519
- try {
520
- result = await service.send({ wallet: 'default', to: address, asset: token, amount, requestId, dryRun: false });
521
- }
522
- catch (error) {
523
- spinner.fail('Could not complete the transfer request.');
524
- this.displayTransactionError(error);
525
- if (error instanceof AgentError && error.code === 'SWAP_IN_PROGRESS') {
526
- console.log(`Blocking swap: ${error.details?.requestId}`);
527
- console.log('Open Swap > Track Swaps to refresh its saved status, then retry the transfer once the swap has finished.');
528
- return;
529
- }
530
- if (error instanceof AgentError && error.details?.transactionHash) {
531
- console.log(`Saved transfer: ${error.details.requestId}`);
532
- console.log(this.selectedNetwork.explorer + error.details.transactionHash);
533
- }
534
- else {
535
- console.log(`Request ID: ${requestId}`);
536
- }
537
- console.log('Open Transfer Funds to check or resume any saved request before creating another payment.');
538
- return;
539
- }
540
- spinner.succeed(result.status === 'confirmed' ? 'Transaction confirmed!' : 'Transaction submitted; awaiting confirmation.');
541
- console.log(`Request ID: ${requestId}`);
542
- console.log(this.selectedNetwork.explorer + result.transactionHash);
543
- try {
544
- const currentBalance = (await this.network.getAssetBalance(result.from, token)).amount;
545
- await this.addToTransactions(address, token, result.amount, result.transactionHash, currentBalance);
546
- await this.displayTransactionResult(address, token, result.amount, result.transactionHash, currentBalance);
547
- const existingContact = await this.db.get('contact', this.storageNetworkName, address);
548
- if (!existingContact)
549
- await this.addToAddressBook(address);
550
- }
551
- catch (error) {
552
- Wallet.displayError('Transfer recorded; could not update balance, history or contact.', errorMessage(error));
553
- }
554
- }
555
- /**
556
- * @param {any} error
557
- * @returns {void}
558
- */
559
- displayTransactionError(error) {
560
- const reason = typeof error === 'object' && error !== null && 'reason' in error
561
- ? String(error.reason)
562
- : null;
563
- const message = error instanceof Error ? error.message : errorMessage(error);
564
- const data = reason ? reason.replace(/(\w+):/g, "\n$1:").trim() : null;
565
- Wallet.displayError(message, data);
566
- }
567
- /**
568
- * @param {string} message
569
- * @param {unknown} [data]
570
- * @returns {void}
571
- */
572
- static displayError(message, data) {
573
- const table = new AnyTable({
574
- head: [message],
575
- style: { head: ['red'] },
576
- wordWrap: true,
577
- });
578
- if (data) {
579
- table.push([data.toString()]);
580
- }
581
- console.log(table.toString());
582
- }
583
- /**
584
- * @param {string | number | Date} date
585
- * @returns {string}
586
- */
587
- formatDate(date) {
588
- return new Date(date).toLocaleString('en-GB', {
589
- year: 'numeric',
590
- month: '2-digit',
591
- day: '2-digit',
592
- hour: '2-digit',
593
- minute: '2-digit',
594
- hour12: false
595
- }).replace(/(\d{2})\/(\d{2})\/(\d{4})/, '$3-$2-$1').replace(",", "");
596
- }
597
- /**
598
- * @param {string} recipient
599
- * @param {string} token
600
- * @param {string} amount
601
- * @param {string} hash
602
- * @param {string} balance
603
- * @returns {Promise<void>}
604
- */
605
- async addToTransactions(recipient, token, amount, hash, balance) {
606
- const transaction = {
607
- timestamp: new Date().toISOString(),
608
- recipient,
609
- token,
610
- amount,
611
- hash,
612
- balance
613
- };
614
- const address = await this.getAddress();
615
- const history = await this.db.values('transactions', address, this.selectedNetwork.nativeToken) || [];
616
- if (!history.some(entry => entry.hash === hash)) {
617
- await this.db.add('transactions', address, this.selectedNetwork.nativeToken, transaction);
618
- }
619
- }
620
- async showTransactions() {
621
- const address = await this.getAddress();
622
- const history = await this.db.values('transactions', address, this.selectedNetwork.nativeToken) || [];
623
- const legacyHistoryKey = this.selectedNetwork.id === 'op'
624
- ? 'OP'
625
- : this.selectedNetwork.id === 'arb'
626
- ? 'ARB'
627
- : null;
628
- if (legacyHistoryKey) {
629
- const legacyHistory = await this.db.values('transactions', address, legacyHistoryKey) || [];
630
- history.push(...legacyHistory);
631
- history.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
632
- }
633
- const transfers = await new TransferService(this.db, this.selectedNetwork, this.network).list();
634
- for (const transfer of transfers.filter(item => item.from === address)) {
635
- const existing = history.find(item => item.hash === transfer.transactionHash);
636
- if (existing)
637
- existing.status = transfer.status;
638
- else
639
- history.push({
640
- timestamp: transfer.createdAt, recipient: transfer.to, token: transfer.asset,
641
- amount: transfer.amount, hash: transfer.transactionHash, status: transfer.status
642
- });
643
- }
644
- history.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
645
- const table = new AnyTable({
646
- head: ['Date', 'Recipient', 'Contact', 'Token', 'Amount', 'Balance', 'Status'],
647
- style: { head: ['blue'] },
648
- });
649
- if (history.length === 0) {
650
- table.push([{ colSpan: 7, content: 'No transaction history available.' }]);
651
- }
652
- for (const tx of history) {
653
- const date = this.formatDate(tx.timestamp);
654
- const contact = await this.db.get('contact', this.storageNetworkName, tx.recipient);
655
- const contactName = contact ? contact.name : '-';
656
- const amount = this.formatAmount(tx.amount);
657
- const balance = tx.balance !== undefined ? this.formatAmount(tx.balance) : '-';
658
- table.push([date, tx.recipient, contactName, tx.token, amount, balance, tx.status || '-']);
659
- table.push([{ colSpan: 7, content: this.selectedNetwork.explorer + tx.hash }]);
660
- }
661
- console.log(table.toString());
662
- }
663
- /**
664
- * @param {string} address
665
- * @returns {Promise<void>}
666
- */
667
- async addToAddressBook(address) {
668
- const { name } = await inquirer.prompt({
669
- type: 'input',
670
- name: 'name',
671
- message: 'Name for the address book (leave empty to skip):',
672
- });
673
- if (name.trim() !== '') {
674
- await this.db.set('contact', this.storageNetworkName, address, 'name', name);
675
- const table = new AnyTable({
676
- head: [{ colSpan: 2, content: "Recipient saved to the address book." }],
677
- style: { head: ['green'] },
678
- });
679
- table.push([name, address]);
680
- console.log(table.toString());
681
- }
682
- }
683
- async deleteFromAddressBook() {
684
- const contacts = await this.db.get('contact', this.storageNetworkName) || {};
685
- const addressBook = Object.entries(contacts).map(([address, data]) => ({
686
- address,
687
- name: data.name
688
- }));
689
- if (addressBook.length === 0) {
690
- const table = new AnyTable({
691
- head: ['Address Book'],
692
- style: { head: ['yellow'] },
693
- });
694
- table.push(['No addresses in the address book.']);
695
- console.log(table.toString());
696
- return;
697
- }
698
- addressBook.push({ name: 'Go Back', address: '' });
699
- const { addressToDelete } = await inquirer.prompt({
700
- type: 'list',
701
- name: 'addressToDelete',
702
- message: 'Select an address to delete:',
703
- choices: addressBook.map(entry => ({
704
- name: entry.address ? `${entry.address} (${entry.name})` : entry.name,
705
- value: entry.address
706
- }))
707
- });
708
- if (!addressToDelete)
709
- return;
710
- const { confirmDelete } = await inquirer.prompt({
711
- type: 'confirm',
712
- name: 'confirmDelete',
713
- message: 'Are you sure you want to delete this address?',
714
- default: false
715
- });
716
- if (confirmDelete) {
717
- await this.db.del('contact', this.storageNetworkName, addressToDelete);
718
- const table = new AnyTable({
719
- head: ['Address Book'],
720
- style: { head: ['green'] },
721
- });
722
- table.push(['Address deleted successfully.']);
723
- console.log(table.toString());
724
- }
725
- }
726
- /**
727
- * @param {string} address
728
- * @param {string} token
729
- * @param {string} amount
730
- * @param {string} hash
731
- * @param {string} balance
732
- * @returns {Promise<void>}
733
- */
734
- async displayTransactionResult(address, token, amount, hash, balance) {
735
- const table = new AnyTable({
736
- head: ['Date', 'Recipient', 'Contact', 'Token', 'Amount', 'Balance'],
737
- style: { head: ['green'] },
738
- });
739
- const date = this.formatDate(new Date());
740
- const contact = await this.db.get('contact', this.storageNetworkName, address);
741
- const contactName = contact ? contact.name : '-';
742
- table.push([date, address, contactName, token, this.formatAmount(amount), this.formatAmount(balance)]);
743
- table.push([{ colSpan: 6, content: this.selectedNetwork.explorer + hash }]);
744
- console.log(table.toString());
745
- }
746
- async clearAccountData() {
747
- try {
748
- await this.db.dispose();
749
- }
750
- finally {
751
- this.securePersistFile();
752
- this.profileLock.release();
753
- }
754
- }
755
- get storageNetworkName() { return networkStorageName(this.selectedNetwork); }
756
- getSwapRoute() { return routeForNetwork(this.selectedNetwork.id); }
757
- async swapFunds() {
758
- const route = this.getSwapRoute();
759
- if (!route)
760
- throw new Error('No swap route for the selected network.');
761
- await startSwapMenu(new SwapService(this.db, { routeId: route.id }));
762
- }
763
- securePersistFile() {
764
- const persistPath = path.join(this.hodlDir, 'persist.json');
765
- if (fs.existsSync(persistPath)) {
766
- fs.chmodSync(persistPath, 0o600);
767
- }
768
- }
769
- async displayAccountDetails() {
770
- const table = new AnyTable({
771
- head: [{ colSpan: 2, content: 'Account Details' }],
772
- style: { head: ['green'] },
773
- wordWrap: true
774
- });
775
- const account = await this.getAccount();
776
- if (!account) {
777
- Wallet.displayError('Account not initialized.');
778
- return;
779
- }
780
- table.push(['Address', await this.getAddress()], ['Private-key', account.privateKey]);
781
- const mnemonic = await this.getMnemonic();
782
- if (mnemonic) {
783
- table.push(['Mnemonic Phrase', mnemonic]);
784
- table.push(['WARNING', "Please keep your private-key and mnemonic phrase secure. Never share it."]);
785
- }
786
- else {
787
- table.push(['WARNING', "Please keep your private-key secure. Never share it."]);
788
- }
789
- console.log(table.toString());
790
- Persist.clearSensitiveData(account);
791
- }
792
- async switchNetwork() {
793
- const networkPlugins = await this.loadNetworkPlugins();
794
- await this.selectNetwork(networkPlugins);
795
- this.network = new this.selectedNetwork.NetworkClass(this.selectedNetwork);
796
- this.network.name = this.selectedNetwork.name;
797
- if (await this.hasAccount()) {
798
- await this.displayAccountAddress();
799
- }
800
- else {
801
- console.log(`\nNo account found for ${this.selectedNetwork.name}. Please create or import an account.`);
802
- await this.loadAccount(true);
803
- }
804
- }
805
- async exportHODLFile() {
806
- const address = await this.getAddress();
807
- const defaultFileName = `${address.slice(-6).toUpperCase()}`;
808
- let { fileName } = await inquirer.prompt({
809
- type: 'input',
810
- name: 'fileName',
811
- message: 'Enter the name for the HODL file:',
812
- default: defaultFileName
813
- });
814
- fileName += '.HODL';
815
- const data = await this.db.get();
816
- const encryptionKey = await UIManager.getEncryptionKey();
817
- try {
818
- const encryptedData = Persist.encrypt(data, encryptionKey);
819
- fs.writeFileSync(fileName, encryptedData);
820
- }
821
- finally {
822
- Persist.clearSensitiveData(data);
823
- }
824
- const table = new AnyTable({
825
- head: ['HODL File Exported'],
826
- style: { head: ['green'] }
827
- });
828
- table.push([`File saved as: ${fileName}`]);
829
- console.log(table.toString());
830
- }
831
- async importHODLFile() {
832
- // Scan current directory for .HODL files
833
- const currentDir = process.cwd();
834
- let hodlFiles = [];
835
- try {
836
- const files = fs.readdirSync(currentDir);
837
- hodlFiles = files.filter(file => file.endsWith('.HODL'));
838
- }
839
- catch (error) {
840
- console.error('Error reading directory:', errorMessage(error));
841
- }
842
- // Create file options array for autocomplete
843
- const fileOptions = hodlFiles.map(file => ({
844
- name: file,
845
- value: path.join(currentDir, file)
846
- }));
847
- // Use autocomplete pattern similar to transferFunds
848
- const { filePath } = await inquirer.prompt({
849
- type: 'autocomplete',
850
- name: 'filePath',
851
- message: 'HODL file path:',
852
- source: (_answersSoFar, input = '') => {
853
- input = input || '';
854
- const filenameOptions = fileOptions
855
- .filter(entry => entry.name.toLowerCase().includes(input.toLowerCase()) || entry.value.toLowerCase().includes(input.toLowerCase()))
856
- .map(entry => ({
857
- name: entry.name,
858
- value: entry.value
859
- }));
860
- // Put path options first, then filename options, then manual input
861
- return [{ name: input, value: input }, ...filenameOptions];
862
- },
863
- });
864
- // If user leaves empty, skip the operation
865
- if (!filePath || filePath.trim() === '') {
866
- return null;
867
- }
868
- // Validate the file path
869
- if (!filePath.endsWith('.HODL')) {
870
- Wallet.displayError('File must have .HODL extension');
871
- return null;
872
- }
873
- if (!fs.existsSync(filePath)) {
874
- Wallet.displayError('File not found.');
875
- return null;
876
- }
877
- const encryptedData = fs.readFileSync(filePath, 'utf8');
878
- const encryptionKey = await UIManager.getEncryptionKey();
879
- let importedData;
880
- try {
881
- importedData = Persist.decrypt(encryptedData, encryptionKey);
882
- await this.db.set(importedData);
883
- return await this.getAccount();
884
- }
885
- catch (error) {
886
- Wallet.displayError('Failed to import HODL file.', 'The password is incorrect.');
887
- return null;
888
- }
889
- finally {
890
- Persist.clearSensitiveData(importedData);
891
- }
892
- }
893
- async importPrivateKey() {
894
- const { privateKey } = await inquirer.prompt({
895
- type: 'password',
896
- name: 'privateKey',
897
- message: 'Private-key (leave empty to cancel):',
898
- mask: '*',
899
- validate: (input) => {
900
- if (input.trim() === '')
901
- return true;
902
- return this.network.validatePrivateKey(input) ||
903
- 'Please enter a valid private-key for the selected network or leave empty to cancel.';
904
- }
905
- });
906
- if (!privateKey.trim()) {
907
- return; // Silently return if empty
908
- }
909
- try {
910
- await this.setAccount(await this.network.privateKeyToAccount(privateKey));
911
- await this.displayAccountAddress();
912
- }
913
- catch (error) {
914
- Wallet.displayError('Invalid private-key.');
915
- }
916
- }
917
- async createNewAccount() {
918
- let existingMnemonic = await this.getMnemonic();
919
- let useMnemonic = false;
920
- if (existingMnemonic) {
921
- const { useExistingMnemonic } = await inquirer.prompt({
922
- type: 'confirm',
923
- name: 'useExistingMnemonic',
924
- message: 'Use existing mnemonic to create account?',
925
- default: true
926
- });
927
- if (useExistingMnemonic) {
928
- useMnemonic = true;
929
- await this.setAccount(await this.network.accountFromMnemonic(existingMnemonic));
930
- existingMnemonic = null;
931
- }
932
- }
933
- if (!useMnemonic) {
934
- const { createWithMnemonic } = await inquirer.prompt({
935
- type: 'confirm',
936
- name: 'createWithMnemonic',
937
- message: 'Create account with mnemonic?',
938
- default: true
939
- });
940
- if (createWithMnemonic) {
941
- // Ask for mnemonic word count
942
- const { wordCount } = await inquirer.prompt({
943
- type: 'list',
944
- name: 'wordCount',
945
- message: 'Choose mnemonic phrase length:',
946
- choices: [
947
- { name: '12 words (standard)', value: 12 },
948
- { name: '24 words (more secure)', value: 24 }
949
- ],
950
- default: 12
951
- });
952
- await this.setAccount(await this.network.createAccountFromMnemonic(wordCount));
953
- }
954
- else {
955
- await this.setAccount(await this.network.createAccount());
956
- }
957
- }
958
- // Check if there are addresses in the address book
959
- const addressBook = await this.db.entries('contact', this.storageNetworkName) || [];
960
- if (addressBook.length > 0) {
961
- const { deleteAddresses } = await inquirer.prompt({
962
- type: 'confirm',
963
- name: 'deleteAddresses',
964
- message: `Do you want to delete all ${addressBook.length} addresses from the previous account?`,
965
- default: false
966
- });
967
- if (deleteAddresses) {
968
- await this.db.del('contact');
969
- const table = new AnyTable({
970
- head: ['Address Book'],
971
- style: { head: ['green'] },
972
- });
973
- table.push(['All addresses deleted successfully.']);
974
- console.log(table.toString());
975
- }
976
- }
977
- let message = 'Do you want to display sensitive information (private key';
978
- if (await this.getMnemonic()) {
979
- message += ' and mnemonic';
980
- }
981
- message += ')?';
982
- const { showSensitive } = await inquirer.prompt({
983
- type: 'confirm',
984
- name: 'showSensitive',
985
- message,
986
- default: false,
987
- });
988
- if (showSensitive) {
989
- await this.displayAccountDetails();
990
- }
991
- else {
992
- await this.displayAccountAddress();
993
- }
994
- }
995
- }
996
- class UIManager {
997
- static displayWelcome() {
998
- console.log('\x1b[32m'); // Set text color to green
999
- console.log(` ░░░░░░░░░░░░░░ █ █ █▀█ █▀▄ █ ░░░░░░░░░░░░░░
1000
- ░░░░░░░░░░░░░░ █▀█ █▄█ █▄▀ █▄▄ ░░░░░░░░░░░░░░
1001
- ░░░░░░░░░░░░░░ ──────── WALLET ░░░░░░░░░░░░░░`);
1002
- console.log('\x1b[0m'); // Reset text color
1003
- }
1004
- static async getEncryptionKey() {
1005
- const { key } = await inquirer.prompt({
1006
- type: 'password',
1007
- name: 'key',
1008
- message: 'Password:',
1009
- mask: '*',
1010
- });
1011
- return key;
1012
- }
1013
- static async confirmEncryptionKey() {
1014
- const { confirmKey } = await inquirer.prompt({
1015
- type: 'password',
1016
- name: 'confirmKey',
1017
- message: 'Repeat Password:',
1018
- mask: '*',
1019
- });
1020
- return confirmKey;
1021
- }
1022
- static displayExitPhrase() {
1023
- const phrases = [
1024
- "Buy the rumor, sell the news",
1025
- "The trend is your friend",
1026
- "Don't fight the tape",
1027
- "Cut your losses and let your profits run",
1028
- "Be fearful when others are greedy, and greedy when others are fearful",
1029
- "The market can remain irrational longer than you can remain solvent",
1030
- "Bulls make money, bears make money, pigs get slaughtered",
1031
- "No one is bigger than the market",
1032
- "Don't catch a falling knife",
1033
- "Past performance is not indicative of future results",
1034
- "The stock market is a device for transferring money from the impatient to the patient",
1035
- "Time in the market beats timing the market",
1036
- "Buy low, sell high",
1037
- "Diversification is the only free lunch in investing",
1038
- "The four most dangerous words in investing are: 'This time it's different'",
1039
- "Markets can remain irrational a lot longer than you and I can remain solvent",
1040
- "Risk comes from not knowing what you're doing",
1041
- "In the short run, the market is a voting machine. In the long run, it's a weighing machine",
1042
- "Invest in yourself. Your career is the engine of your wealth",
1043
- "Who has the gold makes the rules",
1044
- "The best time to invest was yesterday. The second best time is now",
1045
- "Don't put all your eggs in one basket",
1046
- "Knowledge is power in the world of investing",
1047
- "Patience is a virtue in the stock market",
1048
- "The market is never wrong, but opinions often are"
1049
- ];
1050
- const randomPhrase = phrases[Math.floor(Math.random() * phrases.length)];
1051
- const table = new AnyTable({
1052
- head: ['✨ Good bye!'],
1053
- style: { head: ['yellow'] },
1054
- wordWrap: true,
1055
- });
1056
- table.push([randomPhrase]);
1057
- console.log(table.toString());
1058
- }
1059
- }
1060
- async function mainMenu(wallet) {
1061
- const route = wallet.getSwapRoute();
1062
- const { action } = await inquirer.prompt({
1063
- type: 'list',
1064
- name: 'action',
1065
- message: 'What would you like to do?',
1066
- choices: [
1067
- { name: 'Transfer Funds', value: 'transferFunds' },
1068
- { name: 'Show Balance', value: 'balance' },
1069
- { name: 'Show Sent Transfers', value: 'showTransactions' },
1070
- { name: 'Account Settings', value: 'account' },
1071
- ...(route ? [{ name: 'Swap', value: 'swapFunds' }] : []),
1072
- { name: 'Exit', value: 'exit' }
1073
- ],
1074
- });
1075
- switch (action) {
1076
- case 'swapFunds':
1077
- await wallet.swapFunds();
1078
- return mainMenu(wallet);
1079
- case 'transferFunds':
1080
- await wallet.transferFunds();
1081
- return mainMenu(wallet);
1082
- case 'balance':
1083
- await wallet.showBalance();
1084
- return mainMenu(wallet);
1085
- case 'showTransactions':
1086
- await wallet.showTransactions();
1087
- return mainMenu(wallet);
1088
- case 'account':
1089
- await wallet.loadAccount(true);
1090
- return mainMenu(wallet);
1091
- case 'exit':
1092
- return;
1093
- }
1094
- }
1095
- let activeWallet = null;
25
+ let activeSession = null;
1096
26
  let shutdownPromise = null;
1097
27
  async function shutdown() {
1098
28
  if (!shutdownPromise) {
1099
29
  shutdownPromise = (async () => {
1100
- if (activeWallet) {
1101
- await activeWallet.clearAccountData();
1102
- activeWallet = null;
30
+ if (activeSession) {
31
+ await activeSession.close();
32
+ activeSession = null;
1103
33
  }
1104
- UIManager.displayExitPhrase();
34
+ farewell(ui);
1105
35
  })();
1106
36
  }
1107
37
  await shutdownPromise;
1108
38
  }
1109
39
  async function run() {
1110
- UIManager.displayWelcome();
40
+ welcome(ui);
1111
41
  const hodlDir = path.join(os.homedir(), '.HODL');
1112
42
  fs.mkdirSync(hodlDir, { recursive: true, mode: 0o700 });
1113
43
  const profileLock = new ProfileLock(path.join(hodlDir, '.default.lock'));
@@ -1118,42 +48,40 @@ async function run() {
1118
48
  if (!(error instanceof ProfileLockedError)) {
1119
49
  throw error;
1120
50
  }
1121
- Wallet.displayError(error.owner === null
51
+ showError(ui, error.owner === null
1122
52
  ? 'The HODL profile is locked. Close the other instance and try again.'
1123
53
  : `HODL is already open in another terminal (PID ${error.owner}). Close that instance and try again.`);
1124
54
  return;
1125
55
  }
1126
56
  let encryptionKey = null;
1127
57
  try {
1128
- encryptionKey = await UIManager.getEncryptionKey();
1129
- const wallet = new Wallet(encryptionKey, profileLock);
1130
- activeWallet = wallet;
1131
- const databaseExisted = wallet.hasStoredDatabase();
58
+ encryptionKey = await ui.password({ message: 'Password:' });
59
+ const session = new NodeSession(encryptionKey, profileLock);
60
+ activeSession = session;
61
+ const databaseExisted = session.hasStoredDatabase();
1132
62
  try {
1133
- await wallet.connect();
63
+ await session.connect();
1134
64
  }
1135
65
  catch (error) {
1136
66
  console.error(errorMessage(error));
1137
- Wallet.displayError('Wrong password.');
67
+ showError(ui, 'Wrong password.');
1138
68
  process.exitCode = 1;
1139
69
  return;
1140
70
  }
1141
71
  if (!databaseExisted) {
1142
- const confirmedKey = await UIManager.confirmEncryptionKey();
72
+ const confirmedKey = await ui.password({ message: 'Repeat Password:' });
1143
73
  if (confirmedKey !== encryptionKey) {
1144
- Wallet.displayError('Passwords do not match. Please try again.');
74
+ showError(ui, 'Passwords do not match. Please try again.');
1145
75
  process.exitCode = 1;
1146
76
  return;
1147
77
  }
1148
78
  }
1149
79
  encryptionKey = null;
1150
80
  try {
1151
- await wallet.initialize();
1152
- await wallet.displayAccountAddress();
1153
- await mainMenu(wallet);
81
+ await runWallet(ui, session);
1154
82
  }
1155
83
  catch (error) {
1156
- Wallet.displayError('Failed to initialize wallet.', error);
84
+ showError(ui, 'Failed to initialize wallet.', error);
1157
85
  process.exitCode = 1;
1158
86
  }
1159
87
  }
@@ -1193,7 +121,7 @@ async function runEntrypoint(argv) {
1193
121
  }
1194
122
  catch (error) {
1195
123
  if (!(error instanceof Error && error.name === 'ExitPromptError')) {
1196
- Wallet.displayError('Unexpected error.', error);
124
+ showError(ui, 'Unexpected error.', error);
1197
125
  process.exitCode = 1;
1198
126
  }
1199
127
  await shutdown();