@sundaeswap/sprinkles 0.2.1 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sundaeswap/sprinkles",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "A TypeScript library for building interactive CLI menus and TUI applications with TypeBox schema validation",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
@@ -1,13 +1,17 @@
1
- import { describe, expect, test, mock, beforeEach } from "bun:test";
2
- import { Sprinkle, Type } from "../index.js";
1
+ import { describe, expect, test, mock, beforeEach, spyOn } from "bun:test";
2
+ import { Sprinkle, Type, WalletSettingsSchema } from "../index.js";
3
3
 
4
4
  // Mock @inquirer/prompts
5
5
  const mockSelect = mock();
6
6
  const mockInput = mock();
7
+ const mockPassword = mock();
8
+ const mockConfirm = mock();
7
9
 
8
10
  mock.module("@inquirer/prompts", () => ({
9
11
  select: mockSelect,
10
12
  input: mockInput,
13
+ password: mockPassword,
14
+ confirm: mockConfirm,
11
15
  }));
12
16
 
13
17
  describe("FillInStruct", () => {
@@ -204,4 +208,53 @@ describe("FillInStruct", () => {
204
208
  const result = await sprinkle.FillInStruct(schema);
205
209
  expect(result).toEqual({ asset: ["policy123", "token456"] });
206
210
  });
211
+
212
+ test("hot wallet private key shows setup choice", async () => {
213
+ const schema = Type.String({ title: "Hot Wallet Private Key" });
214
+
215
+ // Select "existing" option
216
+ mockSelect.mockResolvedValueOnce("existing");
217
+ mockPassword.mockResolvedValueOnce("my-private-key");
218
+
219
+ const result = await sprinkle.FillInStruct(schema);
220
+
221
+ // Verify select was called with correct options
222
+ expect(mockSelect.mock.calls[0][0].message).toBe("Hot wallet setup:");
223
+ expect(mockSelect.mock.calls[0][0].choices).toEqual([
224
+ { name: "Enter existing private key", value: "existing" },
225
+ { name: "Generate new wallet", value: "generate" },
226
+ ]);
227
+ expect(result).toBe("my-private-key");
228
+ });
229
+
230
+ test("hot wallet existing key prompts for password", async () => {
231
+ const schema = Type.String({ title: "Hot Wallet Private Key" });
232
+
233
+ mockSelect.mockResolvedValueOnce("existing");
234
+ mockPassword.mockResolvedValueOnce("deadbeef1234");
235
+
236
+ const result = await sprinkle.FillInStruct(schema);
237
+
238
+ expect(mockPassword).toHaveBeenCalledWith({
239
+ message: "Enter your private key:",
240
+ });
241
+ expect(result).toBe("deadbeef1234");
242
+ });
243
+
244
+ test("full wallet settings schema with existing key", async () => {
245
+ // Select "hot" variant
246
+ mockSelect.mockImplementationOnce(async (opts: any) => {
247
+ return opts.choices[0].value; // hot wallet object
248
+ });
249
+ // Select "existing" key option
250
+ mockSelect.mockResolvedValueOnce("existing");
251
+ mockPassword.mockResolvedValueOnce("abc123privatekey");
252
+
253
+ const result = await sprinkle.FillInStruct(WalletSettingsSchema);
254
+
255
+ expect(result).toEqual({
256
+ type: "hot",
257
+ privateKey: "abc123privatekey",
258
+ });
259
+ });
207
260
  });
@@ -178,4 +178,112 @@ describe("Settings Persistence", () => {
178
178
  expect(sprinkle.defaults).toEqual({ string: "old" });
179
179
  expect(sprinkle.profileMeta.name).toBe("Default");
180
180
  });
181
+
182
+ describe("currentProfile", () => {
183
+ test("returns null when no profile loaded", () => {
184
+ const schema = Type.Object({ name: Type.String() });
185
+ const sprinkle = new Sprinkle(schema, tmpDir);
186
+
187
+ expect(sprinkle.currentProfile).toBeNull();
188
+ });
189
+
190
+ test("returns profile info when loaded", () => {
191
+ const schema = Type.Object({ name: Type.String() });
192
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
193
+
194
+ const profile = sprinkle.currentProfile;
195
+ expect(profile).not.toBeNull();
196
+ expect(profile!.id).toBe("test");
197
+ expect(profile!.name).toBe("Test");
198
+ expect(profile!.createdAt).toBeDefined();
199
+ expect(profile!.updatedAt).toBeDefined();
200
+ });
201
+
202
+ test("includes description when present", () => {
203
+ const schema = Type.Object({ name: Type.String() });
204
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
205
+ sprinkle.profileMeta.description = "Test profile description";
206
+
207
+ const profile = sprinkle.currentProfile;
208
+ expect(profile!.description).toBe("Test profile description");
209
+ });
210
+
211
+ test("has undefined description when not set", () => {
212
+ const schema = Type.Object({ name: Type.String() });
213
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
214
+ delete sprinkle.profileMeta.description;
215
+
216
+ const profile = sprinkle.currentProfile;
217
+ expect(profile!.description).toBeUndefined();
218
+ });
219
+ });
220
+
221
+ describe("getDisplaySettings", () => {
222
+ test("masks sensitive fields", () => {
223
+ const schema = Type.Object({
224
+ name: Type.String(),
225
+ secret: Type.String({ sensitive: true }),
226
+ });
227
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
228
+ sprinkle.settings = { name: "visible", secret: "hidden" } as any;
229
+
230
+ const display = sprinkle.getDisplaySettings();
231
+ expect(display.name).toBe("visible");
232
+ expect(display.secret).toBe("********");
233
+ });
234
+
235
+ test("preserves non-sensitive fields", () => {
236
+ const schema = Type.Object({
237
+ name: Type.String(),
238
+ count: Type.Number(),
239
+ });
240
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
241
+ sprinkle.settings = { name: "test", count: 42 } as any;
242
+
243
+ const display = sprinkle.getDisplaySettings();
244
+ expect(display.name).toBe("test");
245
+ expect(display.count).toBe(42);
246
+ });
247
+
248
+ test("preserves BigInt values", () => {
249
+ const schema = Type.Object({
250
+ amount: Type.BigInt(),
251
+ secret: Type.String({ sensitive: true }),
252
+ });
253
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
254
+ sprinkle.settings = { amount: 42n, secret: "hidden" } as any;
255
+
256
+ const display = sprinkle.getDisplaySettings();
257
+ expect(display.amount).toBe(42n);
258
+ expect(display.secret).toBe("********");
259
+ });
260
+
261
+ test("masks nested sensitive fields", () => {
262
+ const schema = Type.Object({
263
+ wallet: Type.Object({
264
+ key: Type.String({ sensitive: true }),
265
+ address: Type.String(),
266
+ }),
267
+ });
268
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
269
+ sprinkle.settings = {
270
+ wallet: { key: "secret", address: "addr1..." },
271
+ } as any;
272
+
273
+ const display = sprinkle.getDisplaySettings();
274
+ expect(display.wallet.key).toBe("********");
275
+ expect(display.wallet.address).toBe("addr1...");
276
+ });
277
+
278
+ test("does not mask empty sensitive fields", () => {
279
+ const schema = Type.Object({
280
+ secret: Type.String({ sensitive: true }),
281
+ });
282
+ const sprinkle = withProfile(new Sprinkle(schema, tmpDir));
283
+ sprinkle.settings = { secret: "" } as any;
284
+
285
+ const display = sprinkle.getDisplaySettings();
286
+ expect(display.secret).toBe("");
287
+ });
288
+ });
181
289
  });
@@ -6,7 +6,13 @@ import {
6
6
  HotWallet,
7
7
  type Wallet,
8
8
  } from "@blaze-cardano/sdk";
9
- import { CborSet, VkeyWitness, blake2b_256, TxCBOR } from "@blaze-cardano/core";
9
+ import {
10
+ CborSet,
11
+ VkeyWitness,
12
+ blake2b_256,
13
+ TxCBOR,
14
+ wordlist,
15
+ } from "@blaze-cardano/core";
10
16
  import { confirm, input, password, search, select } from "@inquirer/prompts";
11
17
  import {
12
18
  Kind,
@@ -68,6 +74,10 @@ export interface IProfileMeta {
68
74
  updatedAt: string;
69
75
  }
70
76
 
77
+ export interface ICurrentProfile extends IProfileMeta {
78
+ id: string;
79
+ }
80
+
71
81
  export interface IProfileEntry {
72
82
  id: string;
73
83
  meta: IProfileMeta;
@@ -167,7 +177,11 @@ export const ProviderSettingsSchema = Type.Union([
167
177
  export const WalletSettingsSchema = Type.Union([
168
178
  Type.Object({
169
179
  type: Type.Literal("hot"),
170
- privateKey: Type.String({ minLength: 1, title: "Hot Wallet Private Key" }),
180
+ privateKey: Type.String({
181
+ minLength: 1,
182
+ title: "Hot Wallet Private Key",
183
+ sensitive: true,
184
+ }),
171
185
  }),
172
186
  Type.Object({
173
187
  type: Type.Literal("cold"),
@@ -201,6 +215,16 @@ export class Sprinkle<S extends TSchema> {
201
215
  this.options = options ?? {};
202
216
  }
203
217
 
218
+ // --- Current Profile Accessor ---
219
+
220
+ get currentProfile(): ICurrentProfile | null {
221
+ if (!this.profileId) return null;
222
+ return {
223
+ id: this.profileId,
224
+ ...this.profileMeta,
225
+ };
226
+ }
227
+
204
228
  // --- Profile path helpers ---
205
229
 
206
230
  static sanitizeProfileId(name: string): string {
@@ -686,6 +710,47 @@ export class Sprinkle<S extends TSchema> {
686
710
  return Blaze.from(provider, wallet);
687
711
  }
688
712
 
713
+ /**
714
+ * Generates a new wallet from a BIP39 mnemonic phrase.
715
+ * Displays the 24-word recovery phrase and requires user confirmation.
716
+ * @returns The Bip32PrivateKey hex string for storage
717
+ */
718
+ private static async generateWalletFromMnemonic(): Promise<string> {
719
+ const mnemonic = Core.generateMnemonic(wordlist, 256); // 24 words
720
+ const words = mnemonic.split(" ");
721
+
722
+ console.log("\n=== NEW WALLET GENERATED ===\n");
723
+ console.log("IMPORTANT: Save these 24 words in a secure location.");
724
+ console.log("This is the ONLY way to recover your wallet.\n");
725
+
726
+ // Display in 4 columns
727
+ for (let i = 0; i < 6; i++) {
728
+ console.log(
729
+ `${(i + 1).toString().padStart(2)}. ${words[i]!.padEnd(12)} ` +
730
+ `${(i + 7).toString().padStart(2)}. ${words[i + 6]!.padEnd(12)} ` +
731
+ `${(i + 13).toString().padStart(2)}. ${words[i + 12]!.padEnd(12)} ` +
732
+ `${(i + 19).toString().padStart(2)}. ${words[i + 18]}`,
733
+ );
734
+ }
735
+ console.log("");
736
+
737
+ const confirmed = await confirm({
738
+ message: "Have you saved your recovery phrase?",
739
+ default: false,
740
+ });
741
+
742
+ if (!confirmed) {
743
+ throw new Error("Wallet generation cancelled - recovery phrase not saved");
744
+ }
745
+
746
+ const entropy = Core.mnemonicToEntropy(mnemonic, wordlist);
747
+ const masterKey = Core.Bip32PrivateKey.fromBip39Entropy(
748
+ Buffer.from(entropy),
749
+ "",
750
+ );
751
+ return masterKey.hex();
752
+ }
753
+
689
754
  static async SearchSelect<T>(opts: {
690
755
  message: string;
691
756
  source: (
@@ -814,6 +879,21 @@ export class Sprinkle<S extends TSchema> {
814
879
  this.saveProfile();
815
880
  }
816
881
 
882
+ getDisplaySettings(): TExact<S> {
883
+ const clone = JSON.parse(
884
+ JSON.stringify(this.settings, Sprinkle.bigIntReplacer),
885
+ Sprinkle.bigIntReviver,
886
+ );
887
+ const sensitivePaths = Sprinkle.collectSensitivePaths(this.type);
888
+ for (const p of sensitivePaths) {
889
+ const value = Sprinkle.getNestedValue(clone, p);
890
+ if (typeof value === "string" && value.length > 0) {
891
+ Sprinkle.setNestedValue(clone, p, "********");
892
+ }
893
+ }
894
+ return clone;
895
+ }
896
+
817
897
  // --- TxDialog Helpers ---
818
898
 
819
899
  /**
@@ -1321,6 +1401,24 @@ export class Sprinkle<S extends TSchema> {
1321
1401
  }
1322
1402
 
1323
1403
  if (isString(type)) {
1404
+ // Special handling for hot wallet private key - offer generation option
1405
+ if (type.title === "Hot Wallet Private Key") {
1406
+ const choice = await select({
1407
+ message: "Hot wallet setup:",
1408
+ choices: [
1409
+ { name: "Enter existing private key", value: "existing" },
1410
+ { name: "Generate new wallet", value: "generate" },
1411
+ ],
1412
+ });
1413
+
1414
+ if (choice === "generate") {
1415
+ return Sprinkle.generateWalletFromMnemonic() as Promise<TExact<U>>;
1416
+ }
1417
+ // Fall through to password prompt for "existing" choice
1418
+ const answer = await password({ message: "Enter your private key:" });
1419
+ return answer as TExact<U>;
1420
+ }
1421
+
1324
1422
  const defaultString = (def ? def : this.defaults["string"]) as
1325
1423
  | string
1326
1424
  | undefined;