applesauce-wallet 0.0.0-next-20250314151125 → 0.0.0-next-20250315140539
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.
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vitest } from "vitest";
|
|
2
|
+
import { EventStore } from "applesauce-core";
|
|
3
|
+
import { EventFactory } from "applesauce-factory";
|
|
4
|
+
import { ActionHub } from "applesauce-actions";
|
|
5
|
+
import { CheckStateEnum } from "@cashu/cashu-ts";
|
|
6
|
+
import { subscribeSpyTo } from "@hirez_io/observer-spy";
|
|
7
|
+
import { FakeUser } from "../../__tests__/fake-user.js";
|
|
8
|
+
import { ConsolidateTokens } from "../tokens.js";
|
|
9
|
+
import { WalletTokenBlueprint } from "../../blueprints/tokens.js";
|
|
10
|
+
import { unlockTokenContent, WALLET_TOKEN_KIND } from "../../helpers/tokens.js";
|
|
11
|
+
// Update the mock to allow controlling the states
|
|
12
|
+
const mockCheckProofsStates = vitest.fn();
|
|
13
|
+
vitest.mock("@cashu/cashu-ts", () => ({
|
|
14
|
+
CashuMint: vitest.fn(),
|
|
15
|
+
CashuWallet: vitest.fn().mockImplementation(() => ({
|
|
16
|
+
checkProofsStates: mockCheckProofsStates,
|
|
17
|
+
})),
|
|
18
|
+
CheckStateEnum: { UNSPENT: "UNSPENT", SPENT: "SPENT" },
|
|
19
|
+
}));
|
|
20
|
+
const user = new FakeUser();
|
|
21
|
+
const testMint = "https://mint.test.com";
|
|
22
|
+
let events;
|
|
23
|
+
let factory;
|
|
24
|
+
let hub;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
events = new EventStore();
|
|
27
|
+
factory = new EventFactory({ signer: user });
|
|
28
|
+
hub = new ActionHub(events, factory);
|
|
29
|
+
// Reset the mock before each test
|
|
30
|
+
mockCheckProofsStates.mockReset();
|
|
31
|
+
});
|
|
32
|
+
describe("ConsolidateTokens", () => {
|
|
33
|
+
it("should combine multiple token events into a single event", async () => {
|
|
34
|
+
// Set all proofs to be unspent
|
|
35
|
+
mockCheckProofsStates.mockResolvedValue([{ state: CheckStateEnum.UNSPENT }, { state: CheckStateEnum.UNSPENT }]);
|
|
36
|
+
// Create two token events with different proofs
|
|
37
|
+
const token1 = await factory.sign(await factory.create(WalletTokenBlueprint, {
|
|
38
|
+
mint: testMint,
|
|
39
|
+
proofs: [{ amount: 10, secret: "secret1", C: "C", id: "id" }],
|
|
40
|
+
}));
|
|
41
|
+
const token2 = await factory.sign(await factory.create(WalletTokenBlueprint, {
|
|
42
|
+
mint: testMint,
|
|
43
|
+
proofs: [{ amount: 20, secret: "secret2", C: "C", id: "id" }],
|
|
44
|
+
}));
|
|
45
|
+
// Add tokens to event store
|
|
46
|
+
events.add(token1);
|
|
47
|
+
events.add(token2);
|
|
48
|
+
// Run consolidate action
|
|
49
|
+
const spy = subscribeSpyTo(hub.exec(ConsolidateTokens));
|
|
50
|
+
await spy.onComplete();
|
|
51
|
+
// First event should be the new consolidated token
|
|
52
|
+
expect(spy.getValueAt(0).kind).toBe(WALLET_TOKEN_KIND);
|
|
53
|
+
// Extract token content and verify proofs were combined
|
|
54
|
+
const content = await unlockTokenContent(spy.getValueAt(0), user);
|
|
55
|
+
expect(content.proofs).toHaveLength(2);
|
|
56
|
+
expect(content.proofs).toEqual(expect.arrayContaining([
|
|
57
|
+
expect.objectContaining({ amount: 10, secret: "secret1" }),
|
|
58
|
+
expect.objectContaining({ amount: 20, secret: "secret2" }),
|
|
59
|
+
]));
|
|
60
|
+
expect(content.mint).toBe(testMint);
|
|
61
|
+
});
|
|
62
|
+
it("should handle duplicate proofs", async () => {
|
|
63
|
+
// Set all proofs to be unspent
|
|
64
|
+
mockCheckProofsStates.mockResolvedValue([{ state: CheckStateEnum.UNSPENT }, { state: CheckStateEnum.UNSPENT }]);
|
|
65
|
+
// Create two token events with different proofs
|
|
66
|
+
const token1 = await factory.sign(await factory.create(WalletTokenBlueprint, {
|
|
67
|
+
mint: testMint,
|
|
68
|
+
proofs: [{ amount: 10, secret: "secret1", C: "C", id: "id" }],
|
|
69
|
+
}));
|
|
70
|
+
const token2 = await factory.sign(await factory.create(WalletTokenBlueprint, {
|
|
71
|
+
mint: testMint,
|
|
72
|
+
proofs: [
|
|
73
|
+
{ amount: 20, secret: "secret2", C: "C", id: "id" },
|
|
74
|
+
{ amount: 10, secret: "secret1", C: "C", id: "id" },
|
|
75
|
+
],
|
|
76
|
+
}));
|
|
77
|
+
// Add tokens to event store
|
|
78
|
+
events.add(token1);
|
|
79
|
+
events.add(token2);
|
|
80
|
+
// Run consolidate action
|
|
81
|
+
const spy = subscribeSpyTo(hub.exec(ConsolidateTokens));
|
|
82
|
+
await spy.onComplete();
|
|
83
|
+
// First event should be the new consolidated token
|
|
84
|
+
expect(spy.getValueAt(0).kind).toBe(WALLET_TOKEN_KIND);
|
|
85
|
+
// Extract token content and verify proofs were combined
|
|
86
|
+
const content = await unlockTokenContent(spy.getValueAt(0), user);
|
|
87
|
+
expect(content.proofs).toHaveLength(2);
|
|
88
|
+
expect(content.proofs).toEqual(expect.arrayContaining([
|
|
89
|
+
expect.objectContaining({ amount: 10, secret: "secret1" }),
|
|
90
|
+
expect.objectContaining({ amount: 20, secret: "secret2" }),
|
|
91
|
+
]));
|
|
92
|
+
expect(content.mint).toBe(testMint);
|
|
93
|
+
});
|
|
94
|
+
it("should filter out spent proofs", async () => {
|
|
95
|
+
// Create token events with multiple proofs
|
|
96
|
+
const token1 = await factory.sign(await factory.create(WalletTokenBlueprint, {
|
|
97
|
+
mint: testMint,
|
|
98
|
+
proofs: [
|
|
99
|
+
{ amount: 10, secret: "secret1", C: "C", id: "id" },
|
|
100
|
+
{ amount: 20, secret: "secret2", C: "C", id: "id" },
|
|
101
|
+
],
|
|
102
|
+
}, []));
|
|
103
|
+
const token2 = await factory.sign(await factory.create(WalletTokenBlueprint, {
|
|
104
|
+
mint: testMint,
|
|
105
|
+
proofs: [
|
|
106
|
+
{ amount: 30, secret: "secret3", C: "C", id: "id" },
|
|
107
|
+
{ amount: 40, secret: "secret4", C: "C", id: "id" },
|
|
108
|
+
],
|
|
109
|
+
}, []));
|
|
110
|
+
// Add tokens to event store
|
|
111
|
+
events.add(token1);
|
|
112
|
+
events.add(token2);
|
|
113
|
+
// Mock some proofs as spent
|
|
114
|
+
mockCheckProofsStates.mockResolvedValue([
|
|
115
|
+
{ state: CheckStateEnum.UNSPENT }, // secret1
|
|
116
|
+
{ state: CheckStateEnum.SPENT }, // secret2
|
|
117
|
+
{ state: CheckStateEnum.SPENT }, // secret3
|
|
118
|
+
{ state: CheckStateEnum.UNSPENT }, // secret4
|
|
119
|
+
]);
|
|
120
|
+
// Run consolidate action
|
|
121
|
+
const spy = subscribeSpyTo(hub.exec(ConsolidateTokens));
|
|
122
|
+
await spy.onComplete();
|
|
123
|
+
// Verify the consolidated token only contains unspent proofs
|
|
124
|
+
const content = await unlockTokenContent(spy.getValueAt(0), user);
|
|
125
|
+
expect(content.proofs).toHaveLength(2);
|
|
126
|
+
expect(content.proofs).toEqual(expect.arrayContaining([
|
|
127
|
+
expect.objectContaining({ amount: 10, secret: "secret1" }),
|
|
128
|
+
expect.objectContaining({ amount: 40, secret: "secret4" }),
|
|
129
|
+
]));
|
|
130
|
+
expect(content.mint).toBe(testMint);
|
|
131
|
+
// Verify checkProofsStates was called with all proofs
|
|
132
|
+
expect(mockCheckProofsStates).toHaveBeenCalledWith(expect.arrayContaining([
|
|
133
|
+
expect.objectContaining({ amount: 10, secret: "secret1" }),
|
|
134
|
+
expect.objectContaining({ amount: 20, secret: "secret2" }),
|
|
135
|
+
expect.objectContaining({ amount: 30, secret: "secret3" }),
|
|
136
|
+
expect.objectContaining({ amount: 40, secret: "secret4" }),
|
|
137
|
+
]));
|
|
138
|
+
});
|
|
139
|
+
});
|
package/dist/actions/tokens.d.ts
CHANGED
|
@@ -11,4 +11,7 @@ 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
|
-
/**
|
|
14
|
+
/** Combines all unlocked token events into a single event per mint */
|
|
15
|
+
export declare function ConsolidateTokens(opts?: {
|
|
16
|
+
ignoreLocked?: boolean;
|
|
17
|
+
}): Action;
|
package/dist/actions/tokens.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { CashuMint, CashuWallet, CheckStateEnum } from "@cashu/cashu-ts";
|
|
1
2
|
import { DeleteBlueprint } from "applesauce-factory/blueprints";
|
|
2
|
-
import { getTokenContent, isTokenContentLocked } from "../helpers/tokens.js";
|
|
3
|
+
import { getTokenContent, ignoreDuplicateProofs, isTokenContentLocked, WALLET_TOKEN_KIND } from "../helpers/tokens.js";
|
|
3
4
|
import { WalletTokenBlueprint } from "../blueprints/tokens.js";
|
|
4
5
|
import { WalletHistoryBlueprint } from "../blueprints/history.js";
|
|
5
6
|
/**
|
|
@@ -62,32 +63,48 @@ export function CompleteSpend(spent, change) {
|
|
|
62
63
|
yield signedHistory;
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
66
|
+
/** Combines all unlocked token events into a single event per mint */
|
|
67
|
+
export function ConsolidateTokens(opts) {
|
|
68
|
+
return async function* ({ events, factory, self }) {
|
|
69
|
+
const tokens = Array.from(events.getAll({ kinds: [WALLET_TOKEN_KIND], authors: [self] })).filter((token) => {
|
|
70
|
+
if (isTokenContentLocked(token)) {
|
|
71
|
+
if (opts?.ignoreLocked)
|
|
72
|
+
return false;
|
|
73
|
+
else
|
|
74
|
+
throw new Error("Token is locked");
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
const byMint = tokens.reduce((map, token) => {
|
|
80
|
+
const mint = getTokenContent(token).mint;
|
|
81
|
+
if (!map.has(mint))
|
|
82
|
+
map.set(mint, []);
|
|
83
|
+
map.get(mint).push(token);
|
|
84
|
+
return map;
|
|
85
|
+
}, new Map());
|
|
86
|
+
// loop over each mint and consolidate proofs
|
|
87
|
+
for (const [mint, tokens] of byMint) {
|
|
88
|
+
const cashuMint = new CashuMint(mint);
|
|
89
|
+
const cashuWallet = new CashuWallet(cashuMint);
|
|
90
|
+
// get all tokens proofs
|
|
91
|
+
const proofs = tokens
|
|
92
|
+
.map((t) => getTokenContent(t).proofs)
|
|
93
|
+
.flat()
|
|
94
|
+
// filter out duplicate proofs
|
|
95
|
+
.filter(ignoreDuplicateProofs());
|
|
96
|
+
// NOTE: this assumes that the states array is the same length and order as the proofs array
|
|
97
|
+
const states = await cashuWallet.checkProofsStates(proofs);
|
|
98
|
+
const notSpent = proofs.filter((_, i) => states[i].state !== CheckStateEnum.SPENT);
|
|
99
|
+
// create delete and token event
|
|
100
|
+
const deleteDraft = await factory.create(DeleteBlueprint, tokens);
|
|
101
|
+
const tokenDraft = await factory.create(WalletTokenBlueprint, { mint, proofs: notSpent }, tokens.map((t) => t.id));
|
|
102
|
+
// sign events
|
|
103
|
+
const signedToken = await factory.sign(tokenDraft);
|
|
104
|
+
const signedDelete = await factory.sign(deleteDraft);
|
|
105
|
+
// publish events for mint
|
|
106
|
+
yield signedToken;
|
|
107
|
+
yield signedDelete;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -9,6 +9,10 @@ export function setTokenContent(token, del = []) {
|
|
|
9
9
|
const method = EventContentEncryptionMethod[draft.kind];
|
|
10
10
|
if (!method)
|
|
11
11
|
throw new Error("Failed to find encryption method");
|
|
12
|
+
if (!token.mint)
|
|
13
|
+
throw new Error("Token mint is required");
|
|
14
|
+
if (!token.proofs || token.proofs.length === 0)
|
|
15
|
+
throw new Error("Token proofs are required");
|
|
12
16
|
const content = {
|
|
13
17
|
mint: token.mint,
|
|
14
18
|
proofs: token.proofs,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "applesauce-wallet",
|
|
3
|
-
"version": "0.0.0-next-
|
|
3
|
+
"version": "0.0.0-next-20250315140539",
|
|
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-
|
|
84
|
-
"applesauce-core": "0.0.0-next-
|
|
85
|
-
"applesauce-factory": "0.0.0-next-
|
|
83
|
+
"applesauce-actions": "0.0.0-next-20250315140539",
|
|
84
|
+
"applesauce-core": "0.0.0-next-20250315140539",
|
|
85
|
+
"applesauce-factory": "0.0.0-next-20250315140539",
|
|
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-
|
|
92
|
+
"applesauce-signers": "0.0.0-next-20250315140539",
|
|
93
93
|
"typescript": "^5.7.3",
|
|
94
94
|
"vitest": "^3.0.5"
|
|
95
95
|
},
|