@sundaeswap/sprinkles 0.8.2 → 0.9.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.
Files changed (29) hide show
  1. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js +19 -7
  2. package/dist/cjs/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  3. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js +3 -0
  4. package/dist/cjs/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  5. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js +109 -98
  6. package/dist/cjs/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  7. package/dist/cjs/Sprinkle/index.js +474 -177
  8. package/dist/cjs/Sprinkle/index.js.map +1 -1
  9. package/dist/cjs/Sprinkle/types.js.map +1 -1
  10. package/dist/esm/Sprinkle/__tests__/enhancements.test.js +19 -7
  11. package/dist/esm/Sprinkle/__tests__/enhancements.test.js.map +1 -1
  12. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js +3 -0
  13. package/dist/esm/Sprinkle/__tests__/fill-in-struct.test.js.map +1 -1
  14. package/dist/esm/Sprinkle/__tests__/show-menu.test.js +110 -99
  15. package/dist/esm/Sprinkle/__tests__/show-menu.test.js.map +1 -1
  16. package/dist/esm/Sprinkle/index.js +477 -180
  17. package/dist/esm/Sprinkle/index.js.map +1 -1
  18. package/dist/esm/Sprinkle/types.js.map +1 -1
  19. package/dist/types/Sprinkle/index.d.ts +29 -0
  20. package/dist/types/Sprinkle/index.d.ts.map +1 -1
  21. package/dist/types/Sprinkle/types.d.ts +6 -0
  22. package/dist/types/Sprinkle/types.d.ts.map +1 -1
  23. package/dist/types/tsconfig.build.tsbuildinfo +1 -1
  24. package/package.json +1 -1
  25. package/src/Sprinkle/__tests__/enhancements.test.ts +20 -7
  26. package/src/Sprinkle/__tests__/fill-in-struct.test.ts +1 -0
  27. package/src/Sprinkle/__tests__/show-menu.test.ts +132 -116
  28. package/src/Sprinkle/index.ts +546 -186
  29. package/src/Sprinkle/types.ts +6 -0
@@ -2,8 +2,8 @@ function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object
2
2
  function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
3
3
  function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
4
4
  import { Core, HotWallet } from "@blaze-cardano/sdk";
5
- import { CborSet, VkeyWitness, TxCBOR } from "@blaze-cardano/core";
6
- import { selectCancellable, selectWithClear, inputCancellable, inputWithClear, passwordWithClear, confirmCancellable, searchCancellable } from "./prompts.js";
5
+ import { CborSet, VkeyWitness, TxCBOR, wordlist } from "@blaze-cardano/core";
6
+ import { selectCancellable, selectWithClear, inputCancellable, inputWithClear, passwordCancellable, passwordWithClear, confirmCancellable, searchCancellable, select } from "./prompts.js";
7
7
  import colors from "yoctocolors-cjs";
8
8
  import { OptionalKind } from "@sinclair/typebox";
9
9
  import { Value } from "@sinclair/typebox/value";
@@ -31,7 +31,7 @@ import { GetProvider as GetProviderFn, GetWallet as GetWalletFn, GetBlaze as Get
31
31
  export { GetProvider, GetWallet, GetBlaze } from "./wallet.js";
32
32
 
33
33
  // Import encryption utilities
34
- import { bigIntReplacer, bigIntReviver, encryptSensitiveFields, decryptSensitiveFields, maskSensitiveFields } from "./encryption.js";
34
+ import { collectSensitivePaths, getNestedValue, setNestedValue, bigIntReplacer, bigIntReviver, encryptSensitiveFields, decryptSensitiveFields, maskSensitiveFields } from "./encryption.js";
35
35
 
36
36
  // Import tx-dialog utilities
37
37
  import { countSignatures, getRequiredSigners, getTxBodyHash, formatHash, mergeSignatures } from "./tx-dialog.js";
@@ -249,9 +249,133 @@ export class Sprinkle {
249
249
  }
250
250
  }
251
251
  }
252
+
253
+ // --- Importing profiles from external files ---
254
+
255
+ static globToRegex(pattern) {
256
+ const escaped = pattern.split("*").map(p => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
257
+ return new RegExp(`^${escaped}$`);
258
+ }
259
+
260
+ /**
261
+ * Scan the current working directory for files matching `importPattern`.
262
+ * Returns absolute paths. Designed to surface state files committed to a
263
+ * project repo (e.g. `state.preview.json`) that the consumer might want
264
+ * to restore from.
265
+ */
266
+ findImportableProfiles(cwd = process.cwd()) {
267
+ if (this.options.importPattern === null) return [];
268
+ const pattern = this.options.importPattern ?? "state.*.json";
269
+ const regex = Sprinkle.globToRegex(pattern);
270
+ try {
271
+ return fs.readdirSync(cwd).filter(f => regex.test(f)).map(f => path.join(cwd, f));
272
+ } catch {
273
+ return [];
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Read a profile or raw-settings JSON file and save it as a new local
279
+ * profile. Prompts for missing sensitive fields before writing.
280
+ */
281
+ async importProfileFromFile(filePath) {
282
+ const content = fs.readFileSync(filePath, "utf-8");
283
+ const parsed = JSON.parse(content, Sprinkle.bigIntReviver);
284
+ let importedSettings;
285
+ let importedMeta;
286
+ let importedDefaults = {};
287
+ if (parsed && typeof parsed === "object" && "settings" in parsed && "meta" in parsed) {
288
+ const obj = parsed;
289
+ importedMeta = obj.meta;
290
+ importedSettings = obj.settings;
291
+ importedDefaults = obj.defaults ?? {};
292
+ } else {
293
+ importedSettings = parsed;
294
+ }
295
+
296
+ // If the file came from this same Sprinkle install (same encryption),
297
+ // decrypt sensitive fields. Otherwise we'll re-prompt for missing ones below.
298
+ try {
299
+ importedSettings = await this.decryptSettings(importedSettings);
300
+ } catch {
301
+ // ignore - encrypted with a different key, will re-prompt
302
+ }
303
+ const baseName = path.basename(filePath).replace(/\.json$/, "");
304
+ const meta = await this.promptProfileMeta(importedMeta?.name ?? baseName, importedMeta?.description);
305
+ if (meta === null) return; // user cancelled
306
+ const {
307
+ name,
308
+ description
309
+ } = meta;
310
+
311
+ // Re-prompt any sensitive fields that are missing or look encrypted
312
+ const sensitivePaths = collectSensitivePaths(this.type);
313
+ for (const p of sensitivePaths) {
314
+ const value = getNestedValue(importedSettings, p);
315
+ if (typeof value !== "string" || value.length === 0) {
316
+ const entered = await passwordCancellable({
317
+ message: `Enter value for sensitive field "${p}":`
318
+ });
319
+ if (entered === null) return; // user cancelled
320
+ setNestedValue(importedSettings, p, entered);
321
+ }
322
+ }
323
+ const profilesDir = Sprinkle.profilesDir(this.storagePath);
324
+ if (!fs.existsSync(profilesDir)) {
325
+ fs.mkdirSync(profilesDir, {
326
+ recursive: true
327
+ });
328
+ }
329
+ const id = Sprinkle.findAvailableId(profilesDir, Sprinkle.sanitizeProfileId(name));
330
+ const now = new Date().toISOString();
331
+ this.profileId = id;
332
+ this.profileMeta = {
333
+ name,
334
+ description,
335
+ createdAt: now,
336
+ updatedAt: now
337
+ };
338
+ this.settings = importedSettings;
339
+ this.defaults = importedDefaults;
340
+ this.saveProfile();
341
+ fs.writeFileSync(Sprinkle.activeProfilePath(this.storagePath), id, "utf-8");
342
+ }
343
+ async promptImportFromCwd() {
344
+ const importable = this.findImportableProfiles();
345
+ if (importable.length === 0) return false;
346
+ const cancelToken = "__cancel__";
347
+ const choices = [...importable.map(f => ({
348
+ name: `Import ${path.basename(f)}`,
349
+ value: f
350
+ })), {
351
+ name: "Enter a different path...",
352
+ value: "__custom__"
353
+ }, {
354
+ name: "Skip import",
355
+ value: cancelToken
356
+ }];
357
+ const choice = await select({
358
+ message: "Found importable state file(s) in this directory. Restore from one?",
359
+ choices
360
+ });
361
+ if (choice === null || choice === cancelToken) return false;
362
+ let filePath = choice;
363
+ if (choice === "__custom__") {
364
+ const entered = await inputCancellable({
365
+ message: "Path to state file:",
366
+ validate: v => v.trim().length > 0 ? true : "Path cannot be empty"
367
+ });
368
+ if (entered === null) return false;
369
+ filePath = entered;
370
+ }
371
+ await this.importProfileFromFile(filePath);
372
+ return true;
373
+ }
252
374
  async selectOrCreateProfile() {
253
375
  const profiles = this.scanProfiles();
254
376
  if (profiles.length === 0) {
377
+ const imported = await this.promptImportFromCwd();
378
+ if (imported) return;
255
379
  await this.createProfile();
256
380
  return;
257
381
  }
@@ -374,8 +498,318 @@ export class Sprinkle {
374
498
 
375
499
  // --- Menu ---
376
500
 
501
+ async importProfileInteractive() {
502
+ const importable = this.findImportableProfiles();
503
+ const customToken = "__custom__";
504
+ let filePath;
505
+ if (importable.length > 0) {
506
+ const choice = await select({
507
+ message: "Select a file to import:",
508
+ choices: [...importable.map(f => ({
509
+ name: path.basename(f),
510
+ value: f
511
+ })), {
512
+ name: "Enter a different path...",
513
+ value: customToken
514
+ }]
515
+ });
516
+ if (choice === null) return; // user cancelled
517
+ if (choice === customToken) {
518
+ const entered = await inputCancellable({
519
+ message: "Path to state file:",
520
+ validate: v => v.trim().length > 0 ? true : "Path cannot be empty"
521
+ });
522
+ if (entered === null) return;
523
+ filePath = entered;
524
+ } else {
525
+ filePath = choice;
526
+ }
527
+ } else {
528
+ const entered = await inputCancellable({
529
+ message: "Path to state file:",
530
+ validate: v => v.trim().length > 0 ? true : "Path cannot be empty"
531
+ });
532
+ if (entered === null) return;
533
+ filePath = entered;
534
+ }
535
+ try {
536
+ await this.importProfileFromFile(filePath);
537
+ console.log(`Imported profile "${this.profileMeta.name}".`);
538
+ } catch (error) {
539
+ console.error(`Import failed: ${error.message}`);
540
+ }
541
+ }
542
+ buildSettingsMenu() {
543
+ return {
544
+ title: "Settings & Profiles",
545
+ items: [{
546
+ title: "View settings",
547
+ action: async () => {
548
+ const jsonStr = JSON.stringify(this.getDisplaySettings(), bigIntReplacer, 2);
549
+ const jsonLines = jsonStr.split("\n").length;
550
+ console.log(jsonStr);
551
+
552
+ // Wait for user to press Enter
553
+ await selectWithClear({
554
+ message: "Press Enter to continue...",
555
+ choices: [{
556
+ name: "Continue",
557
+ value: "continue"
558
+ }]
559
+ });
560
+
561
+ // Clear the JSON output
562
+ process.stdout.write("\x1b[1A\x1b[2K".repeat(jsonLines) + "\x1b[G");
563
+ }
564
+ }, {
565
+ title: "Edit settings",
566
+ action: async () => {
567
+ try {
568
+ this.settings = await this.EditStruct(this.type, this.settings);
569
+ this.saveSettings();
570
+ } catch (e) {
571
+ if (e instanceof UserCancelledError) {
572
+ return; // User cancelled, return to menu
573
+ }
574
+ throw e;
575
+ }
576
+ }
577
+ }, {
578
+ title: "Switch profile",
579
+ action: async () => {
580
+ this.saveSettings();
581
+ const profiles = this.scanProfiles();
582
+ if (profiles.length <= 1) {
583
+ console.log("No other profiles to switch to. Create a new one first.");
584
+ } else {
585
+ await this.selectProfile(profiles);
586
+ }
587
+ }
588
+ }, {
589
+ title: "Create new profile",
590
+ action: async () => {
591
+ await this.createProfile();
592
+ }
593
+ }, {
594
+ title: "Duplicate current profile",
595
+ action: async () => {
596
+ await this.duplicateProfile();
597
+ }
598
+ }, {
599
+ title: "Rename current profile",
600
+ action: async () => {
601
+ await this.renameProfile();
602
+ }
603
+ }, {
604
+ title: "Delete a profile",
605
+ action: async () => {
606
+ await this.deleteProfile();
607
+ }
608
+ }, {
609
+ title: "Import profile from file",
610
+ action: async () => {
611
+ await this.importProfileInteractive();
612
+ }
613
+ }, {
614
+ title: "Addressbook",
615
+ items: [{
616
+ title: "View entries",
617
+ action: async () => {
618
+ const entries = Object.entries(this.addressbook);
619
+ if (entries.length === 0) {
620
+ console.log("Addressbook is empty.");
621
+ } else {
622
+ for (const [name, ms] of entries) {
623
+ const json = JSON.stringify(ms, bigIntReplacer, 2);
624
+ let hashStr = "unknown";
625
+ try {
626
+ hashStr = toNativeScript(ms).hash();
627
+ } catch {/* skip */}
628
+ console.log(colors.bold(name) + " " + colors.dim(hashStr));
629
+ console.log(json);
630
+ console.log();
631
+ }
632
+ }
633
+ await selectWithClear({
634
+ message: "Press Enter to continue...",
635
+ choices: [{
636
+ name: "Continue",
637
+ value: "continue"
638
+ }]
639
+ });
640
+ }
641
+ }, {
642
+ title: "Add entry",
643
+ action: async () => {
644
+ const name = await inputCancellable({
645
+ message: "Entry name:",
646
+ validate: v => v.trim().length > 0 ? true : "Name cannot be empty"
647
+ });
648
+ if (name === null) return;
649
+ if (this.addressbook[name]) {
650
+ const overwrite = await confirmCancellable({
651
+ message: `Entry "${name}" already exists. Overwrite?`,
652
+ default: false
653
+ });
654
+ if (!overwrite) return;
655
+ }
656
+ try {
657
+ const script = await this.FillInStruct(MultisigScriptSchema);
658
+ this.addressbook[name] = script;
659
+ this.saveSettings();
660
+ console.log(`Added "${name}" to addressbook.`);
661
+ } catch (e) {
662
+ if (e instanceof UserCancelledError) return;
663
+ throw e;
664
+ }
665
+ }
666
+ }, {
667
+ title: "Edit entry",
668
+ action: async () => {
669
+ const entries = Object.keys(this.addressbook);
670
+ if (entries.length === 0) {
671
+ console.log("Addressbook is empty.");
672
+ return;
673
+ }
674
+ const selected = await selectCancellable({
675
+ message: "Select entry to edit:",
676
+ choices: entries.map(n => ({
677
+ name: n,
678
+ value: n
679
+ }))
680
+ });
681
+ if (selected === null) return;
682
+ const editName = selected;
683
+ try {
684
+ const updated = await this.EditStruct(MultisigScriptSchema, this.addressbook[editName]);
685
+ this.addressbook[editName] = updated;
686
+ this.saveSettings();
687
+ console.log(`Updated "${editName}".`);
688
+ } catch (e) {
689
+ if (e instanceof UserCancelledError) return;
690
+ throw e;
691
+ }
692
+ }
693
+ }, {
694
+ title: "Delete entry",
695
+ action: async () => {
696
+ const entries = Object.keys(this.addressbook);
697
+ if (entries.length === 0) {
698
+ console.log("Addressbook is empty.");
699
+ return;
700
+ }
701
+ const delSelected = await selectCancellable({
702
+ message: "Select entry to delete:",
703
+ choices: entries.map(n => ({
704
+ name: n,
705
+ value: n
706
+ }))
707
+ });
708
+ if (delSelected === null) return;
709
+ const delName = delSelected;
710
+ const confirmed = await confirmCancellable({
711
+ message: `Delete "${delName}"?`,
712
+ default: false
713
+ });
714
+ if (!confirmed) return;
715
+ delete this.addressbook[delName];
716
+ this.saveSettings();
717
+ console.log(`Deleted "${delName}".`);
718
+ }
719
+ }]
720
+ }]
721
+ };
722
+ }
377
723
  async showMenu(menu) {
378
- return this._showMenu(menu, true, [menu.title]);
724
+ return this._searchMenu(menu);
725
+ }
726
+
727
+ // --- Flat search-based top-level menu ---
728
+
729
+ _flattenLeaves(menu, prefix, beforeShowChain, isRoot) {
730
+ // Root's title is omitted from breadcrumbs (no "Main Menu › " prefix on
731
+ // every leaf). Root's beforeShow is fired by the search loop, not as part
732
+ // of the leaf chain (otherwise it would fire twice).
733
+ const chain = !isRoot && menu.beforeShow ? [...beforeShowChain, menu.beforeShow] : beforeShowChain;
734
+ const breadcrumbPrefix = isRoot ? prefix : [...prefix, menu.title];
735
+ const leaves = [];
736
+ for (const item of menu.items) {
737
+ if ("action" in item) {
738
+ leaves.push({
739
+ breadcrumb: [...breadcrumbPrefix, item.title].join(" › "),
740
+ leafTitle: item.title,
741
+ action: item.action,
742
+ beforeShowChain: chain
743
+ });
744
+ } else {
745
+ leaves.push(...this._flattenLeaves(item, breadcrumbPrefix, chain, false));
746
+ }
747
+ }
748
+ return leaves;
749
+ }
750
+ static _scoreLeaf(leaf, tokens) {
751
+ if (tokens.length === 0) return 1;
752
+ const crumbLower = leaf.breadcrumb.toLowerCase();
753
+ const titleLower = leaf.leafTitle.toLowerCase();
754
+ let score = 0;
755
+ for (const t of tokens) {
756
+ if (!crumbLower.includes(t)) return 0; // require every token
757
+ if (titleLower.includes(t)) {
758
+ score += 3; // matches in the leaf title outrank ancestor matches
759
+ } else {
760
+ score += 1;
761
+ }
762
+ }
763
+ return score;
764
+ }
765
+ _filterLeaves(leaves, term) {
766
+ const trimmed = term.trim();
767
+ if (!trimmed) return leaves;
768
+ const tokens = trimmed.toLowerCase().split(/\s+/);
769
+ return leaves.map(leaf => ({
770
+ leaf,
771
+ score: Sprinkle._scoreLeaf(leaf, tokens)
772
+ })).filter(x => x.score > 0).sort((a, b) => b.score - a.score).map(x => x.leaf);
773
+ }
774
+ async _searchMenu(menu) {
775
+ const EXIT_TITLE = "Exit";
776
+ while (true) {
777
+ // Root beforeShow fires once per iteration so consumers can refresh any
778
+ // dynamic state before the search prompt is built.
779
+ if (menu.beforeShow) {
780
+ await menu.beforeShow(this);
781
+ }
782
+ const userLeaves = this._flattenLeaves(menu, [], [], true);
783
+ const settingsLeaves = this._flattenLeaves(this.buildSettingsMenu(), [], [], false);
784
+ const exitLeaf = {
785
+ breadcrumb: EXIT_TITLE,
786
+ leafTitle: EXIT_TITLE,
787
+ action: async () => {},
788
+ beforeShowChain: [],
789
+ isExit: true
790
+ };
791
+ const allLeaves = [...userLeaves, ...settingsLeaves, exitLeaf];
792
+ const selected = await searchCancellable({
793
+ message: "What would you like to do?",
794
+ source: async term => {
795
+ return this._filterLeaves(allLeaves, term ?? "").map(leaf => ({
796
+ name: leaf.breadcrumb,
797
+ value: leaf
798
+ }));
799
+ }
800
+ });
801
+ if (selected === null || selected.isExit) return;
802
+
803
+ // Fire submenu beforeShow hooks along the path to the selected leaf
804
+ for (const beforeShow of selected.beforeShowChain) {
805
+ await beforeShow(this);
806
+ }
807
+ const result = await selected.action(this);
808
+ if (result instanceof Sprinkle) {
809
+ this.settings = result.settings;
810
+ this.saveSettings();
811
+ }
812
+ }
379
813
  }
380
814
  async _showMenu(menu, main, path, clearPrevious = false) {
381
815
  // Clear previous breadcrumb if coming back from action/submenu
@@ -484,181 +918,7 @@ export class Sprinkle {
484
918
  return;
485
919
  }
486
920
  if (selection === -5) {
487
- const settingsMenu = {
488
- title: "Settings & Profiles",
489
- items: [{
490
- title: "View settings",
491
- action: async () => {
492
- const jsonStr = JSON.stringify(this.getDisplaySettings(), bigIntReplacer, 2);
493
- const jsonLines = jsonStr.split("\n").length;
494
- console.log(jsonStr);
495
-
496
- // Wait for user to press Enter
497
- await selectWithClear({
498
- message: "Press Enter to continue...",
499
- choices: [{
500
- name: "Continue",
501
- value: "continue"
502
- }]
503
- });
504
-
505
- // Clear the JSON output
506
- process.stdout.write("\x1b[1A\x1b[2K".repeat(jsonLines) + "\x1b[G");
507
- }
508
- }, {
509
- title: "Edit settings",
510
- action: async () => {
511
- try {
512
- this.settings = await this.EditStruct(this.type, this.settings);
513
- this.saveSettings();
514
- } catch (e) {
515
- if (e instanceof UserCancelledError) {
516
- return; // User cancelled, return to menu
517
- }
518
- throw e;
519
- }
520
- }
521
- }, {
522
- title: "Switch profile",
523
- action: async () => {
524
- this.saveSettings();
525
- const profiles = this.scanProfiles();
526
- if (profiles.length <= 1) {
527
- console.log("No other profiles to switch to. Create a new one first.");
528
- } else {
529
- await this.selectProfile(profiles);
530
- }
531
- }
532
- }, {
533
- title: "Create new profile",
534
- action: async () => {
535
- await this.createProfile();
536
- }
537
- }, {
538
- title: "Duplicate current profile",
539
- action: async () => {
540
- await this.duplicateProfile();
541
- }
542
- }, {
543
- title: "Rename current profile",
544
- action: async () => {
545
- await this.renameProfile();
546
- }
547
- }, {
548
- title: "Delete a profile",
549
- action: async () => {
550
- await this.deleteProfile();
551
- }
552
- }, {
553
- title: "Addressbook",
554
- items: [{
555
- title: "View entries",
556
- action: async () => {
557
- const entries = Object.entries(this.addressbook);
558
- if (entries.length === 0) {
559
- console.log("Addressbook is empty.");
560
- } else {
561
- for (const [name, ms] of entries) {
562
- const json = JSON.stringify(ms, bigIntReplacer, 2);
563
- let hashStr = "unknown";
564
- try {
565
- hashStr = toNativeScript(ms).hash();
566
- } catch {/* skip */}
567
- console.log(colors.bold(name) + " " + colors.dim(hashStr));
568
- console.log(json);
569
- console.log();
570
- }
571
- }
572
- await selectWithClear({
573
- message: "Press Enter to continue...",
574
- choices: [{
575
- name: "Continue",
576
- value: "continue"
577
- }]
578
- });
579
- }
580
- }, {
581
- title: "Add entry",
582
- action: async () => {
583
- const name = await inputCancellable({
584
- message: "Entry name:",
585
- validate: v => v.trim().length > 0 ? true : "Name cannot be empty"
586
- });
587
- if (name === null) return;
588
- if (this.addressbook[name]) {
589
- const overwrite = await confirmCancellable({
590
- message: `Entry "${name}" already exists. Overwrite?`,
591
- default: false
592
- });
593
- if (!overwrite) return;
594
- }
595
- try {
596
- const script = await this.FillInStruct(MultisigScriptSchema);
597
- this.addressbook[name] = script;
598
- this.saveSettings();
599
- console.log(`Added "${name}" to addressbook.`);
600
- } catch (e) {
601
- if (e instanceof UserCancelledError) return;
602
- throw e;
603
- }
604
- }
605
- }, {
606
- title: "Edit entry",
607
- action: async () => {
608
- const entries = Object.keys(this.addressbook);
609
- if (entries.length === 0) {
610
- console.log("Addressbook is empty.");
611
- return;
612
- }
613
- const selected = await selectCancellable({
614
- message: "Select entry to edit:",
615
- choices: entries.map(n => ({
616
- name: n,
617
- value: n
618
- }))
619
- });
620
- if (selected === null) return;
621
- const editName = selected;
622
- try {
623
- const updated = await this.EditStruct(MultisigScriptSchema, this.addressbook[editName]);
624
- this.addressbook[editName] = updated;
625
- this.saveSettings();
626
- console.log(`Updated "${editName}".`);
627
- } catch (e) {
628
- if (e instanceof UserCancelledError) return;
629
- throw e;
630
- }
631
- }
632
- }, {
633
- title: "Delete entry",
634
- action: async () => {
635
- const entries = Object.keys(this.addressbook);
636
- if (entries.length === 0) {
637
- console.log("Addressbook is empty.");
638
- return;
639
- }
640
- const delSelected = await selectCancellable({
641
- message: "Select entry to delete:",
642
- choices: entries.map(n => ({
643
- name: n,
644
- value: n
645
- }))
646
- });
647
- if (delSelected === null) return;
648
- const delName = delSelected;
649
- const confirmed = await confirmCancellable({
650
- message: `Delete "${delName}"?`,
651
- default: false
652
- });
653
- if (!confirmed) return;
654
- delete this.addressbook[delName];
655
- this.saveSettings();
656
- console.log(`Deleted "${delName}".`);
657
- }
658
- }]
659
- }]
660
- };
661
- await this._showMenu(settingsMenu, false, [...path, "Settings & Profiles"], true);
921
+ await this._showMenu(this.buildSettingsMenu(), false, [...path, "Settings & Profiles"], true);
662
922
  await this._showMenu(menu, main, path, true);
663
923
  return;
664
924
  }
@@ -700,6 +960,37 @@ export class Sprinkle {
700
960
  return GetBlazeFn(network, providerSettings, walletSettings);
701
961
  }
702
962
 
963
+ /**
964
+ * Derive a Bip32 private key hex from a BIP39 mnemonic phrase.
965
+ * Accepts 12, 15, or 24 word phrases.
966
+ */
967
+ static privateKeyFromMnemonic(phrase) {
968
+ const normalized = phrase.trim().toLowerCase().split(/\s+/).join(" ");
969
+ const words = normalized.split(" ");
970
+ if (![12, 15, 24].includes(words.length)) {
971
+ throw new Error(`Expected 12, 15, or 24 words but got ${words.length}.`);
972
+ }
973
+ const entropy = Core.mnemonicToEntropy(normalized, wordlist);
974
+ const masterKey = Core.Bip32PrivateKey.fromBip39Entropy(Buffer.from(entropy), "");
975
+ return masterKey.hex();
976
+ }
977
+
978
+ /**
979
+ * Prompt the user for a recovery phrase and derive the Bip32 private key hex.
980
+ */
981
+ static async importWalletFromMnemonic() {
982
+ const phrase = await passwordCancellable({
983
+ message: "Enter your recovery phrase (12, 15, or 24 words):"
984
+ });
985
+ if (phrase === null) {
986
+ throw new UserCancelledError();
987
+ }
988
+ if (phrase.trim().length === 0) {
989
+ throw new Error("Recovery phrase cannot be empty");
990
+ }
991
+ return Sprinkle.privateKeyFromMnemonic(phrase);
992
+ }
993
+
703
994
  /**
704
995
  * Generates a new wallet from a BIP39 mnemonic phrase.
705
996
  * Displays the 24-word recovery phrase and requires user confirmation.
@@ -1280,13 +1571,16 @@ export class Sprinkle {
1280
1571
  return this._fillInStruct(selection, path, defs, matchedDef);
1281
1572
  }
1282
1573
  if (isString(type)) {
1283
- // Special handling for hot wallet private key - offer generation option
1574
+ // Special handling for hot wallet private key - offer multiple sources
1284
1575
  if (type.title === "Hot Wallet Private Key") {
1285
1576
  const choice = await selectWithClear({
1286
1577
  message: "Hot wallet setup:",
1287
1578
  choices: [{
1288
1579
  name: "Enter existing private key",
1289
1580
  value: "existing"
1581
+ }, {
1582
+ name: "Import from recovery phrase",
1583
+ value: "mnemonic"
1290
1584
  }, {
1291
1585
  name: "Generate new wallet",
1292
1586
  value: "generate"
@@ -1298,6 +1592,9 @@ export class Sprinkle {
1298
1592
  if (choice === "generate") {
1299
1593
  return Sprinkle.generateWalletFromMnemonic();
1300
1594
  }
1595
+ if (choice === "mnemonic") {
1596
+ return Sprinkle.importWalletFromMnemonic();
1597
+ }
1301
1598
  // Fall through to password prompt for "existing" choice
1302
1599
  const answer = await passwordWithClear({
1303
1600
  message: "Enter your private key:"