applesauce-wallet 0.11.0 → 0.12.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/README.md +3 -0
- package/dist/__tests__/fake-user.d.ts +10 -0
- package/dist/__tests__/fake-user.js +31 -0
- package/dist/actions/__tests__/tokens.test.d.ts +1 -0
- package/dist/actions/__tests__/tokens.test.js +139 -0
- package/dist/actions/__tests__/wallet.test.d.ts +1 -0
- package/dist/actions/__tests__/wallet.test.js +56 -0
- package/dist/actions/index.d.ts +2 -0
- package/dist/actions/index.js +2 -0
- package/dist/actions/tokens.d.ts +17 -0
- package/dist/actions/tokens.js +110 -0
- package/dist/actions/wallet.d.ts +13 -0
- package/dist/actions/wallet.js +64 -0
- package/dist/blueprints/history.d.ts +5 -0
- package/dist/blueprints/history.js +11 -0
- package/dist/blueprints/index.d.ts +2 -0
- package/dist/blueprints/index.js +2 -0
- package/dist/blueprints/tokens.d.ts +8 -0
- package/dist/blueprints/tokens.js +11 -0
- package/dist/blueprints/wallet.d.ts +6 -0
- package/dist/blueprints/wallet.js +13 -0
- package/dist/helpers/__tests__/animated-qr.test.d.ts +1 -0
- package/dist/helpers/__tests__/animated-qr.test.js +44 -0
- package/dist/helpers/__tests__/tokens.test.d.ts +1 -0
- package/dist/helpers/__tests__/tokens.test.js +127 -0
- package/dist/helpers/animated-qr.d.ts +30 -0
- package/dist/helpers/animated-qr.js +71 -0
- package/dist/helpers/history.d.ts +26 -0
- package/dist/helpers/history.js +45 -0
- package/dist/helpers/index.d.ts +3 -0
- package/dist/helpers/index.js +3 -0
- package/dist/helpers/tokens.d.ts +58 -0
- package/dist/helpers/tokens.js +160 -0
- package/dist/helpers/wallet.d.ts +3 -1
- package/dist/helpers/wallet.js +9 -4
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/operations/event/__tests__/wallet.test.d.ts +1 -0
- package/dist/operations/event/__tests__/wallet.test.js +25 -0
- package/dist/operations/event/history.d.ts +7 -0
- package/dist/operations/event/history.js +19 -0
- package/dist/operations/event/index.d.ts +3 -0
- package/dist/operations/event/index.js +3 -0
- package/dist/operations/event/tokens.d.ts +4 -0
- package/dist/operations/event/tokens.js +24 -0
- package/dist/operations/event/wallet.d.ts +4 -0
- package/dist/operations/event/wallet.js +14 -0
- package/dist/operations/index.d.ts +2 -0
- package/dist/operations/index.js +2 -0
- package/dist/operations/tag/__tests__/wallet.test.d.ts +1 -0
- package/dist/operations/tag/__tests__/wallet.test.js +16 -0
- package/dist/operations/tag/history.d.ts +14 -0
- package/dist/operations/tag/history.js +34 -0
- package/dist/operations/tag/index.d.ts +2 -0
- package/dist/operations/tag/index.js +2 -0
- package/dist/operations/tag/wallet.d.ts +5 -0
- package/dist/operations/tag/wallet.js +15 -0
- package/dist/queries/__tests__/wallet.test.d.ts +1 -0
- package/dist/queries/__tests__/wallet.test.js +30 -0
- package/dist/queries/history.d.ts +6 -0
- package/dist/queries/history.js +27 -0
- package/dist/queries/index.d.ts +2 -0
- package/dist/queries/index.js +2 -0
- package/dist/queries/tokens.d.ts +6 -0
- package/dist/queries/tokens.js +64 -0
- package/dist/queries/wallet.d.ts +4 -1
- package/dist/queries/wallet.js +3 -3
- package/package.json +43 -2
package/README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Applesauce Wallet
|
|
2
|
+
|
|
3
|
+
The `applesauce-wallet` package is a package of helpers, queries, blueprints and other useful stuff for [NIP-60](https://github.com/nostr-protocol/nips/blob/master/60.md) wallets and [NIP-61](https://github.com/nostr-protocol/nips/blob/master/61.md) nutzaps
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { SimpleSigner } from "applesauce-signers/signers/simple-signer";
|
|
2
|
+
import type { NostrEvent } from "nostr-tools";
|
|
3
|
+
export declare class FakeUser extends SimpleSigner {
|
|
4
|
+
pubkey: string;
|
|
5
|
+
event(data?: Partial<NostrEvent>): NostrEvent;
|
|
6
|
+
note(content?: string, extra?: Partial<NostrEvent>): import("nostr-tools").Event;
|
|
7
|
+
profile(profile: any, extra?: Partial<NostrEvent>): import("nostr-tools").Event;
|
|
8
|
+
contacts(pubkeys?: string[]): import("nostr-tools").Event;
|
|
9
|
+
list(tags?: string[][], extra?: Partial<NostrEvent>): import("nostr-tools").Event;
|
|
10
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { unixNow } from "applesauce-core/helpers";
|
|
2
|
+
import { SimpleSigner } from "applesauce-signers/signers/simple-signer";
|
|
3
|
+
import { finalizeEvent, getPublicKey, kinds } from "nostr-tools";
|
|
4
|
+
export class FakeUser extends SimpleSigner {
|
|
5
|
+
pubkey = getPublicKey(this.key);
|
|
6
|
+
event(data) {
|
|
7
|
+
return finalizeEvent({
|
|
8
|
+
kind: data?.kind ?? kinds.ShortTextNote,
|
|
9
|
+
content: data?.content || "",
|
|
10
|
+
created_at: data?.created_at ?? unixNow(),
|
|
11
|
+
tags: data?.tags || [],
|
|
12
|
+
}, this.key);
|
|
13
|
+
}
|
|
14
|
+
note(content = "Hello World", extra) {
|
|
15
|
+
return this.event({ kind: kinds.ShortTextNote, content, ...extra });
|
|
16
|
+
}
|
|
17
|
+
profile(profile, extra) {
|
|
18
|
+
return this.event({ kind: kinds.Metadata, content: JSON.stringify({ ...profile }), ...extra });
|
|
19
|
+
}
|
|
20
|
+
contacts(pubkeys = []) {
|
|
21
|
+
return this.event({ kind: kinds.Contacts, tags: pubkeys.map((p) => ["p", p]) });
|
|
22
|
+
}
|
|
23
|
+
list(tags = [], extra) {
|
|
24
|
+
return this.event({
|
|
25
|
+
kind: kinds.Bookmarksets,
|
|
26
|
+
content: "",
|
|
27
|
+
tags: [["d", String(Math.round(Math.random() * 10000))], ...tags],
|
|
28
|
+
...extra,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vitest } from "vitest";
|
|
2
|
+
import { unlockHiddenTags } from "applesauce-core/helpers";
|
|
3
|
+
import { lastValueFrom } from "rxjs";
|
|
4
|
+
import { generateSecretKey } from "nostr-tools";
|
|
5
|
+
import { EventStore } from "applesauce-core";
|
|
6
|
+
import { EventFactory } from "applesauce-factory";
|
|
7
|
+
import { ActionHub } from "applesauce-actions";
|
|
8
|
+
import { bytesToHex } from "@noble/hashes/utils";
|
|
9
|
+
import { FakeUser } from "../../__tests__/fake-user.js";
|
|
10
|
+
import { WalletAddPrivateKey, CreateWallet } from "../wallet.js";
|
|
11
|
+
import { getWalletPrivateKey, unlockWallet, WALLET_BACKUP_KIND } from "../../helpers/wallet.js";
|
|
12
|
+
import { WalletBlueprint } from "../../blueprints/wallet.js";
|
|
13
|
+
const user = new FakeUser();
|
|
14
|
+
let events;
|
|
15
|
+
let factory;
|
|
16
|
+
let publish;
|
|
17
|
+
let hub;
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
events = new EventStore();
|
|
20
|
+
factory = new EventFactory({ signer: user });
|
|
21
|
+
publish = vitest.fn().mockResolvedValue(undefined);
|
|
22
|
+
hub = new ActionHub(events, factory, publish);
|
|
23
|
+
});
|
|
24
|
+
describe("CreateWallet", () => {
|
|
25
|
+
it("should publish a wallet backup event", async () => {
|
|
26
|
+
await hub.run(CreateWallet, ["https://mint.money.com"]);
|
|
27
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({ kind: WALLET_BACKUP_KIND }));
|
|
28
|
+
});
|
|
29
|
+
it("should publish a wallet event with mints", async () => {
|
|
30
|
+
const event = await lastValueFrom(hub.exec(CreateWallet, ["https://mint.money.com"]));
|
|
31
|
+
const hiddenTags = await unlockHiddenTags(event, user);
|
|
32
|
+
// the second call should be the wallet event
|
|
33
|
+
expect(hiddenTags).toEqual(expect.arrayContaining([["mint", "https://mint.money.com"]]));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe("WalletAddPrivateKey", () => {
|
|
37
|
+
it("should add a private key to an existing wallet event without a private key", async () => {
|
|
38
|
+
const walletEvent = await factory.sign(await factory.create(WalletBlueprint, ["https://mint.money.com"]));
|
|
39
|
+
await events.add(walletEvent);
|
|
40
|
+
const privateKey = generateSecretKey();
|
|
41
|
+
const updatedWallet = await lastValueFrom(hub.exec(WalletAddPrivateKey, privateKey));
|
|
42
|
+
await unlockWallet(updatedWallet, user);
|
|
43
|
+
const key = getWalletPrivateKey(updatedWallet);
|
|
44
|
+
expect(key).toBeDefined();
|
|
45
|
+
expect(bytesToHex(key)).toEqual(bytesToHex(privateKey));
|
|
46
|
+
});
|
|
47
|
+
it("should throw an error if a wallet event already has a private key", async () => {
|
|
48
|
+
const walletEvent = await factory.sign(await factory.create(WalletBlueprint, ["https://mint.money.com"], generateSecretKey()));
|
|
49
|
+
await events.add(walletEvent);
|
|
50
|
+
await expect(hub.run(WalletAddPrivateKey, generateSecretKey())).rejects.toThrow("Wallet already has a private key");
|
|
51
|
+
});
|
|
52
|
+
it("should throw an error if the wallet event does not exist", async () => {
|
|
53
|
+
const privateKey = generateSecretKey();
|
|
54
|
+
await expect(hub.run(WalletAddPrivateKey, privateKey)).rejects.toThrow("Wallet does not exist");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
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[], fee?: number): 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;
|
|
14
|
+
/** Combines all unlocked token events into a single event per mint */
|
|
15
|
+
export declare function ConsolidateTokens(opts?: {
|
|
16
|
+
ignoreLocked?: boolean;
|
|
17
|
+
}): Action;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { CashuMint, CashuWallet, CheckStateEnum } from "@cashu/cashu-ts";
|
|
2
|
+
import { DeleteBlueprint } from "applesauce-factory/blueprints";
|
|
3
|
+
import { getTokenContent, ignoreDuplicateProofs, isTokenContentLocked, WALLET_TOKEN_KIND } from "../helpers/tokens.js";
|
|
4
|
+
import { WalletTokenBlueprint } from "../blueprints/tokens.js";
|
|
5
|
+
import { WalletHistoryBlueprint } from "../blueprints/history.js";
|
|
6
|
+
/**
|
|
7
|
+
* Adds a cashu token to the wallet and marks a list of nutzaps as redeemed
|
|
8
|
+
* @param token the cashu token to add
|
|
9
|
+
* @param redeemed an array of nutzap event ids to mark as redeemed
|
|
10
|
+
*/
|
|
11
|
+
export function ReceiveToken(token, redeemed, fee) {
|
|
12
|
+
return async function* ({ factory }) {
|
|
13
|
+
const amount = token.proofs.reduce((t, p) => t + p.amount, 0);
|
|
14
|
+
const tokenEvent = await factory.sign(await factory.create(WalletTokenBlueprint, token, []));
|
|
15
|
+
const history = await factory.sign(await factory.create(WalletHistoryBlueprint, { direction: "in", amount, mint: token.mint, created: [tokenEvent.id], fee }, redeemed ?? []));
|
|
16
|
+
yield tokenEvent;
|
|
17
|
+
yield history;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** An action that deletes old tokens and creates a new one but does not add a history event */
|
|
21
|
+
export function RolloverTokens(tokens, token) {
|
|
22
|
+
return async function* ({ factory }) {
|
|
23
|
+
// create a delete event for old tokens
|
|
24
|
+
const deleteDraft = await factory.create(DeleteBlueprint, tokens);
|
|
25
|
+
// create a new token event
|
|
26
|
+
const tokenDraft = await factory.create(WalletTokenBlueprint, token, tokens.map((e) => e.id));
|
|
27
|
+
// sign events
|
|
28
|
+
const signedDelete = await factory.sign(deleteDraft);
|
|
29
|
+
const signedToken = await factory.sign(tokenDraft);
|
|
30
|
+
// publish events
|
|
31
|
+
yield signedDelete;
|
|
32
|
+
yield signedToken;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** An action that deletes old token events and adds a spend history item */
|
|
36
|
+
export function CompleteSpend(spent, change) {
|
|
37
|
+
return async function* ({ factory }) {
|
|
38
|
+
if (spent.length === 0)
|
|
39
|
+
throw new Error("Cant complete spent with no token events");
|
|
40
|
+
if (spent.some((s) => isTokenContentLocked(s)))
|
|
41
|
+
throw new Error("Cant complete spend with locked tokens");
|
|
42
|
+
// create the nip-09 delete event for previous events
|
|
43
|
+
const deleteDraft = await factory.create(DeleteBlueprint, spent);
|
|
44
|
+
const changeAmount = change.proofs.reduce((t, p) => t + p.amount, 0);
|
|
45
|
+
// create a new token event if needed
|
|
46
|
+
const changeDraft = changeAmount > 0
|
|
47
|
+
? await factory.create(WalletTokenBlueprint, change, spent.map((e) => e.id))
|
|
48
|
+
: undefined;
|
|
49
|
+
const total = spent.reduce((total, token) => total + getTokenContent(token).proofs.reduce((t, p) => t + p.amount, 0), 0);
|
|
50
|
+
// calculate the amount that was spent
|
|
51
|
+
const diff = total - changeAmount;
|
|
52
|
+
// sign delete and token
|
|
53
|
+
const signedDelete = await factory.sign(deleteDraft);
|
|
54
|
+
const signedToken = changeDraft && (await factory.sign(changeDraft));
|
|
55
|
+
// create a history entry
|
|
56
|
+
const history = await factory.create(WalletHistoryBlueprint, { direction: "out", mint: change.mint, amount: diff, created: signedToken ? [signedToken.id] : [] }, []);
|
|
57
|
+
// sign history
|
|
58
|
+
const signedHistory = await factory.sign(history);
|
|
59
|
+
// publish events
|
|
60
|
+
yield signedDelete;
|
|
61
|
+
if (signedToken)
|
|
62
|
+
yield signedToken;
|
|
63
|
+
yield signedHistory;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Action } from "applesauce-actions";
|
|
2
|
+
/** An action that creates a new 17375 wallet event and 375 wallet backup */
|
|
3
|
+
export declare function CreateWallet(mints: string[], privateKey?: Uint8Array): Action;
|
|
4
|
+
/**
|
|
5
|
+
* Adds a private key to a wallet event
|
|
6
|
+
* @throws if the wallet does not exist or is locked
|
|
7
|
+
*/
|
|
8
|
+
export declare function WalletAddPrivateKey(privateKey: Uint8Array): Action;
|
|
9
|
+
/** Unlocks the wallet event and optionally the tokens and history events */
|
|
10
|
+
export declare function UnlockWallet(unlock?: {
|
|
11
|
+
history?: boolean;
|
|
12
|
+
tokens?: boolean;
|
|
13
|
+
}): Action;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getWalletMints, getWalletPrivateKey, isWalletLocked, unlockWallet, WALLET_KIND } from "../helpers/wallet.js";
|
|
2
|
+
import { WalletBackupBlueprint, WalletBlueprint } from "../blueprints/wallet.js";
|
|
3
|
+
import { isTokenContentLocked, unlockTokenContent, WALLET_TOKEN_KIND } from "../helpers/tokens.js";
|
|
4
|
+
import { isHistoryContentLocked, unlockHistoryContent, WALLET_HISTORY_KIND } from "../helpers/history.js";
|
|
5
|
+
/** An action that creates a new 17375 wallet event and 375 wallet backup */
|
|
6
|
+
export function CreateWallet(mints, privateKey) {
|
|
7
|
+
return async function* ({ events, factory, self }) {
|
|
8
|
+
const existing = events.getReplaceable(WALLET_KIND, self);
|
|
9
|
+
if (existing)
|
|
10
|
+
throw new Error("Wallet already exists");
|
|
11
|
+
const wallet = await factory.sign(await factory.create(WalletBlueprint, mints, privateKey));
|
|
12
|
+
const backup = await factory.sign(await factory.create(WalletBackupBlueprint, wallet));
|
|
13
|
+
// publish the backup first
|
|
14
|
+
yield backup;
|
|
15
|
+
yield wallet;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Adds a private key to a wallet event
|
|
20
|
+
* @throws if the wallet does not exist or is locked
|
|
21
|
+
*/
|
|
22
|
+
export function WalletAddPrivateKey(privateKey) {
|
|
23
|
+
return async function* ({ events, self, factory }) {
|
|
24
|
+
const wallet = events.getReplaceable(WALLET_KIND, self);
|
|
25
|
+
if (!wallet)
|
|
26
|
+
throw new Error("Wallet does not exist");
|
|
27
|
+
if (isWalletLocked(wallet))
|
|
28
|
+
throw new Error("Wallet is locked");
|
|
29
|
+
if (getWalletPrivateKey(wallet))
|
|
30
|
+
throw new Error("Wallet already has a private key");
|
|
31
|
+
const draft = await factory.create(WalletBlueprint, getWalletMints(wallet), privateKey);
|
|
32
|
+
const signed = await factory.sign(draft);
|
|
33
|
+
// create backup event for wallet
|
|
34
|
+
const backup = await factory.sign(await factory.create(WalletBackupBlueprint, signed));
|
|
35
|
+
// publish events
|
|
36
|
+
yield backup;
|
|
37
|
+
yield signed;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Unlocks the wallet event and optionally the tokens and history events */
|
|
41
|
+
export function UnlockWallet(unlock) {
|
|
42
|
+
return async function* ({ events, self, factory }) {
|
|
43
|
+
const signer = factory.context.signer;
|
|
44
|
+
if (!signer)
|
|
45
|
+
throw new Error("Missing signer");
|
|
46
|
+
const wallet = events.getReplaceable(WALLET_KIND, self);
|
|
47
|
+
if (!wallet)
|
|
48
|
+
throw new Error("Wallet does not exist");
|
|
49
|
+
if (isWalletLocked(wallet))
|
|
50
|
+
await unlockWallet(wallet, signer);
|
|
51
|
+
if (unlock?.tokens) {
|
|
52
|
+
const tokens = events.getTimeline({ kinds: [WALLET_TOKEN_KIND], authors: [self] });
|
|
53
|
+
for (const token of tokens)
|
|
54
|
+
if (isTokenContentLocked(token))
|
|
55
|
+
await unlockTokenContent(token, signer);
|
|
56
|
+
}
|
|
57
|
+
if (unlock?.history) {
|
|
58
|
+
const history = events.getTimeline({ kinds: [WALLET_HISTORY_KIND], authors: [self] });
|
|
59
|
+
for (const entry of history)
|
|
60
|
+
if (isHistoryContentLocked(entry))
|
|
61
|
+
await unlockHistoryContent(entry, signer);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { EventPointer } from "nostr-tools/nip19";
|
|
2
|
+
import { HistoryContent } from "../helpers/history.js";
|
|
3
|
+
import { EventBlueprint } from "applesauce-factory";
|
|
4
|
+
/** A blueprint that creates a wallet history event */
|
|
5
|
+
export declare function WalletHistoryBlueprint(content: HistoryContent, redeemed: (string | EventPointer)[]): EventBlueprint;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { WALLET_HISTORY_KIND } from "../helpers/history.js";
|
|
2
|
+
import { EventFactory } from "applesauce-factory";
|
|
3
|
+
import { setHistoryContent, setHistoryRedeemed } from "../operations/event/history.js";
|
|
4
|
+
/** A blueprint that creates a wallet history event */
|
|
5
|
+
export function WalletHistoryBlueprint(content, redeemed) {
|
|
6
|
+
return (ctx) => EventFactory.runProcess({ kind: WALLET_HISTORY_KIND }, ctx,
|
|
7
|
+
// set the encrypted tags on the event
|
|
8
|
+
setHistoryContent(content),
|
|
9
|
+
// set the public redeemed tags
|
|
10
|
+
setHistoryRedeemed(redeemed));
|
|
11
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Token } from "@cashu/cashu-ts";
|
|
2
|
+
import { EventBlueprint } from "applesauce-factory";
|
|
3
|
+
/**
|
|
4
|
+
* A blueprint for a wallet token event, takes a cashu token and previous deleted token event ids
|
|
5
|
+
* @param token the cashu token to store
|
|
6
|
+
* @param [del=[]] an array of previous token event ids that are deleted
|
|
7
|
+
*/
|
|
8
|
+
export declare function WalletTokenBlueprint(token: Token, del?: string[]): EventBlueprint;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { EventFactory } from "applesauce-factory";
|
|
2
|
+
import { WALLET_TOKEN_KIND } from "../helpers/tokens.js";
|
|
3
|
+
import { setTokenContent } from "../operations/event/tokens.js";
|
|
4
|
+
/**
|
|
5
|
+
* A blueprint for a wallet token event, takes a cashu token and previous deleted token event ids
|
|
6
|
+
* @param token the cashu token to store
|
|
7
|
+
* @param [del=[]] an array of previous token event ids that are deleted
|
|
8
|
+
*/
|
|
9
|
+
export function WalletTokenBlueprint(token, del = []) {
|
|
10
|
+
return (ctx) => EventFactory.runProcess({ kind: WALLET_TOKEN_KIND }, ctx, setTokenContent(token, del));
|
|
11
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { EventBlueprint } from "applesauce-factory";
|
|
2
|
+
import { NostrEvent } from "nostr-tools";
|
|
3
|
+
/** A blueprint to create a new 17375 wallet */
|
|
4
|
+
export declare function WalletBlueprint(mints: string[], privateKey?: Uint8Array): EventBlueprint;
|
|
5
|
+
/** A blueprint that creates a new 375 wallet backup event */
|
|
6
|
+
export declare function WalletBackupBlueprint(wallet: NostrEvent): EventBlueprint;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { EventFactory } from "applesauce-factory";
|
|
2
|
+
import { modifyHiddenTags } from "applesauce-factory/operations/event";
|
|
3
|
+
import { WALLET_BACKUP_KIND, WALLET_KIND } from "../helpers/wallet.js";
|
|
4
|
+
import { setWalletBackupContent } from "../operations/event/wallet.js";
|
|
5
|
+
import { setMintTags, setPrivateKeyTag } from "../operations/tag/wallet.js";
|
|
6
|
+
/** A blueprint to create a new 17375 wallet */
|
|
7
|
+
export function WalletBlueprint(mints, privateKey) {
|
|
8
|
+
return (ctx) => EventFactory.runProcess({ kind: WALLET_KIND }, ctx, modifyHiddenTags(privateKey ? setPrivateKeyTag(privateKey) : undefined, setMintTags(mints)));
|
|
9
|
+
}
|
|
10
|
+
/** A blueprint that creates a new 375 wallet backup event */
|
|
11
|
+
export function WalletBackupBlueprint(wallet) {
|
|
12
|
+
return (ctx) => EventFactory.runProcess({ kind: WALLET_BACKUP_KIND }, ctx, setWalletBackupContent(wallet));
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getDecodedToken } from "@cashu/cashu-ts";
|
|
3
|
+
import { lastValueFrom, map, take, timer } from "rxjs";
|
|
4
|
+
import { subscribeSpyTo } from "@hirez_io/observer-spy";
|
|
5
|
+
import { receiveAnimated, sendAnimated } from "../animated-qr.js";
|
|
6
|
+
const tokenStr = "cashuBo2FteBtodHRwczovL3Rlc3RudXQuY2FzaHUuc3BhY2VhdWNzYXRhdIGiYWlIAJofKTJT5B5hcIOkYWEQYXN4QDA2M2VlYjgwZDZjODM4NWU4NzYwODVhN2E4NzdkOTkyY2U4N2QwNTRmY2RjYzNiODMwMzhjOWY3MmNmMDY1ZGVhY1ghAynsxZ-OuZfZDcqYTLPYfCqHO7jkGjn97aolgtSYhYykYWSjYWVYIGbp_9B9aztSlZxz7g6Tqx5M_1PuFzJCVEMOVd8XAF8wYXNYINTOU77ODcj28v04pq7ektdf6sq2XuxvMjVE0wK6jFolYXJYIM_gZnUGT5jDOyZiQ-2vG9zYnuWaY8vPoWGe_3sXvrvbpGFhBGFzeEA0MWMwZDk1YTU5ZjkxNTdlYTc5NTJlNGFlMzYzYTI3NTMxNTllNmQ1NGJiNzExMTg5ZDk5YjU1MmYzYjIzZTJiYWNYIQM-49gen_1nPchxbaAiKprVr78VmMRVpHH_Tu9P8TO5mGFko2FlWCB9j7rlpdBH_m7tNYnLpzPhn-nGmS1CcbUfnPzjxy6G92FzWCDdsby7fGM5324T5UEoV858YWzZ9MCY59KgKP362fJDfmFyWCDL73v4FRo7iMe83bfMuEy3RJPtC1Vr1jdOpw2-x-7EAaRhYQFhc3hANjRhZDI3NmExOGNmNDhiMDZmYjdiMGYwOWFiMTU4ZTA0ZmM0NmIxYzA4YzMyNjJlODUxNzZkYTMzMTgyYzQ3YWFjWCECMDpCbNbrgA9FcQEIYxobU7ik_pTl8sByPqHDmkY4azxhZKNhZVggqHGaff9M270EU8LGxRpG_G4rn2bMgjyk3hFFg78ZXRVhc1ggP6DsNsWykwKE94yZF23gpCyapcoqh6DDZdVu0lKn2Z5hclggmPKig-lObsuxi_1XCm7_Y_tqaCcqEDz8eCwVhJ8gq9M";
|
|
7
|
+
const token = getDecodedToken(tokenStr);
|
|
8
|
+
describe("sendAnimated", () => {
|
|
9
|
+
it("should loop", async () => {
|
|
10
|
+
const qr$ = sendAnimated(token, { interval: 0 });
|
|
11
|
+
const spy = subscribeSpyTo(qr$);
|
|
12
|
+
// wait 100ms
|
|
13
|
+
await lastValueFrom(timer(100));
|
|
14
|
+
// should not have competed
|
|
15
|
+
expect(spy.receivedComplete()).toBeFalsy();
|
|
16
|
+
spy.unsubscribe();
|
|
17
|
+
});
|
|
18
|
+
it("should emit parts", async () => {
|
|
19
|
+
const qr$ = sendAnimated(token, { interval: 0 }).pipe(take(6));
|
|
20
|
+
const spy = subscribeSpyTo(qr$);
|
|
21
|
+
// wait 100ms
|
|
22
|
+
await lastValueFrom(qr$);
|
|
23
|
+
// should not have competed
|
|
24
|
+
expect(spy.getValues()).toEqual(Array(6)
|
|
25
|
+
.fill(0)
|
|
26
|
+
.map((_, i) => expect.stringContaining(`ur:bytes/${i + 1}-6/`)));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("receiveAnimated", () => {
|
|
30
|
+
it("should decode animated qr", async () => {
|
|
31
|
+
const qr$ = sendAnimated(token, { interval: 0 }).pipe(receiveAnimated, map((part) => (typeof part === "string" ? getDecodedToken(part) : part)));
|
|
32
|
+
const spy = subscribeSpyTo(qr$);
|
|
33
|
+
await lastValueFrom(qr$);
|
|
34
|
+
expect(spy.getValues()).toEqual([
|
|
35
|
+
expect.any(Number),
|
|
36
|
+
expect.any(Number),
|
|
37
|
+
expect.any(Number),
|
|
38
|
+
expect.any(Number),
|
|
39
|
+
expect.any(Number),
|
|
40
|
+
expect.any(Number),
|
|
41
|
+
token,
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|