applesauce-wallet 0.0.0-next-20250312152821 → 0.0.0-next-20250312195837

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,13 @@
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 tokens and creates a new one but does not add a history event */
11
+ export declare function RolloverTokens(tokens: NostrEvent[], token: Token): Action;
12
+ /** An action that deletes old token events and adds a spend history item */
13
+ export declare function CompleteSpend(spent: NostrEvent[], change: Token): Action;
@@ -0,0 +1,64 @@
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 tokens and creates a new one but does not add a history event */
20
+ export function RolloverTokens(tokens, token) {
21
+ return async ({ factory, publish }) => {
22
+ // create a delete event for old tokens
23
+ const deleteDraft = await factory.create(DeleteBlueprint, tokens);
24
+ // create a new token event
25
+ const tokenDraft = await factory.create(WalletTokenBlueprint, token, tokens.map((e) => e.id));
26
+ // sign events
27
+ const signedDelete = await factory.sign(deleteDraft);
28
+ const signedToken = await factory.sign(tokenDraft);
29
+ // publish events
30
+ await publish("Delete old tokens", signedDelete);
31
+ await publish("Save new token", signedToken);
32
+ };
33
+ }
34
+ /** An action that deletes old token events and adds a spend history item */
35
+ export function CompleteSpend(spent, change) {
36
+ return async ({ factory, publish }) => {
37
+ if (spent.length === 0)
38
+ throw new Error("Cant complete spent with no token events");
39
+ if (spent.some((s) => isTokenContentLocked(s)))
40
+ throw new Error("Cant complete spend with locked tokens");
41
+ // create the nip-09 delete event for previous events
42
+ const deleteDraft = await factory.create(DeleteBlueprint, spent);
43
+ const changeAmount = change.proofs.reduce((t, p) => t + p.amount, 0);
44
+ // create a new token event if needed
45
+ const changeDraft = changeAmount > 0
46
+ ? await factory.create(WalletTokenBlueprint, change, spent.map((e) => e.id))
47
+ : undefined;
48
+ const total = spent.reduce((total, token) => total + getTokenContent(token).proofs.reduce((t, p) => t + p.amount, 0), 0);
49
+ // calculate the amount that was spent
50
+ const diff = total - changeAmount;
51
+ // sign delete and token
52
+ const signedDelete = await factory.sign(deleteDraft);
53
+ const signedToken = changeDraft && (await factory.sign(changeDraft));
54
+ // create a history entry
55
+ const history = await factory.create(WalletHistoryBlueprint, { direction: "out", mint: change.mint, amount: diff, created: signedToken ? [signedToken.id] : [] }, []);
56
+ // sign history
57
+ const signedHistory = await factory.sign(history);
58
+ // publish events
59
+ await publish("Delete old tokens", signedDelete);
60
+ if (signedToken)
61
+ await publish("Save change", signedToken);
62
+ await publish("Save transaction", signedHistory);
63
+ };
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
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
+ import { HiddenContentSymbol } from "applesauce-core/helpers";
7
+ const user = new FakeUser();
8
+ const factory = new EventFactory({ signer: user });
9
+ describe("dumbTokenSelection", () => {
10
+ it("should select old tokens first", async () => {
11
+ const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
12
+ mint: "https://money.com",
13
+ proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
14
+ }));
15
+ await unlockTokenContent(a, user);
16
+ const bDraft = await factory.create(WalletTokenBlueprint, {
17
+ mint: "https://money.com",
18
+ proofs: [{ secret: "B", C: "B", id: "B", amount: 50 }],
19
+ });
20
+ bDraft.created_at -= 60 * 60 * 7;
21
+ const b = await user.signEvent(bDraft);
22
+ await unlockTokenContent(b, user);
23
+ expect(dumbTokenSelection([a, b], 40)).toEqual([b]);
24
+ });
25
+ it("should enough tokens to total min amount", async () => {
26
+ const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
27
+ mint: "https://money.com",
28
+ proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
29
+ }));
30
+ await unlockTokenContent(a, user);
31
+ const bDraft = await factory.create(WalletTokenBlueprint, {
32
+ mint: "https://money.com",
33
+ proofs: [{ secret: "B", C: "B", id: "B", amount: 50 }],
34
+ });
35
+ bDraft.created_at -= 60 * 60 * 7;
36
+ const b = await user.signEvent(bDraft);
37
+ await unlockTokenContent(b, user);
38
+ expect(dumbTokenSelection([a, b], 120)).toEqual(expect.arrayContaining([a, b]));
39
+ });
40
+ it("should throw if not enough funds", async () => {
41
+ const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
42
+ mint: "https://money.com",
43
+ proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
44
+ }));
45
+ await unlockTokenContent(a, user);
46
+ expect(() => dumbTokenSelection([a], 120)).toThrow();
47
+ });
48
+ it("should throw if tokens are locked", async () => {
49
+ const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
50
+ mint: "https://money.com",
51
+ proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
52
+ }));
53
+ // manually remove the hidden content to lock it again
54
+ Reflect.deleteProperty(a, HiddenContentSymbol);
55
+ expect(() => dumbTokenSelection([a], 20)).toThrow();
56
+ });
57
+ });
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "applesauce-wallet",
3
- "version": "0.0.0-next-20250312152821",
3
+ "version": "0.0.0-next-20250312195837",
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-20250312152821",
83
- "applesauce-core": "0.0.0-next-20250312152821",
84
- "applesauce-factory": "0.0.0-next-20250312152821",
82
+ "applesauce-actions": "0.0.0-next-20250312195837",
83
+ "applesauce-core": "0.0.0-next-20250312195837",
84
+ "applesauce-factory": "0.0.0-next-20250312195837",
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-20250312152821",
91
+ "applesauce-signers": "0.0.0-next-20250312195837",
92
92
  "typescript": "^5.7.3",
93
93
  "vitest": "^3.0.5"
94
94
  },