applesauce-wallet 0.0.0-next-20250312113207 → 0.0.0-next-20250312162115

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.
@@ -1 +1,2 @@
1
1
  export * from "./wallet.js";
2
+ export * from "./tokens.js";
@@ -1 +1,2 @@
1
1
  export * from "./wallet.js";
2
+ export * from "./tokens.js";
@@ -0,0 +1,11 @@
1
+ import { Token } from "@cashu/cashu-ts";
2
+ import { Action } from "applesauce-actions";
3
+ import { NostrEvent } from "nostr-tools";
4
+ /**
5
+ * Adds a cashu token to the wallet and marks a list of nutzaps as redeemed
6
+ * @param token the cashu token to add
7
+ * @param redeemed an array of nutzap event ids to mark as redeemed
8
+ */
9
+ export declare function ReceiveToken(token: Token, redeemed?: string[]): Action;
10
+ /** An action that deletes old token events and adds a spend history item */
11
+ export declare function CompleteSpend(spent: NostrEvent[], change: Token): Action;
@@ -0,0 +1,49 @@
1
+ import { DeleteBlueprint } from "applesauce-factory/blueprints";
2
+ import { getTokenContent, isTokenContentLocked } from "../helpers/tokens.js";
3
+ import { WalletTokenBlueprint } from "../blueprints/tokens.js";
4
+ import { WalletHistoryBlueprint } from "../blueprints/history.js";
5
+ /**
6
+ * Adds a cashu token to the wallet and marks a list of nutzaps as redeemed
7
+ * @param token the cashu token to add
8
+ * @param redeemed an array of nutzap event ids to mark as redeemed
9
+ */
10
+ export function ReceiveToken(token, redeemed) {
11
+ return async ({ factory, publish }) => {
12
+ const amount = token.proofs.reduce((t, p) => t + p.amount, 0);
13
+ const tokenEvent = await factory.sign(await factory.create(WalletTokenBlueprint, token, []));
14
+ const history = await factory.sign(await factory.create(WalletHistoryBlueprint, { direction: "in", amount, mint: token.mint, created: [tokenEvent.id] }, redeemed ?? []));
15
+ await publish("Save tokens", tokenEvent);
16
+ await publish("Save transaction", history);
17
+ };
18
+ }
19
+ /** An action that deletes old token events and adds a spend history item */
20
+ export function CompleteSpend(spent, change) {
21
+ return async ({ factory, publish }) => {
22
+ if (spent.length === 0)
23
+ throw new Error("Cant complete spent with no token events");
24
+ if (spent.some((s) => isTokenContentLocked(s)))
25
+ throw new Error("Cant complete spend with locked tokens");
26
+ // create the nip-09 delete event for previous events
27
+ const deleteDraft = await factory.create(DeleteBlueprint, spent);
28
+ const changeAmount = change.proofs.reduce((t, p) => t + p.amount, 0);
29
+ // create a new token event if needed
30
+ const changeDraft = changeAmount > 0
31
+ ? await factory.create(WalletTokenBlueprint, change, spent.map((e) => e.id))
32
+ : undefined;
33
+ const total = spent.reduce((total, token) => total + getTokenContent(token).proofs.reduce((t, p) => t + p.amount, 0), 0);
34
+ // calculate the amount that was spent
35
+ const diff = total - changeAmount;
36
+ // sign delete and token
37
+ const signedDelete = await factory.sign(deleteDraft);
38
+ const signedToken = changeDraft && (await factory.sign(changeDraft));
39
+ // create a history entry
40
+ const history = await factory.create(WalletHistoryBlueprint, { direction: "out", mint: change.mint, amount: diff, created: signedToken ? [signedToken.id] : [] }, []);
41
+ // sign history
42
+ const signedHistory = await factory.sign(history);
43
+ // publish events
44
+ await publish("Delete old tokens", signedDelete);
45
+ if (signedToken)
46
+ await publish("Save change", signedToken);
47
+ await publish("Save transaction", signedHistory);
48
+ };
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { EventFactory } from "applesauce-factory";
3
+ import { FakeUser } from "../../__tests__/fake-user.js";
4
+ import { WalletTokenBlueprint } from "../../blueprints/tokens.js";
5
+ import { dumbTokenSelection, unlockTokenContent } from "../tokens.js";
6
+ const user = new FakeUser();
7
+ const factory = new EventFactory({ signer: user });
8
+ describe("dumbTokenSelection", () => {
9
+ it("should select old tokens first", async () => {
10
+ const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
11
+ mint: "https://money.com",
12
+ proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
13
+ }));
14
+ await unlockTokenContent(a, user);
15
+ const bDraft = await factory.create(WalletTokenBlueprint, {
16
+ mint: "https://money.com",
17
+ proofs: [{ secret: "B", C: "B", id: "B", amount: 50 }],
18
+ });
19
+ bDraft.created_at -= 60 * 60 * 7;
20
+ const b = await user.signEvent(bDraft);
21
+ await unlockTokenContent(b, user);
22
+ expect(dumbTokenSelection([a, b], 40)).toEqual([b]);
23
+ });
24
+ it("should enough tokens to total min amount", async () => {
25
+ const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
26
+ mint: "https://money.com",
27
+ proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
28
+ }));
29
+ await unlockTokenContent(a, user);
30
+ const bDraft = await factory.create(WalletTokenBlueprint, {
31
+ mint: "https://money.com",
32
+ proofs: [{ secret: "B", C: "B", id: "B", amount: 50 }],
33
+ });
34
+ bDraft.created_at -= 60 * 60 * 7;
35
+ const b = await user.signEvent(bDraft);
36
+ await unlockTokenContent(b, user);
37
+ expect(dumbTokenSelection([a, b], 120)).toEqual(expect.arrayContaining([a, b]));
38
+ });
39
+ it("should throw if not enough funds", async () => {
40
+ const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
41
+ mint: "https://money.com",
42
+ proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
43
+ }));
44
+ await unlockTokenContent(a, user);
45
+ expect(() => dumbTokenSelection([a], 120)).toThrow();
46
+ });
47
+ it("should throw if tokens are locked", async () => {
48
+ const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
49
+ mint: "https://money.com",
50
+ proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
51
+ }));
52
+ expect(() => dumbTokenSelection([a], 20)).toThrow();
53
+ });
54
+ });
@@ -17,9 +17,20 @@ export type TokenContent = {
17
17
  del: string[];
18
18
  };
19
19
  export declare const TokenContentSymbol: unique symbol;
20
- /** Returns the decrypted and parsed details of a 7375 token event */
20
+ export declare const TokenProofsTotalSymbol: unique symbol;
21
+ /**
22
+ * Returns the decrypted and parsed details of a 7375 token event
23
+ * @throws
24
+ */
21
25
  export declare function getTokenContent(token: NostrEvent): TokenContent;
22
26
  /** Returns if token details are locked */
23
27
  export declare function isTokenContentLocked(token: NostrEvent): boolean;
24
28
  /** Decrypts a k:7375 token event */
25
29
  export declare function unlockTokenContent(token: NostrEvent, signer: HiddenContentSigner): Promise<TokenContent>;
30
+ /** Gets the totaled amount of proofs in a token event */
31
+ export declare function getTokenProofsTotal(token: NostrEvent): number;
32
+ /**
33
+ * Selects oldest tokens that total up to more than the min amount
34
+ * @throws
35
+ */
36
+ export declare function dumbTokenSelection(tokens: NostrEvent[], minAmount: number): import("nostr-tools").Event[];
@@ -1,7 +1,11 @@
1
1
  import { getHiddenContent, getOrComputeCachedValue, isHiddenContentLocked, unlockHiddenContent, } from "applesauce-core/helpers";
2
2
  export const WALLET_TOKEN_KIND = 7375;
3
3
  export const TokenContentSymbol = Symbol.for("token-content");
4
- /** Returns the decrypted and parsed details of a 7375 token event */
4
+ export const TokenProofsTotalSymbol = Symbol.for("token-proofs-total");
5
+ /**
6
+ * Returns the decrypted and parsed details of a 7375 token event
7
+ * @throws
8
+ */
5
9
  export function getTokenContent(token) {
6
10
  return getOrComputeCachedValue(token, TokenContentSymbol, () => {
7
11
  const plaintext = getHiddenContent(token);
@@ -27,3 +31,33 @@ export async function unlockTokenContent(token, signer) {
27
31
  await unlockHiddenContent(token, signer);
28
32
  return getTokenContent(token);
29
33
  }
34
+ /** Gets the totaled amount of proofs in a token event */
35
+ export function getTokenProofsTotal(token) {
36
+ return getOrComputeCachedValue(token, TokenProofsTotalSymbol, () => {
37
+ const content = getTokenContent(token);
38
+ return content.proofs.reduce((t, p) => t + p.amount, 0);
39
+ });
40
+ }
41
+ /**
42
+ * Selects oldest tokens that total up to more than the min amount
43
+ * @throws
44
+ */
45
+ export function dumbTokenSelection(tokens, minAmount) {
46
+ if (tokens.some((t) => isTokenContentLocked(t)))
47
+ throw new Error("All tokens must be unlocked");
48
+ const total = tokens.reduce((t, token) => t + getTokenProofsTotal(token), 0);
49
+ if (total < minAmount)
50
+ throw new Error("Insufficient funds");
51
+ // sort newest to oldest
52
+ const sorted = Array.from(tokens).sort((a, b) => b.created_at - a.created_at);
53
+ let amount = 0;
54
+ const selected = [];
55
+ while (amount < minAmount) {
56
+ const token = sorted.pop();
57
+ if (!token)
58
+ throw new Error("Ran out of tokens");
59
+ selected.push(token);
60
+ amount += getTokenProofsTotal(token);
61
+ }
62
+ return selected;
63
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { EventStore, QueryStore } from "applesauce-core";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { EventFactory } from "applesauce-factory";
4
+ import { generateSecretKey } from "nostr-tools";
5
+ import { subscribeSpyTo } from "@hirez_io/observer-spy";
6
+ import { FakeUser } from "../../__tests__/fake-user.js";
7
+ import { WalletBlueprint } from "../../blueprints/wallet.js";
8
+ import { WalletQuery } from "../wallet.js";
9
+ import { unlockWallet } from "../../helpers/wallet.js";
10
+ const user = new FakeUser();
11
+ const factory = new EventFactory({ signer: user });
12
+ let events;
13
+ let queries;
14
+ beforeEach(() => {
15
+ events = new EventStore();
16
+ queries = new QueryStore(events);
17
+ });
18
+ describe("WalletQuery", () => {
19
+ it("it should update when event is unlocked", async () => {
20
+ const wallet = await user.signEvent(await factory.create(WalletBlueprint, generateSecretKey(), []));
21
+ events.add(wallet);
22
+ const spy = subscribeSpyTo(queries.createQuery(WalletQuery, await user.getPublicKey()));
23
+ await unlockWallet(wallet, user);
24
+ expect(spy.getValues()).toEqual([
25
+ expect.objectContaining({ locked: true }),
26
+ expect.objectContaining({ locked: false }),
27
+ ]);
28
+ });
29
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-wallet",
3
- "version": "0.0.0-next-20250312113207",
3
+ "version": "0.0.0-next-20250312162115",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -79,16 +79,16 @@
79
79
  "dependencies": {
80
80
  "@cashu/cashu-ts": "2.0.0-rc1",
81
81
  "@noble/hashes": "^1.7.1",
82
- "applesauce-actions": "0.0.0-next-20250312113207",
83
- "applesauce-core": "0.0.0-next-20250312113207",
84
- "applesauce-factory": "0.0.0-next-20250312113207",
82
+ "applesauce-actions": "0.0.0-next-20250312162115",
83
+ "applesauce-core": "0.0.0-next-20250312162115",
84
+ "applesauce-factory": "0.0.0-next-20250312162115",
85
85
  "nostr-tools": "^2.10.4",
86
86
  "rxjs": "^7.8.1"
87
87
  },
88
88
  "devDependencies": {
89
89
  "@hirez_io/observer-spy": "^2.2.0",
90
90
  "@types/debug": "^4.1.12",
91
- "applesauce-signers": "0.0.0-next-20250312113207",
91
+ "applesauce-signers": "0.0.0-next-20250312162115",
92
92
  "typescript": "^5.7.3",
93
93
  "vitest": "^3.0.5"
94
94
  },