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.
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/actions/tokens.d.ts +11 -0
- package/dist/actions/tokens.js +49 -0
- package/dist/helpers/__tests__/tokens.test.d.ts +1 -0
- package/dist/helpers/__tests__/tokens.test.js +54 -0
- package/dist/helpers/tokens.d.ts +12 -1
- package/dist/helpers/tokens.js +35 -1
- package/dist/queries/__tests__/wallet.test.d.ts +1 -0
- package/dist/queries/__tests__/wallet.test.js +29 -0
- package/package.json +5 -5
package/dist/actions/index.d.ts
CHANGED
package/dist/actions/index.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/helpers/tokens.d.ts
CHANGED
|
@@ -17,9 +17,20 @@ export type TokenContent = {
|
|
|
17
17
|
del: string[];
|
|
18
18
|
};
|
|
19
19
|
export declare const TokenContentSymbol: unique symbol;
|
|
20
|
-
|
|
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[];
|
package/dist/helpers/tokens.js
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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-
|
|
83
|
-
"applesauce-core": "0.0.0-next-
|
|
84
|
-
"applesauce-factory": "0.0.0-next-
|
|
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-
|
|
91
|
+
"applesauce-signers": "0.0.0-next-20250312162115",
|
|
92
92
|
"typescript": "^5.7.3",
|
|
93
93
|
"vitest": "^3.0.5"
|
|
94
94
|
},
|