applesauce-wallet 0.0.0-next-20250313225050 → 0.0.0-next-20250314151125

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.
@@ -6,6 +6,7 @@ import { FakeUser } from "../../__tests__/fake-user.js";
6
6
  import { CreateWallet } from "../wallet.js";
7
7
  import { WALLET_BACKUP_KIND } from "../../helpers/wallet.js";
8
8
  import { unlockHiddenTags } from "applesauce-core/helpers";
9
+ import { lastValueFrom } from "rxjs";
9
10
  const user = new FakeUser();
10
11
  let events;
11
12
  let factory;
@@ -20,13 +21,11 @@ beforeEach(() => {
20
21
  describe("CreateWallet", () => {
21
22
  it("should publish a wallet backup event", async () => {
22
23
  await hub.run(CreateWallet, ["https://mint.money.com"]);
23
- expect(publish).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ kind: WALLET_BACKUP_KIND }));
24
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({ kind: WALLET_BACKUP_KIND }));
24
25
  });
25
26
  it("should publish a wallet event with mints", async () => {
26
- await hub.run(CreateWallet, ["https://mint.money.com"]);
27
- // @ts-expect-error
28
- const walletEvent = publish.mock.calls[1][1];
29
- const hiddenTags = await unlockHiddenTags(walletEvent, user);
27
+ const event = await lastValueFrom(hub.exec(CreateWallet, ["https://mint.money.com"]));
28
+ const hiddenTags = await unlockHiddenTags(event, user);
30
29
  // the second call should be the wallet event
31
30
  expect(hiddenTags).toEqual(expect.arrayContaining([["mint", "https://mint.money.com"]]));
32
31
  });
@@ -11,3 +11,4 @@ export declare function ReceiveToken(token: Token, redeemed?: string[], fee?: nu
11
11
  export declare function RolloverTokens(tokens: NostrEvent[], token: Token): Action;
12
12
  /** An action that deletes old token events and adds a spend history item */
13
13
  export declare function CompleteSpend(spent: NostrEvent[], change: Token): Action;
14
+ /** combines all unlocked existing token events into a single event per mint */
@@ -8,17 +8,17 @@ import { WalletHistoryBlueprint } from "../blueprints/history.js";
8
8
  * @param redeemed an array of nutzap event ids to mark as redeemed
9
9
  */
10
10
  export function ReceiveToken(token, redeemed, fee) {
11
- return async ({ factory, publish }) => {
11
+ return async function* ({ factory }) {
12
12
  const amount = token.proofs.reduce((t, p) => t + p.amount, 0);
13
13
  const tokenEvent = await factory.sign(await factory.create(WalletTokenBlueprint, token, []));
14
14
  const history = await factory.sign(await factory.create(WalletHistoryBlueprint, { direction: "in", amount, mint: token.mint, created: [tokenEvent.id], fee }, redeemed ?? []));
15
- await publish("Save tokens", tokenEvent);
16
- await publish("Save transaction", history);
15
+ yield tokenEvent;
16
+ yield history;
17
17
  };
18
18
  }
19
19
  /** An action that deletes old tokens and creates a new one but does not add a history event */
20
20
  export function RolloverTokens(tokens, token) {
21
- return async ({ factory, publish }) => {
21
+ return async function* ({ factory }) {
22
22
  // create a delete event for old tokens
23
23
  const deleteDraft = await factory.create(DeleteBlueprint, tokens);
24
24
  // create a new token event
@@ -27,13 +27,13 @@ export function RolloverTokens(tokens, token) {
27
27
  const signedDelete = await factory.sign(deleteDraft);
28
28
  const signedToken = await factory.sign(tokenDraft);
29
29
  // publish events
30
- await publish("Delete old tokens", signedDelete);
31
- await publish("Save new token", signedToken);
30
+ yield signedDelete;
31
+ yield signedToken;
32
32
  };
33
33
  }
34
34
  /** An action that deletes old token events and adds a spend history item */
35
35
  export function CompleteSpend(spent, change) {
36
- return async ({ factory, publish }) => {
36
+ return async function* ({ factory }) {
37
37
  if (spent.length === 0)
38
38
  throw new Error("Cant complete spent with no token events");
39
39
  if (spent.some((s) => isTokenContentLocked(s)))
@@ -56,9 +56,38 @@ export function CompleteSpend(spent, change) {
56
56
  // sign history
57
57
  const signedHistory = await factory.sign(history);
58
58
  // publish events
59
- await publish("Delete old tokens", signedDelete);
59
+ yield signedDelete;
60
60
  if (signedToken)
61
- await publish("Save change", signedToken);
62
- await publish("Save transaction", signedHistory);
61
+ yield signedToken;
62
+ yield signedHistory;
63
63
  };
64
64
  }
65
+ /** combines all unlocked existing token events into a single event per mint */
66
+ // export function ConsolidateTokens({ ignoreLocked = true }): Action {
67
+ // return async function* ({ events, factory, self }) {
68
+ // const tokens = Array.from(events.getAll({ kinds: [WALLET_TOKEN_KIND], authors: [self] })).filter((token) => {
69
+ // if (isTokenContentLocked(token)) {
70
+ // if (ignoreLocked) return false;
71
+ // else throw new Error("Token is locked");
72
+ // } else return true;
73
+ // });
74
+ // const byMint = tokens.reduce((map, token) => {
75
+ // const mint = getTokenContent(token)!.mint;
76
+ // if (!map.has(mint)) map.set(mint, []);
77
+ // map.get(mint)!.push(token);
78
+ // return map;
79
+ // }, new Map<string, NostrEvent[]>());
80
+ // for (const [mint, tokens] of byMint) {
81
+ // const cashuMint = new CashuMint(mint);
82
+ // const cashuWallet = new CashuWallet(cashuMint);
83
+ // const proofs = tokens
84
+ // .map((t) => getTokenContent(t)!.proofs)
85
+ // .flat()
86
+ // .filter(ignoreDuplicateProofs());
87
+ // // NOTE: this assumes that the states array is the same length and order as the proofs array
88
+ // const states = await cashuWallet.checkProofsStates(proofs);
89
+ // const notSpent = proofs.filter((_, i) => states[i].state !== CheckStateEnum.SPENT);
90
+ // // delete old
91
+ // }
92
+ // };
93
+ // }
@@ -5,19 +5,20 @@ import { isTokenContentLocked, unlockTokenContent, WALLET_TOKEN_KIND } from "../
5
5
  import { isHistoryContentLocked, unlockHistoryContent, WALLET_HISTORY_KIND } from "../helpers/history.js";
6
6
  /** An action that creates a new 17375 wallet event and 375 wallet backup */
7
7
  export function CreateWallet(mints, privateKey = generateSecretKey()) {
8
- return async ({ events, factory, self, publish }) => {
8
+ return async function* ({ events, factory, self }) {
9
9
  const existing = events.getReplaceable(WALLET_KIND, self);
10
10
  if (existing)
11
11
  throw new Error("Wallet already exists");
12
12
  const wallet = await factory.sign(await factory.create(WalletBlueprint, privateKey, mints));
13
13
  const backup = await factory.sign(await factory.create(WalletBackupBlueprint, wallet));
14
- await publish("Wallet backup", backup);
15
- await publish("Create wallet", wallet);
14
+ // publish the backup first
15
+ yield backup;
16
+ yield wallet;
16
17
  };
17
18
  }
18
19
  /** Unlocks the wallet event and optionally the tokens and history events */
19
20
  export function UnlockWallet(unlock) {
20
- return async ({ events, self, factory }) => {
21
+ return async function* ({ events, self, factory }) {
21
22
  const signer = factory.context.signer;
22
23
  if (!signer)
23
24
  throw new Error("Missing signer");
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
2
2
  import { EventFactory } from "applesauce-factory";
3
3
  import { FakeUser } from "../../__tests__/fake-user.js";
4
4
  import { WalletTokenBlueprint } from "../../blueprints/tokens.js";
5
- import { dumbTokenSelection, unlockTokenContent } from "../tokens.js";
6
- import { HiddenContentSymbol } from "applesauce-core/helpers";
5
+ import { decodeTokenFromEmojiString, dumbTokenSelection, encodeTokenToEmoji, unlockTokenContent } from "../tokens.js";
6
+ import { HiddenContentSymbol, unixNow } from "applesauce-core/helpers";
7
7
  const user = new FakeUser();
8
8
  const factory = new EventFactory({ signer: user });
9
9
  describe("dumbTokenSelection", () => {
@@ -20,7 +20,7 @@ describe("dumbTokenSelection", () => {
20
20
  bDraft.created_at -= 60 * 60 * 7;
21
21
  const b = await user.signEvent(bDraft);
22
22
  await unlockTokenContent(b, user);
23
- expect(dumbTokenSelection([a, b], 40)).toEqual([b]);
23
+ expect(dumbTokenSelection([a, b], 40).events).toEqual([b]);
24
24
  });
25
25
  it("should select enough tokens to total min amount", async () => {
26
26
  const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
@@ -35,7 +35,7 @@ describe("dumbTokenSelection", () => {
35
35
  bDraft.created_at -= 60 * 60 * 7;
36
36
  const b = await user.signEvent(bDraft);
37
37
  await unlockTokenContent(b, user);
38
- expect(dumbTokenSelection([a, b], 120)).toEqual(expect.arrayContaining([a, b]));
38
+ expect(dumbTokenSelection([a, b], 120).events).toEqual(expect.arrayContaining([a, b]));
39
39
  });
40
40
  it("should throw if not enough funds", async () => {
41
41
  const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
@@ -59,6 +59,69 @@ describe("dumbTokenSelection", () => {
59
59
  const b = await user.signEvent(bDraft);
60
60
  // manually remove the hidden content to lock it again
61
61
  Reflect.deleteProperty(b, HiddenContentSymbol);
62
- expect(dumbTokenSelection([a, b], 20)).toEqual([a]);
62
+ expect(dumbTokenSelection([a, b], 20).events).toEqual([a]);
63
+ });
64
+ it("should ignore duplicate proofs", async () => {
65
+ const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
66
+ mint: "https://money.com",
67
+ proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
68
+ }));
69
+ // create a second event with the same proofs
70
+ const b = await user.signEvent(await factory.create(WalletTokenBlueprint, {
71
+ mint: "https://money.com",
72
+ proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
73
+ }));
74
+ expect(() => dumbTokenSelection([a, b], 150)).toThrow();
75
+ });
76
+ it("should include duplicate token events and ignore duplicate proofs", async () => {
77
+ const A = { secret: "A", C: "A", id: "A", amount: 100 };
78
+ const a = await user.signEvent({
79
+ ...(await factory.create(WalletTokenBlueprint, {
80
+ mint: "https://money.com",
81
+ proofs: [A],
82
+ })),
83
+ // make event older
84
+ created_at: unixNow() - 100,
85
+ });
86
+ // create a second event with the same proofs
87
+ const a2 = await user.signEvent({
88
+ ...(await factory.create(WalletTokenBlueprint, {
89
+ mint: "https://money.com",
90
+ proofs: [A],
91
+ })),
92
+ // make event older
93
+ created_at: a.created_at - 200,
94
+ });
95
+ const B = { secret: "B", C: "B", id: "B", amount: 50 };
96
+ const b = await user.signEvent(await factory.create(WalletTokenBlueprint, {
97
+ mint: "https://money.com",
98
+ proofs: [B],
99
+ }));
100
+ const result = dumbTokenSelection([a, a2, b], 150);
101
+ expect(result.events.map((e) => e.id)).toEqual(expect.arrayContaining([a.id, a2.id, b.id]));
102
+ expect(result.proofs).toEqual([A, B]);
103
+ });
104
+ });
105
+ describe("encodeTokenToEmoji", () => {
106
+ it("should encode token into emoji string", () => {
107
+ const token = "cashuBo2FteBtodHRwczovL3Rlc3RudXQuY2FzaHUuc3BhY2VhdWNzYXRhdIGiYWlIAJofKTJT5B5hcIGkYWEBYXN4QDdlZDBkMzk3NGQ5ZWM2OTc2YTAzYmZmYjdkMTA4NzIzZTBiMDRjMzRhNDc3MjlmNjMwOGJlODc3OTA2NTY0NDVhY1ghA36iYyOHCe4CnTxzORbcXFVeAbkMUFE6FqPWInujnAOcYWSjYWVYIJmHRwCQ0Uopkd3P5xb0MdcWQEaZz9hXWtcn-FMhZj8LYXNYIF4X9ybXxg5Pp0KSowfu4y_Aovo9iy3TXlLSaKyVJzz2YXJYIC_UFkoC5U9BpSgBTGUQgsjfz_emv5xykDiavZUfRN8E";
108
+ expect(encodeTokenToEmoji(token).length).toBeGreaterThan(token.length);
109
+ });
110
+ });
111
+ const emoji = "🥜󠅓󠅑󠅣󠅘󠅥󠄲󠅟󠄢󠄶󠅤󠅕󠄲󠅤󠅟󠅔󠄸󠅂󠅧󠅓󠅪󠅟󠅦󠄼󠄣󠅂󠅜󠅓󠄣󠅂󠅥󠅔󠅈󠅁󠅥󠅉󠄢󠄶󠅪󠅑󠄸󠅅󠅥󠅓󠄣󠄲󠅘󠅉󠄢󠅆󠅘󠅔󠅇󠄾󠅪󠅉󠅈󠅂󠅘󠅔󠄹󠄷󠅙󠅉󠅇󠅜󠄹󠄱󠄺󠅟󠅖󠄻󠅄󠄺󠅄󠄥󠄲󠄥󠅘󠅓󠄹󠄷󠅛󠅉󠅇󠄵󠄲󠅉󠅈󠄾󠄤󠅁󠄴󠅔󠅜󠅊󠄴󠄲󠅛󠄽󠅪󠅛󠄣󠄾󠄷󠅁󠄥󠅊󠅇󠄽󠄢󠄿󠅄󠅓󠄢󠅉󠅄󠄱󠅪󠅉󠅝󠅊󠅝󠅉󠅚󠅔󠅛󠄽󠅄󠄱󠄤󠄾󠅪󠄹󠅪󠅊󠅄󠄲󠅙󠄽󠄴󠅂󠅚󠄽󠅪󠅂󠅘󠄾󠄴󠅓󠄣󠄽󠅚󠅜󠅝󠄾󠅚󠄽󠅧󠄿󠄷󠄺󠅜󠄿󠄴󠅓󠄣󠄿󠅄󠄱󠄢󠄾󠅄󠅉󠄠󠄾󠄴󠅆󠅘󠅉󠄡󠅗󠅘󠄱󠄣󠄦󠅙󠅉󠅩󠄿󠄸󠄳󠅕󠄤󠄳󠅞󠅄󠅨󠅪󠄿󠅂󠅒󠅓󠅈󠄶󠅆󠅕󠄱󠅒󠅛󠄽󠅅󠄶󠄵󠄦󠄶󠅡󠅀󠅇󠄹󠅞󠅥󠅚󠅞󠄱󠄿󠅓󠅉󠅇󠅃󠅚󠅉󠅇󠅆󠅉󠄹󠄺󠅝󠄸󠅂󠅧󠄳󠅁󠄠󠅅󠅟󠅠󠅛󠅔󠄣󠅀󠄥󠅨󠅒󠄠󠄽󠅔󠅓󠅇󠅁󠄵󠅑󠅊󠅪󠄩󠅘󠅈󠅇󠅤󠅓󠅞󠄝󠄶󠄽󠅘󠅊󠅚󠄨󠄼󠅉󠅈󠄾󠅉󠄹󠄶󠄤󠅈󠄩󠅩󠅒󠅈󠅨󠅗󠄥󠅀󠅠󠄠󠄻󠅃󠅟󠅧󠅖󠅥󠄤󠅩󠅏󠄱󠅟󠅦󠅟󠄩󠅙󠅩󠄣󠅄󠅈󠅜󠄼󠅃󠅑󠄻󠅩󠅆󠄺󠅪󠅪󠄢󠅉󠅈󠄺󠅉󠄹󠄳󠅏󠅅󠄶󠅛󠅟󠄳󠄥󠅅󠄩󠄲󠅠󠅃󠅗󠄲󠅄󠄷󠅅󠅁󠅗󠅣󠅚󠅖󠅪󠅏󠅕󠅝󠅦󠄥󠅨󠅩󠅛󠄴󠅙󠅑󠅦󠅊󠅅󠅖󠅂󠄾󠄨󠄵";
112
+ describe("decodeTokenFromEmojiString", () => {
113
+ it("should decode single emoji", () => {
114
+ expect(decodeTokenFromEmojiString(emoji)).toEqual(expect.objectContaining({
115
+ mint: "https://testnut.cashu.space",
116
+ proofs: [expect.any(Object)],
117
+ unit: "sat",
118
+ }));
119
+ });
120
+ it("should decode an emoji in text", () => {
121
+ expect(decodeTokenFromEmojiString("the money is in the emoji, " + emoji + " you can redeem it using cashu.me")).toEqual(expect.objectContaining({
122
+ mint: "https://testnut.cashu.space",
123
+ proofs: [expect.any(Object)],
124
+ unit: "sat",
125
+ }));
63
126
  });
64
127
  });
@@ -1,5 +1,10 @@
1
+ import { Proof, Token } from "@cashu/cashu-ts";
1
2
  import { HiddenContentSigner } from "applesauce-core/helpers";
2
3
  import { NostrEvent } from "nostr-tools";
4
+ /** Internal method for creating a unique id for each proof */
5
+ export declare function getProofUID(proof: Proof): string;
6
+ /** Internal method to filter out duplicate proofs */
7
+ export declare function ignoreDuplicateProofs(seen?: Set<string>): (proof: Proof) => boolean;
3
8
  export declare const WALLET_TOKEN_KIND = 7375;
4
9
  export type TokenContent = {
5
10
  /** Cashu mint for the proofs */
@@ -17,7 +22,6 @@ export type TokenContent = {
17
22
  del: string[];
18
23
  };
19
24
  export declare const TokenContentSymbol: unique symbol;
20
- export declare const TokenProofsTotalSymbol: unique symbol;
21
25
  /**
22
26
  * Returns the decrypted and parsed details of a 7375 token event
23
27
  * @throws
@@ -29,10 +33,26 @@ export declare function isTokenContentLocked(token: NostrEvent): boolean;
29
33
  export declare function unlockTokenContent(token: NostrEvent, signer: HiddenContentSigner): Promise<TokenContent>;
30
34
  /** Removes the unencrypted hidden content */
31
35
  export declare function lockTokenContent(token: NostrEvent): void;
32
- /** Gets the totaled amount of proofs in a token event */
36
+ /**
37
+ * Gets the totaled amount of proofs in a token event
38
+ * @param token The token event to calculate the total
39
+ */
33
40
  export declare function getTokenProofsTotal(token: NostrEvent): number | undefined;
34
41
  /**
35
- * Selects oldest tokens that total up to more than the min amount
42
+ * Selects oldest tokens and proofs that total up to more than the min amount
36
43
  * @throws
37
44
  */
38
- export declare function dumbTokenSelection(tokens: NostrEvent[], minAmount: number, mint?: string): import("nostr-tools").Event[];
45
+ export declare function dumbTokenSelection(tokens: NostrEvent[], minAmount: number, mint?: string): {
46
+ events: NostrEvent[];
47
+ proofs: Proof[];
48
+ };
49
+ /**
50
+ * Returns a decoded cashu token inside an unicode emoji
51
+ * @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/ReceiveTokenDialog.vue#L387
52
+ */
53
+ export declare function decodeTokenFromEmojiString(str: string): Token | undefined;
54
+ /**
55
+ * Encodes a token into an emoji char
56
+ * @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/SendTokenDialog.vue#L710
57
+ */
58
+ export declare function encodeTokenToEmoji(token: Token | string, emoji?: string): string;
@@ -1,7 +1,23 @@
1
+ import { getDecodedToken, getEncodedToken } from "@cashu/cashu-ts";
1
2
  import { getHiddenContent, getOrComputeCachedValue, isHiddenContentLocked, isHiddenTagsLocked, lockHiddenContent, unlockHiddenContent, } from "applesauce-core/helpers";
3
+ /** Internal method for creating a unique id for each proof */
4
+ export function getProofUID(proof) {
5
+ return proof.id + proof.amount + proof.C + proof.secret;
6
+ }
7
+ /** Internal method to filter out duplicate proofs */
8
+ export function ignoreDuplicateProofs(seen = new Set()) {
9
+ return (proof) => {
10
+ const id = getProofUID(proof);
11
+ if (seen.has(id))
12
+ return false;
13
+ else {
14
+ seen.add(id);
15
+ return true;
16
+ }
17
+ };
18
+ }
2
19
  export const WALLET_TOKEN_KIND = 7375;
3
20
  export const TokenContentSymbol = Symbol.for("token-content");
4
- export const TokenProofsTotalSymbol = Symbol.for("token-proofs-total");
5
21
  /**
6
22
  * Returns the decrypted and parsed details of a 7375 token event
7
23
  * @throws
@@ -36,20 +52,20 @@ export async function unlockTokenContent(token, signer) {
36
52
  /** Removes the unencrypted hidden content */
37
53
  export function lockTokenContent(token) {
38
54
  Reflect.deleteProperty(token, TokenContentSymbol);
39
- Reflect.deleteProperty(token, TokenProofsTotalSymbol);
40
55
  lockHiddenContent(token);
41
56
  }
42
- /** Gets the totaled amount of proofs in a token event */
57
+ /**
58
+ * Gets the totaled amount of proofs in a token event
59
+ * @param token The token event to calculate the total
60
+ */
43
61
  export function getTokenProofsTotal(token) {
44
62
  if (isTokenContentLocked(token))
45
63
  return undefined;
46
- return getOrComputeCachedValue(token, TokenProofsTotalSymbol, () => {
47
- const content = getTokenContent(token);
48
- return content.proofs.reduce((t, p) => t + p.amount, 0);
49
- });
64
+ const content = getTokenContent(token);
65
+ return content.proofs.reduce((t, p) => t + p.amount, 0);
50
66
  }
51
67
  /**
52
- * Selects oldest tokens that total up to more than the min amount
68
+ * Selects oldest tokens and proofs that total up to more than the min amount
53
69
  * @throws
54
70
  */
55
71
  export function dumbTokenSelection(tokens, minAmount, mint) {
@@ -57,17 +73,88 @@ export function dumbTokenSelection(tokens, minAmount, mint) {
57
73
  const sorted = tokens
58
74
  .filter((token) => !isTokenContentLocked(token) && (mint ? getTokenContent(token).mint === mint : true))
59
75
  .sort((a, b) => b.created_at - a.created_at);
60
- const total = sorted.reduce((t, token) => t + getTokenProofsTotal(token), 0);
61
- if (total < minAmount)
62
- throw new Error("Insufficient funds");
63
76
  let amount = 0;
64
- const selected = [];
77
+ const seen = new Set();
78
+ const selectedTokens = [];
79
+ const selectedProofs = [];
65
80
  while (amount < minAmount) {
66
81
  const token = sorted.pop();
67
82
  if (!token)
68
- throw new Error("Ran out of tokens");
69
- selected.push(token);
70
- amount += getTokenProofsTotal(token);
83
+ throw new Error("Insufficient funds");
84
+ const proofs = getTokenContent(token).proofs.filter(ignoreDuplicateProofs(seen));
85
+ const total = proofs.reduce((t, p) => t + p.amount, 0);
86
+ selectedTokens.push(token);
87
+ selectedProofs.push(...proofs);
88
+ amount += total;
89
+ }
90
+ return { events: selectedTokens, proofs: selectedProofs };
91
+ }
92
+ /**
93
+ * Returns a decoded cashu token inside an unicode emoji
94
+ * @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/ReceiveTokenDialog.vue#L387
95
+ */
96
+ export function decodeTokenFromEmojiString(str) {
97
+ try {
98
+ let decoded = [];
99
+ const chars = Array.from(str);
100
+ if (!chars.length)
101
+ return undefined;
102
+ const fromVariationSelector = function (char) {
103
+ const codePoint = char.codePointAt(0);
104
+ if (codePoint === undefined)
105
+ return null;
106
+ // Handle Variation Selectors (VS1-VS16): U+FE00 to U+FE0F
107
+ if (codePoint >= 0xfe00 && codePoint <= 0xfe0f) {
108
+ // Maps FE00->0, FE01->1, ..., FE0F->15
109
+ const byteValue = codePoint - 0xfe00;
110
+ return String.fromCharCode(byteValue);
111
+ }
112
+ // Handle Variation Selectors Supplement (VS17-VS256): U+E0100 to U+E01EF
113
+ if (codePoint >= 0xe0100 && codePoint <= 0xe01ef) {
114
+ // Maps E0100->16, E0101->17, ..., E01EF->255
115
+ const byteValue = codePoint - 0xe0100 + 16;
116
+ return String.fromCharCode(byteValue);
117
+ }
118
+ // No Variation Selector
119
+ return null;
120
+ };
121
+ // Check all input chars for peanut data
122
+ for (const char of chars) {
123
+ let byte = fromVariationSelector(char);
124
+ if (byte === null && decoded.length > 0) {
125
+ break;
126
+ }
127
+ else if (byte === null) {
128
+ continue;
129
+ }
130
+ decoded.push(byte); // got some
131
+ }
132
+ // Switch out token if we found peanut data
133
+ let decodedString = decoded.join("");
134
+ return getDecodedToken(decodedString);
135
+ }
136
+ catch (error) {
137
+ return undefined;
71
138
  }
72
- return selected;
139
+ }
140
+ /**
141
+ * Encodes a token into an emoji char
142
+ * @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/SendTokenDialog.vue#L710
143
+ */
144
+ export function encodeTokenToEmoji(token, emoji = "🥜") {
145
+ return (emoji +
146
+ Array.from(typeof token === "string" ? token : getEncodedToken(token))
147
+ .map((char) => {
148
+ const byteValue = char.charCodeAt(0);
149
+ // For byte values 0-15, use Variation Selectors (VS1-VS16): U+FE00 to U+FE0F
150
+ if (byteValue >= 0 && byteValue <= 15) {
151
+ return String.fromCodePoint(0xfe00 + byteValue);
152
+ }
153
+ // For byte values 16-255, use Variation Selectors Supplement (VS17-VS256): U+E0100 to U+E01EF
154
+ if (byteValue >= 16 && byteValue <= 255) {
155
+ return String.fromCodePoint(0xe0100 + (byteValue - 16));
156
+ }
157
+ return "";
158
+ })
159
+ .join(""));
73
160
  }
@@ -1,5 +1,5 @@
1
1
  import { combineLatest, filter, map, startWith } from "rxjs";
2
- import { getTokenContent, isTokenContentLocked, WALLET_TOKEN_KIND } from "../helpers/tokens.js";
2
+ import { getTokenContent, ignoreDuplicateProofs, isTokenContentLocked, WALLET_TOKEN_KIND } from "../helpers/tokens.js";
3
3
  /** removes deleted events from sorted array */
4
4
  function filterDeleted(tokens) {
5
5
  const deleted = new Set();
@@ -50,11 +50,15 @@ export function WalletBalanceQuery(pubkey) {
50
50
  // filter out deleted tokens
51
51
  map(filterDeleted),
52
52
  // map tokens to totals
53
- map((tokens) => tokens.reduce((totals, token) => {
54
- const details = getTokenContent(token);
55
- const total = details.proofs.reduce((t, p) => t + p.amount, 0);
56
- return { ...totals, [details.mint]: (totals[details.mint] ?? 0) + total };
57
- }, {})));
53
+ map((tokens) => {
54
+ // ignore duplicate proofs
55
+ const seen = new Set();
56
+ return tokens.reduce((totals, token) => {
57
+ const details = getTokenContent(token);
58
+ const total = details.proofs.filter(ignoreDuplicateProofs(seen)).reduce((t, p) => t + p.amount, 0);
59
+ return { ...totals, [details.mint]: (totals[details.mint] ?? 0) + total };
60
+ }, {});
61
+ }));
58
62
  },
59
63
  };
60
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-wallet",
3
- "version": "0.0.0-next-20250313225050",
3
+ "version": "0.0.0-next-20250314151125",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -80,16 +80,16 @@
80
80
  "@cashu/cashu-ts": "2.0.0-rc1",
81
81
  "@gandlaf21/bc-ur": "^1.1.12",
82
82
  "@noble/hashes": "^1.7.1",
83
- "applesauce-actions": "0.0.0-next-20250313225050",
84
- "applesauce-core": "0.0.0-next-20250313225050",
85
- "applesauce-factory": "0.0.0-next-20250313225050",
83
+ "applesauce-actions": "0.0.0-next-20250314151125",
84
+ "applesauce-core": "0.0.0-next-20250314151125",
85
+ "applesauce-factory": "0.0.0-next-20250314151125",
86
86
  "nostr-tools": "^2.10.4",
87
87
  "rxjs": "^7.8.1"
88
88
  },
89
89
  "devDependencies": {
90
90
  "@hirez_io/observer-spy": "^2.2.0",
91
91
  "@types/debug": "^4.1.12",
92
- "applesauce-signers": "0.0.0-next-20250313225050",
92
+ "applesauce-signers": "0.0.0-next-20250314151125",
93
93
  "typescript": "^5.7.3",
94
94
  "vitest": "^3.0.5"
95
95
  },